mirror of
https://github.com/murashit/codex-panel.git
synced 2026-07-22 17:30:31 +00:00
Refactor panel cohesion boundaries
This commit is contained in:
parent
e1c76b260a
commit
53e7dd983a
54 changed files with 2121 additions and 1945 deletions
|
|
@ -1,4 +1,4 @@
|
|||
import type { SendShortcut } from "../settings";
|
||||
import type { SendShortcut } from "../settings/model";
|
||||
|
||||
export interface ComposerSendKeyEvent {
|
||||
key: string;
|
||||
|
|
|
|||
22
src/composer/slash-commands.ts
Normal file
22
src/composer/slash-commands.ts
Normal file
|
|
@ -0,0 +1,22 @@
|
|||
export const SLASH_COMMANDS = [
|
||||
{ command: "/new", detail: "Start a new Codex thread, optionally sending a message." },
|
||||
{ command: "/resume", detail: "Resume a recent Codex thread." },
|
||||
{ command: "/fork", detail: "Fork the active Codex thread." },
|
||||
{ command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." },
|
||||
{ command: "/compact", detail: "Compact the current conversation context." },
|
||||
{ command: "/fast", detail: "Toggle fast service tier for subsequent turns." },
|
||||
{ command: "/plan", detail: "Toggle Plan mode, optionally sending a message." },
|
||||
{ command: "/status", detail: "Show current session, context, and usage limits." },
|
||||
{ command: "/doctor", detail: "Show Codex CLI and app-server connection diagnostics." },
|
||||
{ command: "/model", detail: "Show or set the model for subsequent turns." },
|
||||
{ command: "/effort", detail: "Show or set reasoning effort for subsequent turns." },
|
||||
{ command: "/help", detail: "Show available Codex slash commands." },
|
||||
] as const;
|
||||
|
||||
type SlashCommand = (typeof SLASH_COMMANDS)[number]["command"];
|
||||
|
||||
export type SlashCommandName = SlashCommand extends `/${infer Name}` ? Name : never;
|
||||
|
||||
export function slashCommandHelpLines(): string[] {
|
||||
return SLASH_COMMANDS.map((item) => `${item.command} - ${item.detail}`);
|
||||
}
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import type { SkillMetadata } from "../generated/app-server/v2/SkillMetadata";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import { SLASH_COMMANDS, type SlashCommandName } from "../panel/slash-commands";
|
||||
import { getThreadTitle } from "../threads";
|
||||
import { SLASH_COMMANDS, type SlashCommandName } from "./slash-commands";
|
||||
import { getThreadTitle } from "../threads/model";
|
||||
import { shortThreadId } from "../utils";
|
||||
|
||||
export interface ComposerSuggestion {
|
||||
|
|
|
|||
173
src/display/blocks.ts
Normal file
173
src/display/blocks.ts
Normal file
|
|
@ -0,0 +1,173 @@
|
|||
import type { DisplayBlock, DisplayItem, DisplayKind } from "./types";
|
||||
import { pathRelativeToRoot } from "./paths";
|
||||
import { executionState } from "./state";
|
||||
|
||||
export function displayBlocksForItems(
|
||||
items: DisplayItem[],
|
||||
activeTurnId: string | null,
|
||||
workspaceRoot?: string | null,
|
||||
turnDiffs?: ReadonlyMap<string, string>,
|
||||
): DisplayBlock[] {
|
||||
const visibleItems = items.filter(shouldShowDisplayItem);
|
||||
const orderedItems = activeTurnId ? moveActiveTaskProgressToEnd(visibleItems, activeTurnId) : visibleItems;
|
||||
const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot);
|
||||
const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems);
|
||||
const finalAssistantIdByTurn = finalAssistantItemsByTurn(visibleItems);
|
||||
const groupedTurnIds = new Set([...finalAssistantIdByTurn.keys()].filter((turnId) => turnId !== activeTurnId));
|
||||
|
||||
const groupedActivities = new Map<string, DisplayItem[]>();
|
||||
for (const item of orderedItems) {
|
||||
if (!item.turnId || !groupedTurnIds.has(item.turnId) || !isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) continue;
|
||||
const group = groupedActivities.get(item.turnId) ?? [];
|
||||
group.push(item);
|
||||
groupedActivities.set(item.turnId, group);
|
||||
}
|
||||
|
||||
const emittedGroups = new Set<string>();
|
||||
const blocks: DisplayBlock[] = [];
|
||||
for (const item of orderedItems) {
|
||||
const turnId = item.turnId;
|
||||
if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) {
|
||||
if (!emittedGroups.has(turnId)) {
|
||||
const groupItems = groupedActivities.get(turnId) ?? [];
|
||||
blocks.push({
|
||||
type: "activityGroup",
|
||||
id: `turn-${turnId}-activity`,
|
||||
turnId,
|
||||
summary: turnActivitySummary(groupItems),
|
||||
items: groupItems,
|
||||
});
|
||||
emittedGroups.add(turnId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "item",
|
||||
item: itemWithTurnSummaries(item, editedFilesByTurn, autoReviewSummariesByTurn, finalAssistantIdByTurn, turnDiffs),
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function moveActiveTaskProgressToEnd(items: DisplayItem[], activeTurnId: string): DisplayItem[] {
|
||||
const activeTaskProgress = items.filter((item) => item.kind === "taskProgress" && item.turnId === activeTurnId);
|
||||
if (activeTaskProgress.length === 0) return items;
|
||||
return [...items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId), ...activeTaskProgress];
|
||||
}
|
||||
|
||||
function shouldShowDisplayItem(item: DisplayItem): boolean {
|
||||
return item.kind !== "reasoning" || executionState(item) !== "completed" || item.text.trim().length > 0;
|
||||
}
|
||||
|
||||
function isCompletedTurnDetailItem(item: DisplayItem, finalAssistantIdByTurn: Map<string, string>): boolean {
|
||||
if (!item.turnId || item.role === "user") return false;
|
||||
return finalAssistantIdByTurn.get(item.turnId) !== item.id;
|
||||
}
|
||||
|
||||
function finalAssistantItemsByTurn(items: DisplayItem[]): Map<string, string> {
|
||||
const finalAssistantIdByTurn = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
|
||||
finalAssistantIdByTurn.set(item.turnId, item.id);
|
||||
}
|
||||
return finalAssistantIdByTurn;
|
||||
}
|
||||
|
||||
function isFinalAssistantMessage(item: DisplayItem): boolean {
|
||||
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
|
||||
}
|
||||
|
||||
function itemWithTurnSummaries(
|
||||
item: DisplayItem,
|
||||
editedFilesByTurn: Map<string, string[]>,
|
||||
autoReviewSummariesByTurn: Map<string, string[]>,
|
||||
finalAssistantIdByTurn: Map<string, string>,
|
||||
turnDiffs?: ReadonlyMap<string, string>,
|
||||
): DisplayItem {
|
||||
if (!item.turnId || finalAssistantIdByTurn.get(item.turnId) !== item.id) return item;
|
||||
if (item.kind !== "message") return item;
|
||||
const editedFiles = editedFilesByTurn.get(item.turnId);
|
||||
const autoReviewSummaries = autoReviewSummariesByTurn.get(item.turnId);
|
||||
const diff = turnDiffs?.get(item.turnId);
|
||||
const turnDiff = diff && diff.trim().length > 0 ? { diff } : undefined;
|
||||
if ((!editedFiles || editedFiles.length === 0) && (!autoReviewSummaries || autoReviewSummaries.length === 0) && !turnDiff) return item;
|
||||
return {
|
||||
...item,
|
||||
...(editedFiles && editedFiles.length > 0 ? { editedFiles } : {}),
|
||||
...(turnDiff ? { turnDiff } : {}),
|
||||
...(autoReviewSummaries && autoReviewSummaries.length > 0 ? { autoReviewSummaries } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function editedFilesForTurns(items: DisplayItem[], workspaceRoot?: string | null): Map<string, string[]> {
|
||||
const byTurn = new Map<string, Set<string>>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || item.kind !== "fileChange") continue;
|
||||
const files = editedFilesForItem(item, workspaceRoot);
|
||||
if (files.length === 0) continue;
|
||||
const set = byTurn.get(item.turnId) ?? new Set<string>();
|
||||
files.forEach((file) => set.add(file));
|
||||
byTurn.set(item.turnId, set);
|
||||
}
|
||||
|
||||
return new Map([...byTurn].map(([turnId, files]) => [turnId, [...files].sort((a, b) => a.localeCompare(b))]));
|
||||
}
|
||||
|
||||
function editedFilesForItem(item: DisplayItem, workspaceRoot?: string | null): string[] {
|
||||
if (item.kind !== "fileChange") return [];
|
||||
return item.changes.flatMap((change) =>
|
||||
change.path && change.path !== "(unknown)" ? [pathRelativeToRoot(change.path, workspaceRoot)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function autoReviewSummariesForTurns(items: DisplayItem[]): Map<string, string[]> {
|
||||
const byTurn = new Map<string, string[]>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || item.kind !== "reviewResult") continue;
|
||||
const summary = item.text.trim();
|
||||
if (!summary) continue;
|
||||
const summaries = byTurn.get(item.turnId) ?? [];
|
||||
if (!summaries.includes(summary)) summaries.push(summary);
|
||||
byTurn.set(item.turnId, summaries);
|
||||
}
|
||||
return byTurn;
|
||||
}
|
||||
|
||||
function turnActivitySummary(items: DisplayItem[]): string {
|
||||
const parts = [
|
||||
countMatchingLabel(items, (item) => item.kind === "message" && item.role === "assistant", "response", "responses"),
|
||||
countLabel(items, "taskProgress", "task progress"),
|
||||
countLabel(items, "agent", "agent"),
|
||||
countLabel(items, "command", "command"),
|
||||
countLabel(items, "fileChange", "file change"),
|
||||
countLabel(items, "tool", "tool"),
|
||||
countLabel(items, "hook", "hook"),
|
||||
countLabel(items, "reasoning", "thought", "thought notes"),
|
||||
countLabel(items, "approvalResult", "approval"),
|
||||
countLabel(items, "userInputResult", "input"),
|
||||
countLabel(items, "reviewResult", "review"),
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
if (parts.length === 0) return "Work details";
|
||||
return `Work details: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
function countMatchingLabel(
|
||||
items: DisplayItem[],
|
||||
predicate: (item: DisplayItem) => boolean,
|
||||
label: string,
|
||||
pluralLabel = `${label}s`,
|
||||
): string | null {
|
||||
const count = items.filter(predicate).length;
|
||||
if (count === 0) return null;
|
||||
if (count === 1) return label;
|
||||
return `${count} ${pluralLabel}`;
|
||||
}
|
||||
|
||||
function countLabel(items: DisplayItem[], kind: DisplayKind, label: string, pluralLabel = `${label}s`): string | null {
|
||||
const count = items.filter((item) => item.kind === kind).length;
|
||||
if (count === 0) return null;
|
||||
if (count === 1) return label;
|
||||
return `${count} ${pluralLabel}`;
|
||||
}
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { DisplayBlock, DisplayDetailSection, DisplayFileChange, DisplayItem, DisplayKind } from "./types";
|
||||
import type { DisplayDetailSection, DisplayFileChange, DisplayItem } from "./types";
|
||||
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
|
||||
import type { Turn } from "../generated/app-server/v2/Turn";
|
||||
import type { TurnPlanStep } from "../generated/app-server/v2/TurnPlanStep";
|
||||
|
|
@ -6,7 +6,8 @@ import { inputToText, truncate } from "../utils";
|
|||
import { taskStatusMarker } from "./labels";
|
||||
import { agentDisplayItem } from "./agent";
|
||||
import { pathRelativeToRoot } from "./paths";
|
||||
import { classifyExecutionState, executionState } from "./state";
|
||||
import { normalizeProposedPlanMarkdown } from "./plan";
|
||||
import { classifyExecutionState } from "./state";
|
||||
import {
|
||||
bodyDetail,
|
||||
compactToolSummary,
|
||||
|
|
@ -17,8 +18,19 @@ import {
|
|||
statusQualifier,
|
||||
} from "./tool-format";
|
||||
export { activeAgentRunSummary, agentDisplayItem } from "./agent";
|
||||
export { displayBlocksForItems } from "./blocks";
|
||||
export { normalizeProposedPlanMarkdown } from "./plan";
|
||||
export { classifyExecutionState, executionState, executionStateLabel } from "./state";
|
||||
export { createAutoReviewResultItem, createReviewResultItem } from "./review";
|
||||
export {
|
||||
appendAssistantDelta,
|
||||
appendItemOutput,
|
||||
appendItemText,
|
||||
appendPlanDelta,
|
||||
appendToolOutput,
|
||||
completeReasoningItems,
|
||||
upsertDisplayItem,
|
||||
} from "./stream-updates";
|
||||
|
||||
export function displayItemsFromTurns(turns: Turn[]): DisplayItem[] {
|
||||
const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0));
|
||||
|
|
@ -457,384 +469,10 @@ export function shouldSuppressLifecycleItem(item: ThreadItem): boolean {
|
|||
return item.type === "agentMessage" || item.type === "userMessage";
|
||||
}
|
||||
|
||||
export function upsertDisplayItem(items: DisplayItem[], next: DisplayItem): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.id === next.id);
|
||||
if (index === -1) return [...items, next];
|
||||
const copy = [...items];
|
||||
const previous = copy[index];
|
||||
copy[index] = {
|
||||
...previous,
|
||||
...next,
|
||||
output: mergeOutput(previous, next),
|
||||
changes: mergeChanges(previous, next),
|
||||
} as DisplayItem;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function mergeOutput(previous: DisplayItem, next: DisplayItem): string | undefined {
|
||||
const previousOutput = "output" in previous ? previous.output : undefined;
|
||||
const nextOutput = "output" in next ? next.output : undefined;
|
||||
return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput;
|
||||
}
|
||||
|
||||
function mergeChanges(previous: DisplayItem, next: DisplayItem): DisplayFileChange[] | undefined {
|
||||
const previousChanges = previous.kind === "fileChange" ? previous.changes : undefined;
|
||||
const nextChanges = next.kind === "fileChange" ? next.changes : undefined;
|
||||
return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges;
|
||||
}
|
||||
|
||||
export function appendAssistantDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && item.kind === "message"
|
||||
? {
|
||||
...item,
|
||||
text: `${item.text}${delta}`,
|
||||
copyText: `${item.text}${delta}`,
|
||||
turnId: item.turnId ?? turnId,
|
||||
markdown: false,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: delta,
|
||||
copyText: delta,
|
||||
turnId,
|
||||
itemId,
|
||||
markdown: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function completeReasoningItems(items: DisplayItem[], turnId: string): DisplayItem[] {
|
||||
return items.map((item) =>
|
||||
item.kind === "reasoning" && item.turnId === turnId
|
||||
? {
|
||||
...item,
|
||||
status: "completed",
|
||||
state: "completed",
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function appendPlanDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && item.kind === "message" ? appendPlanDeltaToMessage(item, turnId, delta) : item,
|
||||
);
|
||||
}
|
||||
const text = normalizeProposedPlanMarkdown(delta);
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text,
|
||||
copyText: text,
|
||||
turnId,
|
||||
itemId,
|
||||
markdown: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function appendPlanDeltaToMessage(item: Extract<DisplayItem, { kind: "message" }>, turnId: string, delta: string): DisplayItem {
|
||||
const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`);
|
||||
return {
|
||||
...item,
|
||||
text,
|
||||
copyText: text,
|
||||
turnId: item.turnId ?? turnId,
|
||||
markdown: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeProposedPlanMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*<proposed_plan>\s*\n?/i, "")
|
||||
.replace(/\n?\s*<\/proposed_plan>\s*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
|
||||
export function appendItemText(
|
||||
items: DisplayItem[],
|
||||
itemId: string,
|
||||
turnId: string,
|
||||
label: string,
|
||||
delta: string,
|
||||
kind: Extract<DisplayKind, "tool" | "hook" | "reasoning"> = "tool",
|
||||
): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId);
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item));
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind,
|
||||
role: "tool",
|
||||
text: `${label}: ${delta}`,
|
||||
turnId,
|
||||
itemId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function appendToolOutput(
|
||||
items: DisplayItem[],
|
||||
itemId: string,
|
||||
turnId: string,
|
||||
delta: string,
|
||||
fallbackLabel: string,
|
||||
): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId);
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning")
|
||||
? { ...item, output: `${item.output ?? ""}${delta}` }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind: "tool",
|
||||
role: "tool",
|
||||
text: "details",
|
||||
toolLabel: fallbackLabel,
|
||||
turnId,
|
||||
itemId,
|
||||
output: delta,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function appendItemOutput(
|
||||
items: DisplayItem[],
|
||||
itemId: string,
|
||||
turnId: string,
|
||||
delta: string,
|
||||
kind: "command" | "fileChange",
|
||||
fallbackText: string,
|
||||
): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId);
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && (item.kind === "command" || item.kind === "fileChange")
|
||||
? { ...item, output: `${item.output ?? ""}${delta}` }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind,
|
||||
role: "tool",
|
||||
text: fallbackText,
|
||||
turnId,
|
||||
itemId,
|
||||
output: delta,
|
||||
...(kind === "fileChange"
|
||||
? {
|
||||
status: "inProgress",
|
||||
changes: [],
|
||||
}
|
||||
: {
|
||||
command: fallbackText,
|
||||
cwd: "(unknown)",
|
||||
status: "running",
|
||||
}),
|
||||
},
|
||||
] as DisplayItem[];
|
||||
}
|
||||
|
||||
export function displayBlocksForItems(
|
||||
items: DisplayItem[],
|
||||
activeTurnId: string | null,
|
||||
workspaceRoot?: string | null,
|
||||
turnDiffs?: ReadonlyMap<string, string>,
|
||||
): DisplayBlock[] {
|
||||
const visibleItems = items.filter(shouldShowDisplayItem);
|
||||
const orderedItems = activeTurnId ? moveActiveTaskProgressToEnd(visibleItems, activeTurnId) : visibleItems;
|
||||
const editedFilesByTurn = editedFilesForTurns(visibleItems, workspaceRoot);
|
||||
const autoReviewSummariesByTurn = autoReviewSummariesForTurns(visibleItems);
|
||||
const finalAssistantIdByTurn = finalAssistantItemsByTurn(visibleItems);
|
||||
const groupedTurnIds = new Set([...finalAssistantIdByTurn.keys()].filter((turnId) => turnId !== activeTurnId));
|
||||
|
||||
const groupedActivities = new Map<string, DisplayItem[]>();
|
||||
for (const item of orderedItems) {
|
||||
if (!item.turnId || !groupedTurnIds.has(item.turnId) || !isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) continue;
|
||||
const group = groupedActivities.get(item.turnId) ?? [];
|
||||
group.push(item);
|
||||
groupedActivities.set(item.turnId, group);
|
||||
}
|
||||
|
||||
const emittedGroups = new Set<string>();
|
||||
const blocks: DisplayBlock[] = [];
|
||||
for (const item of orderedItems) {
|
||||
const turnId = item.turnId;
|
||||
if (turnId && groupedActivities.has(turnId) && isCompletedTurnDetailItem(item, finalAssistantIdByTurn)) {
|
||||
if (!emittedGroups.has(turnId)) {
|
||||
const groupItems = groupedActivities.get(turnId) ?? [];
|
||||
blocks.push({
|
||||
type: "activityGroup",
|
||||
id: `turn-${turnId}-activity`,
|
||||
turnId,
|
||||
summary: turnActivitySummary(groupItems),
|
||||
items: groupItems,
|
||||
});
|
||||
emittedGroups.add(turnId);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
blocks.push({
|
||||
type: "item",
|
||||
item: itemWithTurnSummaries(item, editedFilesByTurn, autoReviewSummariesByTurn, finalAssistantIdByTurn, turnDiffs),
|
||||
});
|
||||
}
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
function moveActiveTaskProgressToEnd(items: DisplayItem[], activeTurnId: string): DisplayItem[] {
|
||||
const activeTaskProgress = items.filter((item) => item.kind === "taskProgress" && item.turnId === activeTurnId);
|
||||
if (activeTaskProgress.length === 0) return items;
|
||||
return [...items.filter((item) => item.kind !== "taskProgress" || item.turnId !== activeTurnId), ...activeTaskProgress];
|
||||
}
|
||||
|
||||
function shouldShowDisplayItem(item: DisplayItem): boolean {
|
||||
return item.kind !== "reasoning" || executionState(item) !== "completed" || item.text.trim().length > 0;
|
||||
}
|
||||
|
||||
function isCompletedTurnDetailItem(item: DisplayItem, finalAssistantIdByTurn: Map<string, string>): boolean {
|
||||
if (!item.turnId || item.role === "user") return false;
|
||||
return finalAssistantIdByTurn.get(item.turnId) !== item.id;
|
||||
}
|
||||
|
||||
function finalAssistantItemsByTurn(items: DisplayItem[]): Map<string, string> {
|
||||
const finalAssistantIdByTurn = new Map<string, string>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || !isFinalAssistantMessage(item)) continue;
|
||||
finalAssistantIdByTurn.set(item.turnId, item.id);
|
||||
}
|
||||
return finalAssistantIdByTurn;
|
||||
}
|
||||
|
||||
function isFinalAssistantMessage(item: DisplayItem): boolean {
|
||||
return item.kind === "message" && item.role === "assistant" && item.markdown !== false;
|
||||
}
|
||||
|
||||
function itemWithTurnSummaries(
|
||||
item: DisplayItem,
|
||||
editedFilesByTurn: Map<string, string[]>,
|
||||
autoReviewSummariesByTurn: Map<string, string[]>,
|
||||
finalAssistantIdByTurn: Map<string, string>,
|
||||
turnDiffs?: ReadonlyMap<string, string>,
|
||||
): DisplayItem {
|
||||
if (!item.turnId || finalAssistantIdByTurn.get(item.turnId) !== item.id) return item;
|
||||
if (item.kind !== "message") return item;
|
||||
const editedFiles = editedFilesByTurn.get(item.turnId);
|
||||
const autoReviewSummaries = autoReviewSummariesByTurn.get(item.turnId);
|
||||
const diff = turnDiffs?.get(item.turnId);
|
||||
const turnDiff = diff && diff.trim().length > 0 ? { diff } : undefined;
|
||||
if ((!editedFiles || editedFiles.length === 0) && (!autoReviewSummaries || autoReviewSummaries.length === 0) && !turnDiff) return item;
|
||||
return {
|
||||
...item,
|
||||
...(editedFiles && editedFiles.length > 0 ? { editedFiles } : {}),
|
||||
...(turnDiff ? { turnDiff } : {}),
|
||||
...(autoReviewSummaries && autoReviewSummaries.length > 0 ? { autoReviewSummaries } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function editedFilesForTurns(items: DisplayItem[], workspaceRoot?: string | null): Map<string, string[]> {
|
||||
const byTurn = new Map<string, Set<string>>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || item.kind !== "fileChange") continue;
|
||||
const files = editedFilesForItem(item, workspaceRoot);
|
||||
if (files.length === 0) continue;
|
||||
const set = byTurn.get(item.turnId) ?? new Set<string>();
|
||||
files.forEach((file) => set.add(file));
|
||||
byTurn.set(item.turnId, set);
|
||||
}
|
||||
|
||||
return new Map([...byTurn].map(([turnId, files]) => [turnId, [...files].sort((a, b) => a.localeCompare(b))]));
|
||||
}
|
||||
|
||||
function editedFilesForItem(item: DisplayItem, workspaceRoot?: string | null): string[] {
|
||||
if (item.kind !== "fileChange") return [];
|
||||
return item.changes.flatMap((change) =>
|
||||
change.path && change.path !== "(unknown)" ? [pathRelativeToWorkspace(change.path, workspaceRoot)] : [],
|
||||
);
|
||||
}
|
||||
|
||||
function autoReviewSummariesForTurns(items: DisplayItem[]): Map<string, string[]> {
|
||||
const byTurn = new Map<string, string[]>();
|
||||
for (const item of items) {
|
||||
if (!item.turnId || item.kind !== "reviewResult") continue;
|
||||
const summary = item.text.trim();
|
||||
if (!summary) continue;
|
||||
const summaries = byTurn.get(item.turnId) ?? [];
|
||||
if (!summaries.includes(summary)) summaries.push(summary);
|
||||
byTurn.set(item.turnId, summaries);
|
||||
}
|
||||
return byTurn;
|
||||
}
|
||||
|
||||
export function pathRelativeToWorkspace(path: string, workspaceRoot?: string | null): string {
|
||||
return pathRelativeToRoot(path, workspaceRoot);
|
||||
}
|
||||
|
||||
function turnActivitySummary(items: DisplayItem[]): string {
|
||||
const parts = [
|
||||
countMatchingLabel(items, (item) => item.kind === "message" && item.role === "assistant", "response", "responses"),
|
||||
countLabel(items, "taskProgress", "task progress"),
|
||||
countLabel(items, "agent", "agent"),
|
||||
countLabel(items, "command", "command"),
|
||||
countLabel(items, "fileChange", "file change"),
|
||||
countLabel(items, "tool", "tool"),
|
||||
countLabel(items, "hook", "hook"),
|
||||
countLabel(items, "reasoning", "thought", "thought notes"),
|
||||
countLabel(items, "approvalResult", "approval"),
|
||||
countLabel(items, "userInputResult", "input"),
|
||||
countLabel(items, "reviewResult", "review"),
|
||||
].filter((part): part is string => Boolean(part));
|
||||
|
||||
if (parts.length === 0) return "Work details";
|
||||
return `Work details: ${parts.join(", ")}`;
|
||||
}
|
||||
|
||||
function countMatchingLabel(
|
||||
items: DisplayItem[],
|
||||
predicate: (item: DisplayItem) => boolean,
|
||||
label: string,
|
||||
pluralLabel = `${label}s`,
|
||||
): string | null {
|
||||
const count = items.filter(predicate).length;
|
||||
if (count === 0) return null;
|
||||
if (count === 1) return label;
|
||||
return `${count} ${pluralLabel}`;
|
||||
}
|
||||
|
||||
function countLabel(items: DisplayItem[], kind: DisplayKind, label: string, pluralLabel = `${label}s`): string | null {
|
||||
const count = items.filter((item) => item.kind === kind).length;
|
||||
if (count === 0) return null;
|
||||
if (count === 1) return label;
|
||||
return `${count} ${pluralLabel}`;
|
||||
}
|
||||
|
||||
export function createSystemItem(text: string): DisplayItem {
|
||||
return {
|
||||
id: `system-${Date.now()}-${Math.random().toString(36).slice(2)}`,
|
||||
|
|
|
|||
6
src/display/plan.ts
Normal file
6
src/display/plan.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
export function normalizeProposedPlanMarkdown(text: string): string {
|
||||
return text
|
||||
.replace(/^\s*<proposed_plan>\s*\n?/i, "")
|
||||
.replace(/\n?\s*<\/proposed_plan>\s*$/i, "")
|
||||
.trim();
|
||||
}
|
||||
199
src/display/stream-updates.ts
Normal file
199
src/display/stream-updates.ts
Normal file
|
|
@ -0,0 +1,199 @@
|
|||
import type { DisplayFileChange, DisplayItem, DisplayKind } from "./types";
|
||||
import { normalizeProposedPlanMarkdown } from "./plan";
|
||||
|
||||
export function upsertDisplayItem(items: DisplayItem[], next: DisplayItem): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.id === next.id);
|
||||
if (index === -1) return [...items, next];
|
||||
const copy = [...items];
|
||||
const previous = copy[index];
|
||||
copy[index] = {
|
||||
...previous,
|
||||
...next,
|
||||
output: mergeOutput(previous, next),
|
||||
changes: mergeChanges(previous, next),
|
||||
} as DisplayItem;
|
||||
return copy;
|
||||
}
|
||||
|
||||
function mergeOutput(previous: DisplayItem, next: DisplayItem): string | undefined {
|
||||
const previousOutput = "output" in previous ? previous.output : undefined;
|
||||
const nextOutput = "output" in next ? next.output : undefined;
|
||||
return nextOutput && nextOutput.length > 0 ? nextOutput : previousOutput;
|
||||
}
|
||||
|
||||
function mergeChanges(previous: DisplayItem, next: DisplayItem): DisplayFileChange[] | undefined {
|
||||
const previousChanges = previous.kind === "fileChange" ? previous.changes : undefined;
|
||||
const nextChanges = next.kind === "fileChange" ? next.changes : undefined;
|
||||
return nextChanges && nextChanges.length > 0 ? nextChanges : previousChanges;
|
||||
}
|
||||
|
||||
export function appendAssistantDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && item.kind === "message"
|
||||
? {
|
||||
...item,
|
||||
text: `${item.text}${delta}`,
|
||||
copyText: `${item.text}${delta}`,
|
||||
turnId: item.turnId ?? turnId,
|
||||
markdown: false,
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text: delta,
|
||||
copyText: delta,
|
||||
turnId,
|
||||
itemId,
|
||||
markdown: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function completeReasoningItems(items: DisplayItem[], turnId: string): DisplayItem[] {
|
||||
return items.map((item) =>
|
||||
item.kind === "reasoning" && item.turnId === turnId
|
||||
? {
|
||||
...item,
|
||||
status: "completed",
|
||||
state: "completed",
|
||||
}
|
||||
: item,
|
||||
);
|
||||
}
|
||||
|
||||
export function appendPlanDelta(items: DisplayItem[], itemId: string, turnId: string, delta: string): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId && item.kind === "message" && item.role === "assistant");
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && item.kind === "message" ? appendPlanDeltaToMessage(item, turnId, delta) : item,
|
||||
);
|
||||
}
|
||||
const text = normalizeProposedPlanMarkdown(delta);
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind: "message",
|
||||
role: "assistant",
|
||||
text,
|
||||
copyText: text,
|
||||
turnId,
|
||||
itemId,
|
||||
markdown: false,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function appendPlanDeltaToMessage(item: Extract<DisplayItem, { kind: "message" }>, turnId: string, delta: string): DisplayItem {
|
||||
const text = normalizeProposedPlanMarkdown(`${item.text}${delta}`);
|
||||
return {
|
||||
...item,
|
||||
text,
|
||||
copyText: text,
|
||||
turnId: item.turnId ?? turnId,
|
||||
markdown: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function appendItemText(
|
||||
items: DisplayItem[],
|
||||
itemId: string,
|
||||
turnId: string,
|
||||
label: string,
|
||||
delta: string,
|
||||
kind: Extract<DisplayKind, "tool" | "hook" | "reasoning"> = "tool",
|
||||
): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId);
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) => (itemIndex === index ? { ...item, text: `${item.text}${delta}` } : item));
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind,
|
||||
role: "tool",
|
||||
text: `${label}: ${delta}`,
|
||||
turnId,
|
||||
itemId,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function appendToolOutput(
|
||||
items: DisplayItem[],
|
||||
itemId: string,
|
||||
turnId: string,
|
||||
delta: string,
|
||||
fallbackLabel: string,
|
||||
): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId);
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && (item.kind === "tool" || item.kind === "hook" || item.kind === "reasoning")
|
||||
? { ...item, output: `${item.output ?? ""}${delta}` }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind: "tool",
|
||||
role: "tool",
|
||||
text: "details",
|
||||
toolLabel: fallbackLabel,
|
||||
turnId,
|
||||
itemId,
|
||||
output: delta,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function appendItemOutput(
|
||||
items: DisplayItem[],
|
||||
itemId: string,
|
||||
turnId: string,
|
||||
delta: string,
|
||||
kind: "command" | "fileChange",
|
||||
fallbackText: string,
|
||||
): DisplayItem[] {
|
||||
const index = items.findIndex((item) => item.itemId === itemId);
|
||||
if (index !== -1) {
|
||||
return items.map((item, itemIndex) =>
|
||||
itemIndex === index && (item.kind === "command" || item.kind === "fileChange")
|
||||
? { ...item, output: `${item.output ?? ""}${delta}` }
|
||||
: item,
|
||||
);
|
||||
}
|
||||
return [
|
||||
...items,
|
||||
{
|
||||
id: itemId,
|
||||
kind,
|
||||
role: "tool",
|
||||
text: fallbackText,
|
||||
turnId,
|
||||
itemId,
|
||||
output: delta,
|
||||
...(kind === "fileChange"
|
||||
? {
|
||||
status: "inProgress",
|
||||
changes: [],
|
||||
}
|
||||
: {
|
||||
command: fallbackText,
|
||||
cwd: "(unknown)",
|
||||
status: "running",
|
||||
}),
|
||||
},
|
||||
] as DisplayItem[];
|
||||
}
|
||||
1367
src/main.ts
1367
src/main.ts
File diff suppressed because it is too large
Load diff
228
src/panel/composer-controller.ts
Normal file
228
src/panel/composer-controller.ts
Normal file
|
|
@ -0,0 +1,228 @@
|
|||
import type { App, EventRef } from "obsidian";
|
||||
|
||||
import { isComposerSendKey } from "../composer/keys";
|
||||
import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "../composer/obsidian-context";
|
||||
import {
|
||||
activeComposerSuggestions,
|
||||
applyComposerSuggestionInsertion,
|
||||
composerSuggestionNavigationDirection,
|
||||
composerSuggestionSignature,
|
||||
nextComposerSuggestionIndex,
|
||||
type ComposerSuggestion,
|
||||
type NoteCandidate,
|
||||
} from "../composer/suggestions";
|
||||
import { userInputWithWikiLinkMentions } from "../composer/wikilink-context";
|
||||
import type { UserInput } from "../generated/app-server/v2/UserInput";
|
||||
import type { SendShortcut } from "../settings/model";
|
||||
import { renderComposerShell, renderComposerSuggestions, syncComposerControls, syncComposerHeight } from "../ui/composer";
|
||||
import type { PanelState } from "./state";
|
||||
|
||||
export interface PanelComposerControllerOptions {
|
||||
app: App;
|
||||
state: PanelState;
|
||||
viewId: string;
|
||||
sendShortcut: () => SendShortcut;
|
||||
canInterrupt: () => boolean;
|
||||
renderIfDetached: () => void;
|
||||
onSubmit: () => void;
|
||||
onNewThread: () => void;
|
||||
}
|
||||
|
||||
export class PanelComposerController {
|
||||
private composer: HTMLTextAreaElement | null = null;
|
||||
private suggestionsEl: HTMLElement | null = null;
|
||||
private noteCandidatesCache: NoteCandidate[] | null = null;
|
||||
private noteEventsRegistered = false;
|
||||
|
||||
constructor(private readonly options: PanelComposerControllerOptions) {}
|
||||
|
||||
get trimmedDraft(): string {
|
||||
return this.composer?.value.trim() ?? this.options.state.composerDraft.trim();
|
||||
}
|
||||
|
||||
registerNoteIndexInvalidation(registerEvent: (eventRef: EventRef) => void): void {
|
||||
if (this.noteEventsRegistered) return;
|
||||
this.noteEventsRegistered = true;
|
||||
const invalidate = () => {
|
||||
this.noteCandidatesCache = null;
|
||||
};
|
||||
registerEvent(this.options.app.vault.on("create", invalidate));
|
||||
registerEvent(this.options.app.vault.on("delete", invalidate));
|
||||
registerEvent(this.options.app.vault.on("rename", invalidate));
|
||||
registerEvent(this.options.app.vault.on("modify", invalidate));
|
||||
}
|
||||
|
||||
render(parent: HTMLElement): void {
|
||||
if (this.composer && parent.contains(this.composer)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const elements = renderComposerShell(parent, this.options.viewId, this.options.state.composerDraft, this.options.state.busy, {
|
||||
onInput: () => {
|
||||
this.options.state.composerDraft = this.composer?.value ?? "";
|
||||
this.options.state.composerSuggestionsDismissedSignature = null;
|
||||
this.updateSuggestions();
|
||||
this.syncControls(parent);
|
||||
},
|
||||
onUpdateSuggestions: () => this.updateSuggestions(),
|
||||
onKeydown: (event) => {
|
||||
if (this.handleSuggestionKeydown(event)) {
|
||||
return;
|
||||
}
|
||||
if (isComposerSendKey(event, this.options.sendShortcut())) {
|
||||
event.preventDefault();
|
||||
this.options.onSubmit();
|
||||
}
|
||||
},
|
||||
onNewThread: () => this.options.onNewThread(),
|
||||
onSendOrInterrupt: () => this.options.onSubmit(),
|
||||
onSuggestionHover: (index) => this.selectSuggestion(index),
|
||||
onSuggestionInsert: (suggestion) => this.insertSuggestion(suggestion),
|
||||
});
|
||||
this.composer = elements.composer;
|
||||
this.suggestionsEl = elements.suggestions;
|
||||
this.updateSuggestions();
|
||||
}
|
||||
|
||||
setDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; renderIfDetached?: boolean } = {}): void {
|
||||
this.options.state.composerDraft = text;
|
||||
if (options.clearSuggestions) this.clearSuggestions();
|
||||
if (!this.composer) {
|
||||
if (options.renderIfDetached) this.options.renderIfDetached();
|
||||
return;
|
||||
}
|
||||
|
||||
this.composer.value = text;
|
||||
syncComposerHeight(this.composer);
|
||||
if (options.focus) this.composer.focus();
|
||||
}
|
||||
|
||||
syncControls(parent: HTMLElement | null): void {
|
||||
syncComposerControls(parent, this.composer, this.options.state.busy, this.options.canInterrupt());
|
||||
}
|
||||
|
||||
codexInput(text: string): UserInput[] {
|
||||
return userInputWithWikiLinkMentions(text, (target) => resolveAppWikiLinkMention(this.options.app, target));
|
||||
}
|
||||
|
||||
private handleSuggestionKeydown(event: KeyboardEvent): boolean {
|
||||
if (event.isComposing) return false;
|
||||
if (this.options.state.composerSuggestions.length === 0) return false;
|
||||
|
||||
const direction = composerSuggestionNavigationDirection(event);
|
||||
if (direction) {
|
||||
event.preventDefault();
|
||||
this.options.state.composerSuggestSelected = nextComposerSuggestionIndex(
|
||||
this.options.state.composerSuggestSelected,
|
||||
this.options.state.composerSuggestions.length,
|
||||
direction,
|
||||
);
|
||||
this.renderSuggestions();
|
||||
return true;
|
||||
}
|
||||
if (event.metaKey || event.ctrlKey) return false;
|
||||
|
||||
if (event.key === "Enter" || event.key === "Tab") {
|
||||
event.preventDefault();
|
||||
this.insertSuggestion(this.options.state.composerSuggestions[this.options.state.composerSuggestSelected]);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
this.dismissSuggestions();
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private updateSuggestions(): void {
|
||||
if (!this.composer) {
|
||||
this.clearSuggestions();
|
||||
return;
|
||||
}
|
||||
|
||||
const cursor = this.composer.selectionStart;
|
||||
const signature = this.suggestionSignature();
|
||||
if (this.options.state.composerSuggestionsDismissedSignature === signature) {
|
||||
this.options.state.composerSuggestions = [];
|
||||
this.renderSuggestions();
|
||||
return;
|
||||
}
|
||||
const beforeCursor = this.composer.value.slice(0, cursor);
|
||||
const suggestions = activeComposerSuggestions(
|
||||
beforeCursor,
|
||||
this.noteCandidates(),
|
||||
this.options.state.availableSkills,
|
||||
this.options.state.listedThreads,
|
||||
);
|
||||
|
||||
this.options.state.composerSuggestions = suggestions;
|
||||
if (this.options.state.composerSuggestSelected >= this.options.state.composerSuggestions.length) {
|
||||
this.options.state.composerSuggestSelected = 0;
|
||||
}
|
||||
this.renderSuggestions();
|
||||
}
|
||||
|
||||
private renderSuggestions(): void {
|
||||
renderComposerSuggestions(
|
||||
this.suggestionsEl,
|
||||
this.composer,
|
||||
this.options.viewId,
|
||||
this.options.state.composerSuggestions,
|
||||
this.options.state.composerSuggestSelected,
|
||||
{
|
||||
onSuggestionHover: (index) => this.selectSuggestion(index),
|
||||
onSuggestionInsert: (suggestion) => this.insertSuggestion(suggestion),
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
private selectSuggestion(index: number): void {
|
||||
if (this.options.state.composerSuggestSelected === index) return;
|
||||
this.options.state.composerSuggestSelected = index;
|
||||
this.renderSuggestions();
|
||||
}
|
||||
|
||||
private insertSuggestion(suggestion: ComposerSuggestion | undefined): void {
|
||||
if (!this.composer || !suggestion) return;
|
||||
|
||||
const cursor = this.composer.selectionStart;
|
||||
const value = this.composer.value;
|
||||
const insertion = applyComposerSuggestionInsertion(value, cursor, suggestion);
|
||||
|
||||
this.options.state.composerDraft = insertion.value;
|
||||
this.composer.value = insertion.value;
|
||||
syncComposerHeight(this.composer);
|
||||
this.composer.focus();
|
||||
this.composer.setSelectionRange(insertion.cursor, insertion.cursor);
|
||||
this.clearSuggestions();
|
||||
}
|
||||
|
||||
private clearSuggestions(): void {
|
||||
this.options.state.composerSuggestSelected = 0;
|
||||
this.options.state.composerSuggestions = [];
|
||||
this.composer?.setAttr("aria-expanded", "false");
|
||||
this.composer?.removeAttribute("aria-activedescendant");
|
||||
this.suggestionsEl?.empty();
|
||||
this.suggestionsEl?.hide();
|
||||
}
|
||||
|
||||
private dismissSuggestions(): void {
|
||||
this.options.state.composerSuggestionsDismissedSignature = this.suggestionSignature();
|
||||
this.clearSuggestions();
|
||||
}
|
||||
|
||||
private suggestionSignature(): string | null {
|
||||
if (!this.composer) return null;
|
||||
return composerSuggestionSignature(this.composer.value, this.composer.selectionStart);
|
||||
}
|
||||
|
||||
private noteCandidates(): NoteCandidate[] {
|
||||
if (!this.noteCandidatesCache) {
|
||||
this.noteCandidatesCache = appNoteCandidates(this.options.app);
|
||||
}
|
||||
return this.noteCandidatesCache;
|
||||
}
|
||||
}
|
||||
|
|
@ -1,13 +1,15 @@
|
|||
import { approvalResponse, toPendingApproval, type ApprovalAction, type PendingApproval } from "../approvals/model";
|
||||
import { createAutoReviewResultItem, createReviewResultItem } from "../display/model";
|
||||
import {
|
||||
appendAssistantDelta,
|
||||
appendItemOutput,
|
||||
appendItemText,
|
||||
appendPlanDelta,
|
||||
appendToolOutput,
|
||||
createAutoReviewResultItem,
|
||||
completeReasoningItems,
|
||||
createReviewResultItem,
|
||||
upsertDisplayItem,
|
||||
} from "../display/stream-updates";
|
||||
import {
|
||||
createSystemItem,
|
||||
displayItemFromThreadItem,
|
||||
displayItemsFromTurns,
|
||||
|
|
@ -15,7 +17,6 @@ import {
|
|||
planProgressDisplayItem,
|
||||
shouldSuppressLifecycleItem,
|
||||
shouldSuppressThreadItem,
|
||||
upsertDisplayItem,
|
||||
} from "../display/model";
|
||||
import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../display/types";
|
||||
import type { RequestId } from "../generated/app-server/RequestId";
|
||||
|
|
@ -23,7 +24,7 @@ import type { ServerNotification } from "../generated/app-server/ServerNotificat
|
|||
import type { ServerRequest } from "../generated/app-server/ServerRequest";
|
||||
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
|
||||
import type { Turn } from "../generated/app-server/v2/Turn";
|
||||
import { clearActiveThreadState, type PanelState } from "../state/panel-state";
|
||||
import { clearActiveThreadState, type PanelState } from "./state";
|
||||
import { toPendingUserInput, userInputResponse, type PendingUserInput } from "../user-input/model";
|
||||
import { jsonPreview } from "../utils";
|
||||
import { classifyAppServerLog } from "./app-server-logs";
|
||||
|
|
|
|||
122
src/panel/message-renderer.ts
Normal file
122
src/panel/message-renderer.ts
Normal file
|
|
@ -0,0 +1,122 @@
|
|||
import { MarkdownRenderer, type App, type Component } from "obsidian";
|
||||
|
||||
import type { DisplayItem } from "../display/types";
|
||||
import { copyTextWithNotice } from "../ui/clipboard";
|
||||
import { renderTextWithWikiLinks as renderInlineWikiLinks } from "../ui/dom";
|
||||
import { messageRenderBlocks, syncMessageRenderBlocks } from "../ui/message-stream";
|
||||
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../ui/scroll";
|
||||
import type { TurnDiffViewState } from "../ui/turn-diff";
|
||||
import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./rollback";
|
||||
import type { PanelState } from "./state";
|
||||
|
||||
export interface PanelMessageRendererOptions {
|
||||
app: App;
|
||||
owner: Component;
|
||||
state: PanelState;
|
||||
vaultPath: string;
|
||||
blockSignatures: Map<string, string>;
|
||||
consumeForceScrollToBottom: () => boolean;
|
||||
loadOlderTurns: () => void;
|
||||
rollbackThread: (threadId: string) => void;
|
||||
openTurnDiff: (state: TurnDiffViewState) => void;
|
||||
pendingRequestsSignature: () => string;
|
||||
renderPendingRequests: () => HTMLElement | null;
|
||||
}
|
||||
|
||||
export class PanelMessageRenderer {
|
||||
constructor(private readonly options: PanelMessageRendererOptions) {}
|
||||
|
||||
render(parent: HTMLElement): void {
|
||||
const { state } = this.options;
|
||||
const messagesEl = parent.querySelector<HTMLElement>(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" });
|
||||
messagesEl.onscroll = () => {
|
||||
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
|
||||
};
|
||||
const wasNearBottom = isNearScrollBottom(messagesEl);
|
||||
const shouldScrollToBottom = this.options.consumeForceScrollToBottom() || wasNearBottom;
|
||||
const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl);
|
||||
state.messagesPinnedToBottom = shouldScrollToBottom;
|
||||
const rollbackCandidate = state.busy ? null : rollbackCandidateFromItems(state.displayItems);
|
||||
|
||||
const blocks = messageRenderBlocks({
|
||||
activeThreadId: state.activeThreadId,
|
||||
activeTurnId: state.activeTurnId,
|
||||
historyCursor: state.historyCursor,
|
||||
loadingHistory: state.loadingHistory,
|
||||
busy: state.busy,
|
||||
displayItems: state.displayItems,
|
||||
turnDiffs: state.turnDiffs,
|
||||
workspaceRoot: state.activeThreadCwd ?? this.options.vaultPath,
|
||||
openDetails: state.openDetails,
|
||||
onDetailsToggle: () => {
|
||||
window.requestAnimationFrame(() => {
|
||||
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
|
||||
});
|
||||
},
|
||||
loadOlderTurns: () => this.options.loadOlderTurns(),
|
||||
renderMarkdown: (element, text) => this.renderMarkdownMessage(element, text),
|
||||
renderTextWithWikiLinks: (element, text) => this.renderTextWithWikiLinks(element, text),
|
||||
copyText: (text) => void this.copyMessageText(text),
|
||||
canRollbackItem: (item: DisplayItem) => isRollbackCandidateItem(item, rollbackCandidate),
|
||||
onRollbackItem: () => {
|
||||
if (state.activeThreadId) this.options.rollbackThread(state.activeThreadId);
|
||||
},
|
||||
openTurnDiff: (turnDiffState) => this.options.openTurnDiff(turnDiffState),
|
||||
pendingRequestsSignature: this.options.pendingRequestsSignature(),
|
||||
renderPendingRequests: () => this.options.renderPendingRequests(),
|
||||
});
|
||||
syncMessageRenderBlocks(messagesEl, blocks, this.options.blockSignatures);
|
||||
|
||||
window.requestAnimationFrame(() => {
|
||||
if (shouldScrollToBottom) {
|
||||
messagesEl.scrollTop = bottomScrollTop(messagesEl);
|
||||
} else {
|
||||
restoreScrollAnchor(messagesEl, scrollAnchor);
|
||||
}
|
||||
state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
|
||||
});
|
||||
}
|
||||
|
||||
private async copyMessageText(text: string): Promise<void> {
|
||||
await copyTextWithNotice(text, "Copied message.", "Could not copy message.");
|
||||
}
|
||||
|
||||
private renderMarkdownMessage(parent: HTMLElement, text: string): void {
|
||||
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
|
||||
void MarkdownRenderer.render(this.options.app, text, parent, sourcePath, this.options.owner).then(() => {
|
||||
this.bindRenderedWikiLinks(parent, sourcePath);
|
||||
this.scrollMarkdownMessageIntoPinnedBottom(parent);
|
||||
});
|
||||
}
|
||||
|
||||
private scrollMarkdownMessageIntoPinnedBottom(parent: HTMLElement): void {
|
||||
if (!this.options.state.messagesPinnedToBottom) return;
|
||||
const messagesEl = parent.closest<HTMLElement>(".codex-panel__messages");
|
||||
if (!messagesEl) return;
|
||||
window.requestAnimationFrame(() => {
|
||||
if (!this.options.state.messagesPinnedToBottom) return;
|
||||
messagesEl.scrollTop = bottomScrollTop(messagesEl);
|
||||
this.options.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl);
|
||||
});
|
||||
}
|
||||
|
||||
private bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string): void {
|
||||
parent.querySelectorAll<HTMLAnchorElement>("a.internal-link").forEach((link) => {
|
||||
link.addClass("codex-panel__wikilink");
|
||||
link.onclick = (event) => {
|
||||
event.preventDefault();
|
||||
const target = link.getAttribute("data-href") ?? link.getAttribute("href") ?? link.textContent ?? "";
|
||||
if (target.trim().length > 0) {
|
||||
void this.options.app.workspace.openLinkText(target, sourcePath, false);
|
||||
}
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
private renderTextWithWikiLinks(parent: HTMLElement, text: string): void {
|
||||
renderInlineWikiLinks(parent, text, (target) => {
|
||||
const sourcePath = this.options.app.workspace.getActiveFile()?.path ?? "";
|
||||
void this.options.app.workspace.openLinkText(target, sourcePath, false);
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AppServerClient } from "../app-server/client";
|
||||
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "./runtime-state";
|
||||
import type { PanelState } from "../state/panel-state";
|
||||
import { requestedOrConfiguredServiceTier, type RuntimeSnapshot } from "../runtime/state";
|
||||
import type { PanelState } from "./state";
|
||||
|
||||
export interface PanelSessionControllerHost {
|
||||
state: PanelState;
|
||||
|
|
|
|||
|
|
@ -1,30 +1,14 @@
|
|||
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import { getThreadTitle } from "../threads";
|
||||
import { modelOverrideMessage, parseModelOverride, parseReasoningEffortOverride, reasoningEffortOverrideMessage } from "./runtime-settings";
|
||||
|
||||
export const SLASH_COMMANDS = [
|
||||
{ command: "/new", detail: "Start a new Codex thread, optionally sending a message." },
|
||||
{ command: "/resume", detail: "Resume a recent Codex thread." },
|
||||
{ command: "/fork", detail: "Fork the active Codex thread." },
|
||||
{ command: "/rollback", detail: "Drop the latest turn and restore its prompt to the composer." },
|
||||
{ command: "/compact", detail: "Compact the current conversation context." },
|
||||
{ command: "/fast", detail: "Toggle fast service tier for subsequent turns." },
|
||||
{ command: "/plan", detail: "Toggle Plan mode, optionally sending a message." },
|
||||
{ command: "/status", detail: "Show current session, context, and usage limits." },
|
||||
{ command: "/doctor", detail: "Show Codex CLI and app-server connection diagnostics." },
|
||||
{ command: "/model", detail: "Show or set the model for subsequent turns." },
|
||||
{ command: "/effort", detail: "Show or set reasoning effort for subsequent turns." },
|
||||
{ command: "/help", detail: "Show available Codex slash commands." },
|
||||
] as const;
|
||||
|
||||
type SlashCommand = (typeof SLASH_COMMANDS)[number]["command"];
|
||||
|
||||
export type SlashCommandName = SlashCommand extends `/${infer Name}` ? Name : never;
|
||||
|
||||
export function slashCommandHelpLines(): string[] {
|
||||
return SLASH_COMMANDS.map((item) => `${item.command} - ${item.detail}`);
|
||||
}
|
||||
import { getThreadTitle } from "../threads/model";
|
||||
import { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands";
|
||||
import {
|
||||
modelOverrideMessage,
|
||||
parseModelOverride,
|
||||
parseReasoningEffortOverride,
|
||||
reasoningEffortOverrideMessage,
|
||||
} from "../runtime/settings";
|
||||
export { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands";
|
||||
|
||||
export interface SlashCommandExecutionContext {
|
||||
activeThreadId: string | null;
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ import type { ComposerSuggestion } from "../composer/suggestions";
|
|||
import type { DisplayItem } from "../display/types";
|
||||
import type { PendingUserInput } from "../user-input/model";
|
||||
import type { ServiceTier } from "../app-server/service-tier";
|
||||
import { defaultRuntimeOverride, type RuntimeOverride } from "../panel/runtime-state";
|
||||
import { defaultRuntimeOverride, type RuntimeOverride } from "../runtime/state";
|
||||
|
||||
export interface PanelState {
|
||||
status: string;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { RateLimitSummary } from "./runtime-view";
|
||||
import type { RateLimitSummary } from "../runtime/view";
|
||||
|
||||
export function statusValue(value: unknown, fallback: string): string {
|
||||
if (typeof value === "string") return value;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import type { AppServerClient } from "../app-server/client";
|
||||
import { displayItemsFromTurns } from "../display/model";
|
||||
import type { PanelState } from "../state/panel-state";
|
||||
import type { PanelState } from "./state";
|
||||
|
||||
export interface ThreadHistoryLoaderHost {
|
||||
state: PanelState;
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import type { SortDirection } from "../generated/app-server/v2/SortDirection";
|
|||
import type { ThreadItem } from "../generated/app-server/v2/ThreadItem";
|
||||
import type { Turn } from "../generated/app-server/v2/Turn";
|
||||
import { inputToText, truncate } from "../utils";
|
||||
import { findModelByIdOrName, supportedEffortsForModel } from "./model-runtime";
|
||||
import { findModelByIdOrName, supportedEffortsForModel } from "../runtime/model";
|
||||
|
||||
const NAMING_SERVICE_NAME = "codex-panel-naming";
|
||||
const NAMING_TIMEOUT_MS = 60_000;
|
||||
|
|
|
|||
|
|
@ -1,9 +1,9 @@
|
|||
import type { AppServerClient } from "../app-server/client";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import type { Turn } from "../generated/app-server/v2/Turn";
|
||||
import type { CodexPanelSettings } from "../settings";
|
||||
import type { PanelState } from "../state/panel-state";
|
||||
import { getThreadTitle } from "../threads";
|
||||
import type { CodexPanelSettings } from "../settings/model";
|
||||
import type { PanelState } from "./state";
|
||||
import { getThreadTitle } from "../threads/model";
|
||||
import {
|
||||
findThreadNamingContext,
|
||||
generateThreadTitleWithCodex,
|
||||
|
|
|
|||
72
src/panel/turn-diff-view.ts
Normal file
72
src/panel/turn-diff-view.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
import { ItemView, type ViewStateResult } from "obsidian";
|
||||
|
||||
import { VIEW_TYPE_CODEX_TURN_DIFF } from "../constants";
|
||||
import { copyTextWithNotice } from "../ui/clipboard";
|
||||
import {
|
||||
isPersistedTurnDiffViewState,
|
||||
persistedTurnDiffViewState,
|
||||
renderTurnDiffView,
|
||||
type PersistedTurnDiffViewState,
|
||||
type TurnDiffViewState,
|
||||
} from "../ui/turn-diff";
|
||||
|
||||
export class CodexTurnDiffView extends ItemView {
|
||||
private metadata: PersistedTurnDiffViewState | null = null;
|
||||
private payload: TurnDiffViewState | null = null;
|
||||
|
||||
getViewType(): string {
|
||||
return VIEW_TYPE_CODEX_TURN_DIFF;
|
||||
}
|
||||
|
||||
getDisplayText(): string {
|
||||
return "Codex turn diff";
|
||||
}
|
||||
|
||||
getIcon(): string {
|
||||
return "file-diff";
|
||||
}
|
||||
|
||||
getState(): Record<string, unknown> {
|
||||
return this.metadata ? { ...this.metadata } : {};
|
||||
}
|
||||
|
||||
async setState(state: unknown, result: ViewStateResult): Promise<void> {
|
||||
await super.setState(state, result);
|
||||
this.metadata = isPersistedTurnDiffViewState(state)
|
||||
? {
|
||||
threadId: state.threadId,
|
||||
turnId: state.turnId,
|
||||
cwd: state.cwd,
|
||||
files: [...state.files],
|
||||
}
|
||||
: null;
|
||||
this.payload = null;
|
||||
this.render();
|
||||
}
|
||||
|
||||
async onOpen(): Promise<void> {
|
||||
this.render();
|
||||
}
|
||||
|
||||
setDiffPayload(payload: TurnDiffViewState): void {
|
||||
this.metadata = persistedTurnDiffViewState(payload);
|
||||
this.payload = payload;
|
||||
this.render();
|
||||
}
|
||||
|
||||
private render(): void {
|
||||
const root = this.contentEl;
|
||||
renderTurnDiffView(
|
||||
root,
|
||||
this.payload,
|
||||
{
|
||||
copyDiff: this.payload ? () => void this.copyDiff(this.payload?.diff ?? "") : undefined,
|
||||
},
|
||||
this.metadata,
|
||||
);
|
||||
}
|
||||
|
||||
private async copyDiff(diff: string): Promise<void> {
|
||||
await copyTextWithNotice(diff, "Copied diff.", "Could not copy diff.");
|
||||
}
|
||||
}
|
||||
1026
src/panel/view.ts
Normal file
1026
src/panel/view.ts
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -1,5 +1,5 @@
|
|||
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
|
||||
import { isReasoningEffort } from "./model-runtime";
|
||||
import { isReasoningEffort } from "./model";
|
||||
|
||||
const DEFAULT_ALIASES = new Set(["default", "reset", "clear", "off"]);
|
||||
|
||||
|
|
@ -8,9 +8,9 @@ import type { ThreadTokenUsage } from "../generated/app-server/v2/ThreadTokenUsa
|
|||
import type { DisplayItem } from "../display/types";
|
||||
import { parseServiceTier, serviceTierRequestValue, type ServiceTier, type ServiceTierRequest } from "../app-server/service-tier";
|
||||
import { defaultCollaborationMode, planCollaborationMode } from "./collaboration-mode";
|
||||
import { findModelByIdOrName, isReasoningEffort, supportedEffortsForModel } from "./model-runtime";
|
||||
import { compactModelLabel, compactReasoningEffortLabel } from "./runtime-settings";
|
||||
export { sortedAvailableModels } from "./model-runtime";
|
||||
import { findModelByIdOrName, isReasoningEffort, supportedEffortsForModel } from "./model";
|
||||
import { compactModelLabel, compactReasoningEffortLabel } from "./settings";
|
||||
export { sortedAvailableModels } from "./model";
|
||||
|
||||
export type RuntimeOverride<T> = { kind: "default" } | { kind: "set"; value: T } | { kind: "resetPending" };
|
||||
|
||||
|
|
@ -9,7 +9,7 @@ import {
|
|||
runtimeOverrideLabel,
|
||||
serviceTierLabel,
|
||||
type RuntimeSnapshot,
|
||||
} from "./runtime-state";
|
||||
} from "./state";
|
||||
|
||||
export interface ContextSummary {
|
||||
label: string;
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import type { AppServerClient } from "./app-server/client";
|
||||
import type { HookMetadata } from "./generated/app-server/v2/HookMetadata";
|
||||
import type { Model } from "./generated/app-server/v2/Model";
|
||||
import type { Thread } from "./generated/app-server/v2/Thread";
|
||||
import { errorMessage } from "./utils";
|
||||
import type { AppServerClient } from "../app-server/client";
|
||||
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
|
||||
import type { Model } from "../generated/app-server/v2/Model";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import { errorMessage } from "../utils";
|
||||
|
||||
export interface LoadedHooks {
|
||||
hooks: HookMetadata[];
|
||||
154
src/settings/dynamic-sections.ts
Normal file
154
src/settings/dynamic-sections.ts
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
import { Setting } from "obsidian";
|
||||
|
||||
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import { archivedThreadDisplayTitle, fullThreadTitle } from "../threads/model";
|
||||
import { shortThreadId } from "../utils";
|
||||
|
||||
export interface ArchivedThreadSectionState {
|
||||
threads: Thread[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
status: string;
|
||||
onRestore(threadId: string): void;
|
||||
}
|
||||
|
||||
export interface HookSectionState {
|
||||
hooks: HookMetadata[];
|
||||
warnings: string[];
|
||||
errors: string[];
|
||||
loaded: boolean;
|
||||
loading: boolean;
|
||||
status: string;
|
||||
onTrust(hook: HookMetadata): void;
|
||||
onToggleEnabled(hook: HookMetadata, enabled: boolean): void;
|
||||
}
|
||||
|
||||
export function renderHookSection(containerEl: HTMLElement, state: HookSectionState): void {
|
||||
const section = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" });
|
||||
new Setting(section)
|
||||
.setClass("codex-panel-settings__dynamic-section-heading")
|
||||
.setHeading()
|
||||
.setName("Hook status")
|
||||
.setDesc("Review hooks discovered by Codex app-server for the current vault root, including trust and enabled state.");
|
||||
|
||||
if (state.loading) {
|
||||
section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: "Loading hooks..." });
|
||||
} else if (state.loaded) {
|
||||
renderHooks(section, state);
|
||||
} else if (state.status) {
|
||||
section.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: state.status });
|
||||
}
|
||||
}
|
||||
|
||||
export function renderArchivedThreadSection(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
|
||||
const section = containerEl.createDiv({
|
||||
cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section",
|
||||
});
|
||||
new Setting(section)
|
||||
.setClass("codex-panel-settings__dynamic-section-heading")
|
||||
.setHeading()
|
||||
.setName("Archived thread list")
|
||||
.setDesc("Restore archived Codex threads to chat history when they are needed again.");
|
||||
|
||||
if (state.loading) {
|
||||
section.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
|
||||
text: "Loading archived threads...",
|
||||
});
|
||||
} else if (state.loaded && state.threads.length === 0) {
|
||||
section.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
|
||||
text: "No archived threads.",
|
||||
});
|
||||
} else if (state.loaded) {
|
||||
renderArchivedThreadList(section, state);
|
||||
} else if (state.status) {
|
||||
section.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
|
||||
text: state.status,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function renderArchivedThreadList(containerEl: HTMLElement, state: ArchivedThreadSectionState): void {
|
||||
containerEl.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
|
||||
text: `Loaded ${state.threads.length} archived thread${state.threads.length === 1 ? "" : "s"} from Codex app-server.`,
|
||||
});
|
||||
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
|
||||
for (const thread of state.threads) {
|
||||
const title = archivedThreadDisplayTitle(thread);
|
||||
const setting = new Setting(list)
|
||||
.setClass("codex-panel-settings__dynamic-row")
|
||||
.setName(title)
|
||||
.setDesc(`Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon("rotate-ccw").onClick(() => state.onRestore(thread.id));
|
||||
button.extraSettingsEl.addClass("codex-panel-settings__archived-restore");
|
||||
button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
|
||||
});
|
||||
setting.settingEl.addClass("codex-panel-settings__archived-row");
|
||||
setting.settingEl.setAttr("title", fullThreadTitle(thread));
|
||||
}
|
||||
}
|
||||
|
||||
function renderHooks(containerEl: HTMLElement, state: HookSectionState): void {
|
||||
if (state.hooks.length === 0) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks discovered for the current vault root." });
|
||||
} else {
|
||||
containerEl.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
|
||||
text: `Loaded ${state.hooks.length} hook${state.hooks.length === 1 ? "" : "s"} from Codex app-server.`,
|
||||
});
|
||||
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" });
|
||||
for (const hook of state.hooks) {
|
||||
renderHookRow(list, hook, state);
|
||||
}
|
||||
}
|
||||
|
||||
for (const warning of state.warnings) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning });
|
||||
}
|
||||
for (const error of state.errors) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error });
|
||||
}
|
||||
}
|
||||
|
||||
function renderHookRow(list: HTMLElement, hook: HookMetadata, state: HookSectionState): void {
|
||||
const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
|
||||
const setting = new Setting(list)
|
||||
.setClass("codex-panel-settings__dynamic-row")
|
||||
.setName(hook.statusMessage || hook.command || hook.matcher || hook.eventName)
|
||||
.setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`)
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText("Trust")
|
||||
.setDisabled(state.loading || !canTrust)
|
||||
.onClick(() => state.onTrust(hook));
|
||||
})
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText(hook.enabled ? "Disable" : "Enable")
|
||||
.setDisabled(state.loading || hook.isManaged)
|
||||
.onClick(() => state.onToggleEnabled(hook, !hook.enabled));
|
||||
});
|
||||
setting.settingEl.addClass("codex-panel-settings__hook-row");
|
||||
setting.settingEl.setAttr("title", hook.command ?? hook.key);
|
||||
setting.descEl.createDiv({
|
||||
cls: "codex-panel-settings__hook-hash",
|
||||
text: hook.currentHash,
|
||||
attr: { title: hook.key },
|
||||
});
|
||||
}
|
||||
|
||||
function formatThreadDate(timestamp: number): string {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
|
||||
return new Date(timestamp * 1000).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,8 +1,8 @@
|
|||
import { FileSystemAdapter, type App } from "obsidian";
|
||||
|
||||
import { DEFAULT_CODEX_PATH } from "./constants";
|
||||
import type { ReasoningEffort } from "./generated/app-server/ReasoningEffort";
|
||||
import { normalizeReasoningEffort } from "./panel/model-runtime";
|
||||
import { DEFAULT_CODEX_PATH } from "../constants";
|
||||
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
|
||||
import { normalizeReasoningEffort } from "../runtime/model";
|
||||
|
||||
export interface CodexPanelSettings {
|
||||
codexPath: string;
|
||||
|
|
@ -1,17 +1,18 @@
|
|||
import { type App, Notice, PluginSettingTab, Setting } from "obsidian";
|
||||
|
||||
import type { AppServerClient } from "./app-server/client";
|
||||
import { withAppServerSession } from "./app-server/session-client";
|
||||
import { DEFAULT_CODEX_PATH } from "./constants";
|
||||
import type { ReasoningEffort } from "./generated/app-server/ReasoningEffort";
|
||||
import type { HookMetadata } from "./generated/app-server/v2/HookMetadata";
|
||||
import type { Model } from "./generated/app-server/v2/Model";
|
||||
import type { Thread } from "./generated/app-server/v2/Thread";
|
||||
import type CodexPanelPlugin from "./main";
|
||||
import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supportedEffortsForModel } from "./panel/model-runtime";
|
||||
import { loadHookData, loadSettingsData } from "./settings-data";
|
||||
import { archivedThreadDisplayTitle, fullThreadTitle } from "./threads";
|
||||
import { errorMessage, shortThreadId } from "./utils";
|
||||
import type { AppServerClient } from "../app-server/client";
|
||||
import { withAppServerSession } from "../app-server/session-client";
|
||||
import { DEFAULT_CODEX_PATH } from "../constants";
|
||||
import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort";
|
||||
import type { HookMetadata } from "../generated/app-server/v2/HookMetadata";
|
||||
import type { Model } from "../generated/app-server/v2/Model";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import type CodexPanelPlugin from "../main";
|
||||
import { findModelByIdOrName, REASONING_EFFORTS, sortedAvailableModels, supportedEffortsForModel } from "../runtime/model";
|
||||
import { archivedThreadDisplayTitle } from "../threads/model";
|
||||
import { errorMessage } from "../utils";
|
||||
import { loadHookData, loadSettingsData } from "./data";
|
||||
import { renderArchivedThreadSection, renderHookSection } from "./dynamic-sections";
|
||||
|
||||
const CODEX_DEFAULT_VALUE = "__codex-default__";
|
||||
const SEND_SHORTCUT_LABELS = {
|
||||
|
|
@ -139,48 +140,24 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
});
|
||||
}
|
||||
|
||||
const hookSection = containerEl.createDiv({ cls: "codex-panel-settings__dynamic-section codex-panel-settings__hook-section" });
|
||||
new Setting(hookSection)
|
||||
.setClass("codex-panel-settings__dynamic-section-heading")
|
||||
.setHeading()
|
||||
.setName("Hook status")
|
||||
.setDesc("Review hooks discovered by Codex app-server for the current vault root, including trust and enabled state.");
|
||||
|
||||
if (this.hooksLoading) {
|
||||
hookSection.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: "Loading hooks..." });
|
||||
} else if (this.hooksLoaded) {
|
||||
this.renderHooks(hookSection);
|
||||
} else if (this.hooksStatus) {
|
||||
hookSection.createEl("p", { cls: "setting-item-description codex-panel-settings__dynamic-section-status", text: this.hooksStatus });
|
||||
}
|
||||
|
||||
const archivedSection = containerEl.createDiv({
|
||||
cls: "codex-panel-settings__dynamic-section codex-panel-settings__archived-section",
|
||||
renderHookSection(containerEl, {
|
||||
hooks: this.hooks,
|
||||
warnings: this.hookWarnings,
|
||||
errors: this.hookErrors,
|
||||
loaded: this.hooksLoaded,
|
||||
loading: this.hooksLoading,
|
||||
status: this.hooksStatus,
|
||||
onTrust: (hook) => void this.trustHook(hook),
|
||||
onToggleEnabled: (hook, enabled) => void this.setHookEnabled(hook, enabled),
|
||||
});
|
||||
new Setting(archivedSection)
|
||||
.setClass("codex-panel-settings__dynamic-section-heading")
|
||||
.setHeading()
|
||||
.setName("Archived thread list")
|
||||
.setDesc("Restore archived Codex threads to chat history when they are needed again.");
|
||||
|
||||
if (this.archivedThreadsLoading) {
|
||||
archivedSection.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
|
||||
text: "Loading archived threads...",
|
||||
});
|
||||
} else if (this.archivedThreadsLoaded && this.archivedThreads.length === 0) {
|
||||
archivedSection.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
|
||||
text: "No archived threads.",
|
||||
});
|
||||
} else if (this.archivedThreadsLoaded) {
|
||||
this.renderArchivedThreadList(archivedSection);
|
||||
} else if (this.archivedThreadsStatus) {
|
||||
archivedSection.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-section-status",
|
||||
text: this.archivedThreadsStatus,
|
||||
});
|
||||
}
|
||||
renderArchivedThreadSection(containerEl, {
|
||||
threads: this.archivedThreads,
|
||||
loaded: this.archivedThreadsLoaded,
|
||||
loading: this.archivedThreadsLoading,
|
||||
status: this.archivedThreadsStatus,
|
||||
onRestore: (threadId) => void this.restoreArchivedThread(threadId),
|
||||
});
|
||||
|
||||
this.maybeAutoLoadSettingsData();
|
||||
}
|
||||
|
|
@ -341,86 +318,4 @@ export class CodexPanelSettingTab extends PluginSettingTab {
|
|||
private selectedNamingModel(): Model | null {
|
||||
return findModelByIdOrName(this.namingModels, this.plugin.settings.threadNamingModel);
|
||||
}
|
||||
|
||||
private renderArchivedThreadList(containerEl: HTMLElement): void {
|
||||
containerEl.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
|
||||
text: `Loaded ${this.archivedThreads.length} archived thread${this.archivedThreads.length === 1 ? "" : "s"} from Codex app-server.`,
|
||||
});
|
||||
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__archived-list" });
|
||||
for (const thread of this.archivedThreads) {
|
||||
const title = archivedThreadDisplayTitle(thread);
|
||||
const setting = new Setting(list)
|
||||
.setClass("codex-panel-settings__dynamic-row")
|
||||
.setName(title)
|
||||
.setDesc(`Updated ${formatThreadDate(thread.updatedAt)} · ${shortThreadId(thread.id)}`)
|
||||
.addExtraButton((button) => {
|
||||
button.setIcon("rotate-ccw").onClick(() => void this.restoreArchivedThread(thread.id));
|
||||
button.extraSettingsEl.addClass("codex-panel-settings__archived-restore");
|
||||
button.extraSettingsEl.setAttr("aria-label", `Restore ${title}`);
|
||||
});
|
||||
setting.settingEl.addClass("codex-panel-settings__archived-row");
|
||||
setting.settingEl.setAttr("title", fullThreadTitle(thread));
|
||||
}
|
||||
}
|
||||
|
||||
private renderHooks(containerEl: HTMLElement): void {
|
||||
if (this.hooks.length === 0) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description", text: "No hooks discovered for the current vault root." });
|
||||
} else {
|
||||
containerEl.createEl("p", {
|
||||
cls: "setting-item-description codex-panel-settings__dynamic-list-summary",
|
||||
text: `Loaded ${this.hooks.length} hook${this.hooks.length === 1 ? "" : "s"} from Codex app-server.`,
|
||||
});
|
||||
const list = containerEl.createDiv({ cls: "setting-items codex-panel-settings__dynamic-list codex-panel-settings__hook-list" });
|
||||
for (const hook of this.hooks) {
|
||||
this.renderHookRow(list, hook);
|
||||
}
|
||||
}
|
||||
|
||||
for (const warning of this.hookWarnings) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-warning", text: warning });
|
||||
}
|
||||
for (const error of this.hookErrors) {
|
||||
containerEl.createEl("p", { cls: "setting-item-description codex-panel-settings__hook-error", text: error });
|
||||
}
|
||||
}
|
||||
|
||||
private renderHookRow(list: HTMLElement, hook: HookMetadata): void {
|
||||
const canTrust = !hook.isManaged && (hook.trustStatus === "untrusted" || hook.trustStatus === "modified");
|
||||
const setting = new Setting(list)
|
||||
.setClass("codex-panel-settings__dynamic-row")
|
||||
.setName(hook.statusMessage || hook.command || hook.matcher || hook.eventName)
|
||||
.setDesc(`${hook.eventName} · ${hook.matcher ?? "(no matcher)"} · ${hook.trustStatus} · ${hook.enabled ? "enabled" : "disabled"}`)
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText("Trust")
|
||||
.setDisabled(this.hooksLoading || !canTrust)
|
||||
.onClick(() => void this.trustHook(hook));
|
||||
})
|
||||
.addButton((button) => {
|
||||
button
|
||||
.setButtonText(hook.enabled ? "Disable" : "Enable")
|
||||
.setDisabled(this.hooksLoading || hook.isManaged)
|
||||
.onClick(() => void this.setHookEnabled(hook, !hook.enabled));
|
||||
});
|
||||
setting.settingEl.addClass("codex-panel-settings__hook-row");
|
||||
setting.settingEl.setAttr("title", hook.command ?? hook.key);
|
||||
setting.descEl.createDiv({
|
||||
cls: "codex-panel-settings__hook-hash",
|
||||
text: hook.currentHash,
|
||||
attr: { title: hook.key },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function formatThreadDate(timestamp: number): string {
|
||||
if (!Number.isFinite(timestamp) || timestamp <= 0) return "unknown";
|
||||
return new Date(timestamp * 1000).toLocaleString(undefined, {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
day: "numeric",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
}
|
||||
|
|
@ -1,5 +1,5 @@
|
|||
import type { Thread } from "./generated/app-server/v2/Thread";
|
||||
import { shortThreadId } from "./utils";
|
||||
import type { Thread } from "../generated/app-server/v2/Thread";
|
||||
import { shortThreadId } from "../utils";
|
||||
|
||||
const MAX_THREAD_DISPLAY_TITLE_LENGTH = 96;
|
||||
const UUID_PATTERN = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
|
||||
|
|
@ -1,4 +1,4 @@
|
|||
import type { EffectiveConfigSection } from "../panel/runtime-view";
|
||||
import type { EffectiveConfigSection } from "../runtime/view";
|
||||
import { createDefinitionRow } from "./components";
|
||||
|
||||
export function renderEffectiveConfig(parent: HTMLElement, sections: EffectiveConfigSection[]): void {
|
||||
|
|
@ -1,4 +1,5 @@
|
|||
import { displayBlocksForItems, executionState } from "../display/model";
|
||||
import { displayBlocksForItems } from "../display/blocks";
|
||||
import { executionState } from "../display/model";
|
||||
import { displayItemSignature } from "../display/signature";
|
||||
import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types";
|
||||
import { createIconButton, createMetaPair, createRememberedDetails } from "./components";
|
||||
|
|
@ -1,6 +1,6 @@
|
|||
import { setIcon } from "obsidian";
|
||||
|
||||
import type { EffectiveConfigSection, RateLimitSummary } from "../panel/runtime-view";
|
||||
import type { EffectiveConfigSection, RateLimitSummary } from "../runtime/view";
|
||||
import { createToolbarButton } from "./components";
|
||||
import { renderEffectiveConfig } from "./config";
|
||||
|
||||
|
|
@ -6,7 +6,7 @@ import {
|
|||
defaultCollaborationMode,
|
||||
nextCollaborationMode,
|
||||
planCollaborationMode,
|
||||
} from "../src/panel/collaboration-mode";
|
||||
} from "../src/runtime/collaboration-mode";
|
||||
|
||||
describe("collaboration mode", () => {
|
||||
it("toggles between Default and Plan mode", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { PanelController } from "../src/panel/controller";
|
||||
import { createPanelState } from "../src/state/panel-state";
|
||||
import { createPanelState } from "../src/panel/state";
|
||||
import type { ServerNotification } from "../src/generated/app-server/ServerNotification";
|
||||
import type { ServerRequest } from "../src/generated/app-server/ServerRequest";
|
||||
import type { Thread } from "../src/generated/app-server/v2/Thread";
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import {
|
|||
parseModelOverride,
|
||||
parseReasoningEffortOverride,
|
||||
reasoningEffortOverrideMessage,
|
||||
} from "../src/panel/runtime-settings";
|
||||
} from "../src/runtime/settings";
|
||||
import {
|
||||
currentModel,
|
||||
currentReasoningEffort,
|
||||
|
|
@ -19,8 +19,8 @@ import {
|
|||
setRuntimeOverride,
|
||||
serviceTierLabel,
|
||||
type RuntimeSnapshot,
|
||||
} from "../src/panel/runtime-state";
|
||||
import { contextSummary, rateLimitSummary } from "../src/panel/runtime-view";
|
||||
} from "../src/runtime/state";
|
||||
import { contextSummary, rateLimitSummary } from "../src/runtime/view";
|
||||
|
||||
describe("runtime settings", () => {
|
||||
it("parses model overrides", () => {
|
||||
|
|
|
|||
|
|
@ -5,9 +5,9 @@ import type { Thread } from "../src/generated/app-server/v2/Thread";
|
|||
import type { HookMetadata } from "../src/generated/app-server/v2/HookMetadata";
|
||||
import type { Model } from "../src/generated/app-server/v2/Model";
|
||||
import type { ReasoningEffort } from "../src/generated/app-server/ReasoningEffort";
|
||||
import { findModelByIdOrName, sortedAvailableModels, supportedEffortsForModel } from "../src/panel/model-runtime";
|
||||
import { CodexPanelSettingTab } from "../src/settings-tab";
|
||||
import { archivedThreadDisplayTitle } from "../src/threads";
|
||||
import { findModelByIdOrName, sortedAvailableModels, supportedEffortsForModel } from "../src/runtime/model";
|
||||
import { CodexPanelSettingTab } from "../src/settings/tab";
|
||||
import { archivedThreadDisplayTitle } from "../src/threads/model";
|
||||
import { notices } from "./mocks/obsidian";
|
||||
|
||||
const require = createRequire(import.meta.url);
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { FileSystemAdapter, type App } from "obsidian";
|
||||
|
||||
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData } from "../src/settings";
|
||||
import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData } from "../src/settings/model";
|
||||
|
||||
describe("settings", () => {
|
||||
it("normalizes empty data", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { statusValue, usageLimitStatusLines } from "../src/panel/status-lines";
|
||||
import type { RateLimitSummary } from "../src/panel/runtime-view";
|
||||
import type { RateLimitSummary } from "../src/runtime/view";
|
||||
|
||||
describe("status line helpers", () => {
|
||||
it("formats primitive and structured diagnostic values", () => {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import type { Thread } from "../src/generated/app-server/v2/Thread";
|
||||
import { codexPanelDisplayTitle, inheritedForkThreadName, upsertThread } from "../src/threads";
|
||||
import { codexPanelDisplayTitle, inheritedForkThreadName, upsertThread } from "../src/threads/model";
|
||||
|
||||
describe("thread helpers", () => {
|
||||
it("formats Codex panel display titles from the active thread", () => {
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { renderTextWithWikiLinks, shortSignature } from "../src/view/dom";
|
||||
import { renderTextWithWikiLinks, shortSignature } from "../src/ui/dom";
|
||||
|
||||
declare global {
|
||||
interface HTMLElement {
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ import {
|
|||
scrollComposerSuggestionIntoView,
|
||||
syncComposerControls,
|
||||
syncComposerHeight,
|
||||
} from "../src/view/composer";
|
||||
import { renderPendingRequestMessage } from "../src/view/pending-request-message";
|
||||
import { renderToolbar, toolbarSignature, type ToolbarViewModel } from "../src/view/toolbar";
|
||||
} from "../src/ui/composer";
|
||||
import { renderPendingRequestMessage } from "../src/ui/pending-request-message";
|
||||
import { renderToolbar, toolbarSignature, type ToolbarViewModel } from "../src/ui/toolbar";
|
||||
import { displayItemSignature } from "../src/display/signature";
|
||||
import { messageRenderBlocks, syncMessageRenderBlocks } from "../src/view/message-stream";
|
||||
import { displayDiffLines, persistedTurnDiffViewState, renderTurnDiffView } from "../src/view/turn-diff";
|
||||
import { messageRenderBlocks, syncMessageRenderBlocks } from "../src/ui/message-stream";
|
||||
import { displayDiffLines, persistedTurnDiffViewState, renderTurnDiffView } from "../src/ui/turn-diff";
|
||||
|
||||
declare global {
|
||||
function createDiv(options?: { cls?: string; text?: string; attr?: Record<string, string> }): HTMLDivElement;
|
||||
|
|
@ -220,7 +220,7 @@ describe("view renderers", () => {
|
|||
rows: [
|
||||
{ key: "status", value: "approved" },
|
||||
{ key: "action", value: "apply patch" },
|
||||
{ key: "files", value: "src/display/tool-view.ts\nsrc/view/message-stream.ts" },
|
||||
{ key: "files", value: "src/display/tool-view.ts\nsrc/ui/message-stream.ts" },
|
||||
],
|
||||
},
|
||||
],
|
||||
|
|
@ -242,7 +242,7 @@ describe("view renderers", () => {
|
|||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("statusapproved");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain("actionapply patch");
|
||||
expect(element.querySelector(".codex-panel__meta-grid")?.textContent).toContain(
|
||||
"filessrc/display/tool-view.ts\nsrc/view/message-stream.ts",
|
||||
"filessrc/display/tool-view.ts\nsrc/ui/message-stream.ts",
|
||||
);
|
||||
expect([...element.querySelectorAll(".codex-panel__output-title")].map((title) => title.textContent)).toEqual([]);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,7 +2,7 @@
|
|||
|
||||
import { describe, expect, it } from "vitest";
|
||||
|
||||
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../src/view/scroll";
|
||||
import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "../src/ui/scroll";
|
||||
|
||||
describe("message scroll helpers", () => {
|
||||
it("detects whether the transcript is pinned near the bottom", () => {
|
||||
|
|
|
|||
Loading…
Reference in a new issue