diff --git a/src/composer/keys.ts b/src/composer/keys.ts index 01f2cc30..fd5bdd23 100644 --- a/src/composer/keys.ts +++ b/src/composer/keys.ts @@ -1,4 +1,4 @@ -import type { SendShortcut } from "../settings"; +import type { SendShortcut } from "../settings/model"; export interface ComposerSendKeyEvent { key: string; diff --git a/src/composer/slash-commands.ts b/src/composer/slash-commands.ts new file mode 100644 index 00000000..24613715 --- /dev/null +++ b/src/composer/slash-commands.ts @@ -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}`); +} diff --git a/src/composer/suggestions.ts b/src/composer/suggestions.ts index b3317d8d..5835d317 100644 --- a/src/composer/suggestions.ts +++ b/src/composer/suggestions.ts @@ -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 { diff --git a/src/display/blocks.ts b/src/display/blocks.ts new file mode 100644 index 00000000..e2a55de7 --- /dev/null +++ b/src/display/blocks.ts @@ -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, +): 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(); + 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(); + 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): boolean { + if (!item.turnId || item.role === "user") return false; + return finalAssistantIdByTurn.get(item.turnId) !== item.id; +} + +function finalAssistantItemsByTurn(items: DisplayItem[]): Map { + const finalAssistantIdByTurn = new Map(); + 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, + autoReviewSummariesByTurn: Map, + finalAssistantIdByTurn: Map, + turnDiffs?: ReadonlyMap, +): 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 { + const byTurn = new Map>(); + 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(); + 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 { + const byTurn = new Map(); + 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}`; +} diff --git a/src/display/model.ts b/src/display/model.ts index 18748cbc..bd0ddb73 100644 --- a/src/display/model.ts +++ b/src/display/model.ts @@ -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, 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*\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 = "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, -): 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(); - 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(); - 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): boolean { - if (!item.turnId || item.role === "user") return false; - return finalAssistantIdByTurn.get(item.turnId) !== item.id; -} - -function finalAssistantItemsByTurn(items: DisplayItem[]): Map { - const finalAssistantIdByTurn = new Map(); - 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, - autoReviewSummariesByTurn: Map, - finalAssistantIdByTurn: Map, - turnDiffs?: ReadonlyMap, -): 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 { - const byTurn = new Map>(); - 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(); - 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 { - const byTurn = new Map(); - 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)}`, diff --git a/src/display/plan.ts b/src/display/plan.ts new file mode 100644 index 00000000..bb494869 --- /dev/null +++ b/src/display/plan.ts @@ -0,0 +1,6 @@ +export function normalizeProposedPlanMarkdown(text: string): string { + return text + .replace(/^\s*\s*\n?/i, "") + .replace(/\n?\s*<\/proposed_plan>\s*$/i, "") + .trim(); +} diff --git a/src/display/stream-updates.ts b/src/display/stream-updates.ts new file mode 100644 index 00000000..649055d7 --- /dev/null +++ b/src/display/stream-updates.ts @@ -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, 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 = "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[]; +} diff --git a/src/main.ts b/src/main.ts index 787be31b..4d1f3954 100644 --- a/src/main.ts +++ b/src/main.ts @@ -1,84 +1,11 @@ -import { ItemView, MarkdownRenderer, Notice, Plugin, type ViewStateResult, type WorkspaceLeaf } from "obsidian"; +import { Plugin, type WorkspaceLeaf } from "obsidian"; -import type { AppServerClient } from "./app-server/client"; -import { ConnectionManager, StaleConnectionError } from "./app-server/connection-manager"; -import type { ApprovalAction, PendingApproval } from "./approvals/model"; -import { - activeComposerSuggestions, - applyComposerSuggestionInsertion, - composerSuggestionSignature, - composerSuggestionNavigationDirection, - nextComposerSuggestionIndex, - parseSlashCommand, - type ComposerSuggestion, - type NoteCandidate, -} from "./composer/suggestions"; -import { isComposerSendKey } from "./composer/keys"; -import { noteCandidates as appNoteCandidates, resolveWikiLinkMention as resolveAppWikiLinkMention } from "./composer/obsidian-context"; -import { userInputWithWikiLinkMentions } from "./composer/wikilink-context"; import { VIEW_TYPE_CODEX_PANEL, VIEW_TYPE_CODEX_TURN_DIFF } from "./constants"; -import { createSystemItem } from "./display/model"; -import type { DisplayItem } from "./display/types"; -import type { ReasoningEffort } from "./generated/app-server/ReasoningEffort"; -import type { UserInput } from "./generated/app-server/v2/UserInput"; -import type { ServiceTier } from "./app-server/service-tier"; -import { - collaborationModeLabel as formatCollaborationModeLabel, - collaborationModeToggleMessage, - nextCollaborationMode, -} from "./panel/collaboration-mode"; -import { PanelController } from "./panel/controller"; -import { connectionDiagnosticLines, connectionDiagnosticRows } from "./panel/diagnostics"; -import { isRollbackCandidateItem, rollbackCandidateFromItems } from "./panel/rollback"; -import { contextSummary, effectiveConfigSections, rateLimitSummary } from "./panel/runtime-view"; -import { - configRecord, - currentModel, - currentReasoningEffort, - currentServiceTier, - commitRuntimeOverride, - resetRuntimeOverride, - requestedOrConfiguredServiceTier, - requestedTurnRuntimeSettings, - runtimeSummaryLabel, - runtimeOverrideLabel, - serviceTierLabel, - setRuntimeOverride, - sortedAvailableModels, - supportedReasoningEfforts, - type RuntimeSnapshot, -} from "./panel/runtime-state"; -import { compactContextLabel, modelOverrideMessage, reasoningEffortOverrideMessage } from "./panel/runtime-settings"; -import { statusValue, usageLimitStatusLines } from "./panel/status-lines"; -import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type SlashCommandName } from "./panel/slash-commands"; -import { PanelSessionController } from "./panel/session-controller"; -import { ThreadHistoryLoader } from "./panel/thread-history"; -import { ThreadRenameController } from "./panel/thread-rename"; -import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData, type CodexPanelSettings } from "./settings"; -import { CodexPanelSettingTab } from "./settings-tab"; -import { clearActiveThreadState, clearConnectionScopedState, createPanelState, type PanelState } from "./state/panel-state"; -import { pendingRequestsSignature as requestStateSignature, userInputDraftKey, userInputOtherDraftKey } from "./panel/request-state"; -import { copyTextWithNotice } from "./view/clipboard"; -import { codexPanelDisplayTitle, getThreadTitle, inheritedForkThreadName, upsertThread } from "./threads"; -import { questionDefaultAnswer, type PendingUserInput } from "./user-input/model"; -import { - renderComposerShell, - renderComposerSuggestions, - syncComposerControls as syncComposerControlElements, - syncComposerHeight, -} from "./view/composer"; -import { renderTextWithWikiLinks as renderInlineWikiLinks } from "./view/dom"; -import { messageRenderBlocks, syncMessageRenderBlocks } from "./view/message-stream"; -import { renderPendingRequestMessage } from "./view/pending-request-message"; -import { bottomScrollTop, captureScrollAnchor, isNearScrollBottom, restoreScrollAnchor } from "./view/scroll"; -import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewModel } from "./view/toolbar"; -import { - isPersistedTurnDiffViewState, - persistedTurnDiffViewState, - renderTurnDiffView, - type PersistedTurnDiffViewState, - type TurnDiffViewState, -} from "./view/turn-diff"; +import { CodexPanelView } from "./panel/view"; +import { CodexTurnDiffView } from "./panel/turn-diff-view"; +import { DEFAULT_SETTINGS, getVaultPath, normalizeSettings, settingsMatchNormalizedData, type CodexPanelSettings } from "./settings/model"; +import { CodexPanelSettingTab } from "./settings/tab"; +import { persistedTurnDiffViewState, type TurnDiffViewState } from "./ui/turn-diff"; export default class CodexPanelPlugin extends Plugin { settings: CodexPanelSettings = DEFAULT_SETTINGS; @@ -155,14 +82,6 @@ export default class CodexPanelPlugin extends Plugin { await this.app.workspace.revealLeaf(leaf); } - private createRightSidebarTab(): WorkspaceLeaf | null { - const { workspace } = this.app; - const existing = workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL).find((leaf) => leaf.getRoot() === workspace.rightSplit); - if (!existing) return workspace.getRightLeaf(false); - - return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER); - } - refreshOpenViews(): void { for (const leaf of this.app.workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL)) { if (leaf.view instanceof CodexPanelView) { @@ -190,1276 +109,12 @@ export default class CodexPanelPlugin extends Plugin { async saveSettings(): Promise { await this.saveData(this.settings); } -} -class CodexTurnDiffView extends ItemView { - private metadata: PersistedTurnDiffViewState | null = null; - private payload: TurnDiffViewState | null = null; + private createRightSidebarTab(): WorkspaceLeaf | null { + const { workspace } = this.app; + const existing = workspace.getLeavesOfType(VIEW_TYPE_CODEX_PANEL).find((leaf) => leaf.getRoot() === workspace.rightSplit); + if (!existing) return workspace.getRightLeaf(false); - getViewType(): string { - return VIEW_TYPE_CODEX_TURN_DIFF; - } - - getDisplayText(): string { - return "Codex turn diff"; - } - - getIcon(): string { - return "file-diff"; - } - - getState(): Record { - return this.metadata ? { ...this.metadata } : {}; - } - - async setState(state: unknown, result: ViewStateResult): Promise { - 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 { - 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 { - await copyTextWithNotice(diff, "Copied diff.", "Could not copy diff."); - } -} - -class CodexPanelView extends ItemView { - private client: AppServerClient | null = null; - private readonly connection: ConnectionManager; - private readonly controller: PanelController; - private readonly session: PanelSessionController; - private readonly history: ThreadHistoryLoader; - private readonly threadRename: ThreadRenameController; - private readonly state: PanelState = createPanelState(); - private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; - private readonly blockSignatures = new Map(); - private noteCandidatesCache: NoteCandidate[] | null = null; - private noteEventsRegistered = false; - private composer: HTMLTextAreaElement | null = null; - private composerSuggestEl: HTMLElement | null = null; - private toolbarEl: HTMLElement | null = null; - private configSlotEl: HTMLElement | null = null; - private messagesSlotEl: HTMLElement | null = null; - private composerSlotEl: HTMLElement | null = null; - private scheduledRenderTimer: number | null = null; - private toolbarSignature: string | null = null; - private forceScrollMessagesToBottomOnNextRender = false; - - constructor( - leaf: WorkspaceLeaf, - private readonly plugin: CodexPanelPlugin, - ) { - super(leaf); - this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, { - onNotification: (notification) => { - this.controller.handleNotification(notification); - this.scheduleRender(); - }, - onServerRequest: (request) => { - this.controller.handleServerRequest(request); - this.render(); - }, - onLog: (message) => { - this.controller.handleAppServerLog(message); - this.render(); - }, - onExit: () => { - this.setStatus("Codex app-server stopped."); - clearConnectionScopedState(this.state); - this.threadRename.resetThreadTurnPresence(false); - this.client = null; - this.render(); - }, - }); - this.controller = new PanelController(this.state, { - refreshThreads: () => void this.refreshThreads(), - maybeNameThread: (threadId, turn) => this.threadRename.maybeAutoNameThread(threadId, turn), - respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result), - rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message), - }); - this.session = new PanelSessionController({ - state: this.state, - vaultPath: this.plugin.vaultPath, - currentClient: () => this.connection.currentClient(), - runtimeSnapshot: () => this.runtimeSnapshot(), - setStatus: (status) => this.setStatus(status), - addSystemMessage: (text) => this.addSystemMessage(text), - addDedupedSystemMessage: (text) => this.addDedupedSystemMessage(text), - forceMessagesToBottom: () => this.forceMessagesToBottom(), - }); - this.history = new ThreadHistoryLoader({ - state: this.state, - currentClient: () => this.client, - render: () => this.render(), - addSystemMessage: (text) => this.addSystemMessage(text), - forceMessagesToBottom: () => this.forceMessagesToBottom(), - keepCurrentScrollPosition: () => { - this.forceScrollMessagesToBottomOnNextRender = false; - }, - setThreadTurnPresence: (hadTurns) => this.threadRename.resetThreadTurnPresence(hadTurns), - }); - this.threadRename = new ThreadRenameController({ - state: this.state, - vaultPath: this.plugin.vaultPath, - settings: () => this.plugin.settings, - ensureConnected: () => this.ensureConnected(), - currentClient: () => this.connection.currentClient(), - refreshThreads: () => this.refreshThreads(), - render: () => this.render(), - addSystemMessage: (text) => this.addSystemMessage(text), - }); - } - - getViewType(): string { - return VIEW_TYPE_CODEX_PANEL; - } - - getDisplayText(): string { - return codexPanelDisplayTitle(this.state.activeThreadId, this.state.listedThreads); - } - - getIcon(): string { - return "bot-message-square"; - } - - refreshSettings(): void { - this.render(); - } - - refreshThreadList(): void { - void this.refreshThreads(); - } - - async openThread(threadId: string): Promise { - await this.resumeThread(threadId); - } - - async onOpen(): Promise { - this.registerNoteIndexInvalidation(); - this.registerDomEvent(activeDocument, "pointerdown", (event) => this.closeToolbarPanelOnOutsidePointer(event)); - this.render(); - await this.ensureConnected(); - } - - async onClose(): Promise { - if (this.scheduledRenderTimer !== null) { - window.clearTimeout(this.scheduledRenderTimer); - this.scheduledRenderTimer = null; - } - this.connection.disconnect(); - this.client = null; - } - - setComposerText(text: string): void { - this.setComposerDraft(text, { focus: true, renderIfDetached: true }); - } - - private async ensureConnected(): Promise { - if (this.connection.isConnected()) { - this.client = this.connection.currentClient(); - return; - } - - this.setStatus("Starting Codex app-server..."); - try { - this.state.initializeResponse = await this.connection.connect(); - this.client = this.connection.currentClient(); - if (!this.client) throw new Error("Codex app-server connection did not initialize."); - await this.session.refreshSessionMetadata(); - await this.session.refreshThreadList(); - this.refreshTabHeader(); - this.setStatus("Connected."); - } catch (error) { - if (error instanceof StaleConnectionError) return; - this.setStatus("Connection failed."); - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - new Notice("Codex app-server connection failed."); - } - this.scheduleRender(); - } - - async startNewThread(): Promise { - if (this.state.busy) return; - - await this.ensureConnected(); - if (!this.client) return; - - try { - const response = await this.session.startThread(); - if (!response) return; - this.threadRename.resetThreadTurnPresence(false); - this.state.turnDiffs.clear(); - this.state.displayItems = [this.systemItem(`Started thread ${response.thread.id}`)]; - this.forceMessagesToBottom(); - await this.refreshThreads(); - this.refreshTabHeader(); - this.render(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private async refreshThreads(): Promise { - this.client = this.connection.currentClient(); - if (!this.client) return; - try { - await this.session.refreshThreadList(); - await this.session.refreshSessionMetadata(); - this.refreshTabHeader(); - this.render(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private async resumeThread(threadId: string): Promise { - if (this.state.busy && threadId !== this.state.activeThreadId) { - this.addSystemMessage("Finish or interrupt the current turn before switching threads."); - return; - } - await this.ensureConnected(); - if (!this.client) return; - - try { - const response = await this.client.resumeThread(threadId, this.plugin.vaultPath); - this.state.activeThreadId = response.thread.id; - this.state.activeThreadCwd = response.cwd ?? response.thread.cwd ?? this.plugin.vaultPath; - this.state.activeTurnId = null; - this.state.activeModel = response.model ?? null; - this.state.activeServiceTier = response.serviceTier ?? null; - this.state.activeThreadCliVersion = response.thread.cliVersion ?? null; - this.state.tokenUsage = null; - this.state.displayItems = []; - this.state.turnDiffs.clear(); - this.state.historyCursor = null; - this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread); - this.threadRename.resetThreadTurnPresence(false); - this.refreshTabHeader(); - this.forceMessagesToBottom(); - await this.history.loadLatest(response.thread.id); - if (this.state.displayItems.length === 0) { - this.state.displayItems.push(this.systemItem(`Resumed thread ${response.thread.id}`)); - this.forceMessagesToBottom(); - this.render(); - } - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private refreshTabHeader(): void { - const leaf = this.leaf as WorkspaceLeaf & { - updateHeader?: () => void; - updateDisplay?: () => void; - }; - if (typeof leaf.updateHeader === "function") { - leaf.updateHeader(); - } else if (typeof leaf.updateDisplay === "function") { - leaf.updateDisplay(); - } - } - - private async sendMessage(): Promise { - const text = this.state.composerDraft.trim(); - if (!text) return; - - await this.ensureConnected(); - if (!this.client) return; - - const slashCommand = parseSlashCommand(text); - if (slashCommand) { - this.setComposerDraft("", { clearSuggestions: true }); - const result = await this.executeSlashCommand(slashCommand.command, slashCommand.args); - if (result?.sendText) { - await this.sendTurnText(result.sendText); - } - this.render(); - return; - } - - await this.sendTurnText(text); - } - - private async sendTurnText(text: string): Promise { - const client = this.client; - if (!client) return; - - if (this.state.busy) { - await this.steerCurrentTurn(text); - return; - } - - let optimisticUserId: string | null = null; - try { - if (!this.state.activeThreadId) { - const threadResponse = await this.session.startThread(); - if (!threadResponse) return; - this.refreshTabHeader(); - this.threadRename.resetThreadTurnPresence(false); - } - - optimisticUserId = `local-user-${Date.now()}`; - this.state.displayItems.push({ - id: optimisticUserId, - kind: "message", - role: "user", - text, - copyText: text, - markdown: true, - }); - this.forceMessagesToBottom(); - this.setComposerDraft(""); - this.state.busy = true; - this.render(); - - const turnSettings = requestedTurnRuntimeSettings(this.runtimeSnapshot()); - if (turnSettings.warning) { - this.addSystemMessage(`${this.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`); - } - const codexInput = this.codexInput(text); - const activeThreadId = this.state.activeThreadId; - if (!activeThreadId) return; - const response = await client.startTurn( - activeThreadId, - this.plugin.vaultPath, - codexInput, - requestedOrConfiguredServiceTier(this.runtimeSnapshot()), - turnSettings.collaborationMode, - turnSettings.model, - turnSettings.effort, - ); - this.state.requestedModel = commitRuntimeOverride(this.state.requestedModel); - this.state.requestedReasoningEffort = commitRuntimeOverride(this.state.requestedReasoningEffort); - this.state.activeTurnId = response.turn.id; - this.state.displayItems = this.state.displayItems.map((item) => - item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item, - ); - this.setStatus("Turn running..."); - } catch (error) { - this.state.busy = false; - if (optimisticUserId) this.state.displayItems = this.state.displayItems.filter((item) => item.id !== optimisticUserId); - this.setComposerDraft(text); - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - this.scheduleRender(); - } - - private async steerCurrentTurn(text: string): Promise { - if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) { - this.addSystemMessage("Current turn is not steerable yet."); - return; - } - - const threadId = this.state.activeThreadId; - const expectedTurnId = this.state.activeTurnId; - - this.setComposerDraft("", { clearSuggestions: true }); - this.syncComposerControls(); - - try { - await this.client.steerTurn(threadId, expectedTurnId, this.codexInput(text)); - this.state.displayItems.push({ - id: `local-steer-${Date.now()}`, - kind: "message", - role: "user", - text, - copyText: text, - turnId: expectedTurnId, - markdown: true, - }); - this.forceMessagesToBottom(); - this.setStatus("Steered current turn."); - } catch (error) { - this.setComposerDraft(text, { focus: true }); - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - - this.scheduleRender(); - } - - private async interruptTurn(): Promise { - if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) return; - try { - await this.client.interruptTurn(this.state.activeThreadId, this.state.activeTurnId); - this.setStatus("Interrupt requested."); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private async submitComposerAction(): Promise { - const draft = this.composer?.value.trim() ?? this.state.composerDraft.trim(); - if (this.state.busy && this.state.activeThreadId && this.state.activeTurnId && draft.length === 0) { - await this.interruptTurn(); - return; - } - await this.sendMessage(); - } - - private async executeSlashCommand(command: SlashCommandName, args: string): Promise { - if (!this.client) return; - return runSlashCommand(command, args, { - activeThreadId: this.state.activeThreadId, - listedThreads: this.state.listedThreads, - startNewThread: () => this.startNewThread(), - resumeThread: (threadId) => this.resumeThread(threadId), - forkThread: (threadId) => this.forkThread(threadId), - rollbackThread: (threadId) => this.rollbackThread(threadId), - compactThread: async (threadId) => { - await this.client?.compactThread(threadId); - }, - busy: this.state.busy, - toggleFastMode: () => this.toggleFastMode(), - toggleCollaborationMode: () => this.toggleCollaborationMode(), - addSystemMessage: (text) => this.addSystemMessage(text), - setStatus: (status) => this.setStatus(status), - setRequestedModel: (model) => this.setRequestedModel(model), - setRequestedReasoningEffort: (effort) => this.setRequestedReasoningEffort(effort), - statusSummaryLines: () => this.statusSummaryLines(), - connectionDiagnosticLines: () => this.connectionDiagnosticLines(), - modelStatusLines: () => this.modelStatusLines(), - effortStatusLines: () => this.effortStatusLines(), - }); - } - - private toggleFastMode(): void { - const current = currentServiceTier(this.runtimeSnapshot(), configRecord(this.state.effectiveConfig)); - const next: ServiceTier = current === "fast" ? "standard" : "fast"; - this.state.requestedServiceTier = next; - this.state.activeServiceTier = next; - this.state.runtimePicker = null; - this.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns."); - } - - private toggleCollaborationMode(): void { - const next = nextCollaborationMode(this.state.requestedCollaborationMode); - this.state.requestedCollaborationMode = next; - this.state.runtimePicker = null; - this.addSystemMessage(collaborationModeToggleMessage(next)); - } - - private toggleRuntimePicker(picker: NonNullable): void { - this.state.runtimePicker = this.state.runtimePicker === picker ? null : picker; - if (this.state.runtimePicker !== null) { - this.state.openDetails.delete("history"); - this.state.openDetails.delete("status-panel"); - } - this.render(); - } - - private setRequestedModelFromUi(model: string | null): void { - this.setRequestedModel(model); - this.state.runtimePicker = null; - this.addSystemMessage(modelOverrideMessage(model)); - } - - private setRequestedModel(model: string | null): void { - this.state.requestedModel = model === null ? resetRuntimeOverride() : setRuntimeOverride(model); - } - - private setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): void { - this.setRequestedReasoningEffort(effort); - this.state.runtimePicker = null; - this.addSystemMessage(reasoningEffortOverrideMessage(effort)); - } - - private setRequestedReasoningEffort(effort: ReasoningEffort | null): void { - this.state.requestedReasoningEffort = effort === null ? resetRuntimeOverride() : setRuntimeOverride(effort); - } - - private async resolveApproval(approval: PendingApproval, action: ApprovalAction): Promise { - this.controller.resolveApproval(approval, action); - this.render(); - } - - private respondToServerRequest(requestId: Parameters[0], result: unknown): boolean { - try { - this.client?.respondToServerRequest(requestId, result); - return Boolean(this.client); - } catch { - return false; - } - } - - private rejectServerRequest(requestId: Parameters[0], code: number, message: string): boolean { - try { - this.client?.rejectServerRequest(requestId, code, message); - return Boolean(this.client); - } catch { - return false; - } - } - - private async resolveUserInput(input: PendingUserInput): Promise { - this.controller.resolveUserInput(input, this.answersForUserInput(input)); - this.render(); - } - - private async cancelUserInput(input: PendingUserInput): Promise { - this.controller.cancelUserInput(input); - this.render(); - } - - private systemItem(text: string): DisplayItem { - return createSystemItem(text); - } - - private addSystemMessage(text: string): void { - this.controller.addSystemMessage(text); - this.render(); - } - - private addDedupedSystemMessage(text: string): void { - this.controller.addDedupedSystemMessage(text); - this.render(); - } - - private setStatus(status: string): void { - this.state.status = status; - } - - private render(): void { - if (this.scheduledRenderTimer !== null) { - window.clearTimeout(this.scheduledRenderTimer); - this.scheduledRenderTimer = null; - } - const root = this.containerEl.children[1] as HTMLElement; - if (!this.toolbarEl || !this.configSlotEl || !this.messagesSlotEl || !this.composerSlotEl) { - this.renderShell(root); - } - if (!this.toolbarEl || !this.configSlotEl || !this.messagesSlotEl || !this.composerSlotEl) { - return; - } - - this.renderToolbarIfNeeded(this.toolbarEl); - - this.configSlotEl.empty(); - - this.renderMessages(this.messagesSlotEl); - this.renderComposer(this.composerSlotEl); - this.syncComposerControls(); - } - - private renderToolbarIfNeeded(toolbar: HTMLElement): void { - const model = this.toolbarViewModel(); - const signature = toolbarSignature(model); - if (this.toolbarSignature === signature) return; - - this.toolbarSignature = signature; - renderToolbar(toolbar, model, { - toggleHistory: () => this.toggleHistoryPanel(), - toggleStatusPanel: () => this.toggleStatusPanel(), - togglePlan: () => this.toggleCollaborationMode(), - toggleFast: () => this.toggleFastMode(), - toggleRuntime: () => this.toggleRuntimePicker("model"), - connect: () => void this.reconnectFromToolbar(), - refreshThreads: () => { - this.state.openDetails.delete("status-panel"); - void this.refreshThreads(); - }, - resumeThread: (threadId) => { - if (this.state.busy && threadId !== this.state.activeThreadId) return; - this.state.openDetails.delete("history"); - void this.resumeThread(threadId); - }, - archiveThread: (threadId) => void this.archiveThread(threadId), - startRenameThread: (threadId) => this.threadRename.start(threadId), - updateRenameDraft: (threadId, value) => this.threadRename.updateDraft(threadId, value), - saveRenameThread: (threadId, value) => void this.threadRename.save(threadId, value), - cancelRenameThread: (threadId) => this.threadRename.cancel(threadId), - autoNameThread: (threadId) => void this.threadRename.autoNameDraft(threadId), - }); - } - - private toolbarViewModel(): ToolbarViewModel { - const snapshot = this.runtimeSnapshot(); - const config = configRecord(this.state.effectiveConfig); - const context = contextSummary(snapshot); - const limit = rateLimitSummary(snapshot); - const historyOpen = this.state.openDetails.has("history"); - const statusPanelOpen = this.state.openDetails.has("status-panel"); - const runtimeOpen = this.state.runtimePicker !== null; - const statusState = this.state.busy ? "running" : this.connection.isConnected() ? "connected" : "offline"; - const model = currentModel(snapshot, config); - const effort = currentReasoningEffort(snapshot, config); - const threads = this.state.listedThreads; - return { - connected: this.connection.isConnected(), - status: this.state.status, - statusState, - historyOpen, - statusPanelOpen, - runtimeOpen, - planActive: this.state.requestedCollaborationMode === "plan", - fastActive: currentServiceTier(snapshot, config) === "fast", - runtimeSummary: runtimeSummaryLabel(model, effort), - runtimeTitle: `Model: ${model ?? "(from default)"}; Effort: ${effort ?? "(from default)"}`, - runtimeAriaLabel: `Runtime: ${model ?? "default"} ${effort ?? "default"}`, - runtimeEmphasized: this.state.requestedModel.kind !== "default" || this.state.requestedReasoningEffort.kind !== "default", - context: context ? { ...context, label: compactContextLabel(context.percent, context.label) } : null, - rateLimit: limit, - configSections: effectiveConfigSections(snapshot, this.plugin.vaultPath), - openPanel: historyOpen ? "history" : runtimeOpen ? "runtime" : statusPanelOpen ? "status" : null, - threads: threads.map((thread) => { - const threadId = thread.id; - return { - title: getThreadTitle(thread), - threadId, - selected: threadId === this.state.activeThreadId, - disabled: this.state.busy && threadId !== this.state.activeThreadId, - canArchive: true, - rename: this.threadRename.editState(threadId), - }; - }), - modelChoices: this.modelToolbarChoices(), - effortChoices: this.effortToolbarChoices(), - connectLabel: this.connection.isConnected() ? "Reconnect" : "Connect", - diagnostics: this.connectionDiagnosticRows(), - }; - } - - private async reconnectFromToolbar(): Promise { - const threadId = this.state.activeThreadId; - this.state.openDetails.delete("status-panel"); - this.connection.reconnect(); - this.client = null; - this.state.busy = false; - this.state.activeTurnId = null; - this.state.approvals = []; - this.state.pendingUserInputs = []; - this.state.userInputDrafts.clear(); - this.setStatus("Reconnecting..."); - this.render(); - - await this.ensureConnected(); - if (!threadId || !this.client) return; - - try { - await this.resumeThread(threadId); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private modelToolbarChoices(): ToolbarChoice[] { - const snapshot = this.runtimeSnapshot(); - const models = sortedAvailableModels(this.state.availableModels); - const choices: ToolbarChoice[] = [ - { - label: "Default", - selected: this.state.requestedModel.kind !== "set", - onClick: () => this.setRequestedModelFromUi(null), - }, - ]; - choices.push( - ...models.slice(0, 12).map((model) => ({ - label: model.model, - selected: currentModel(snapshot) === model.model, - onClick: () => this.setRequestedModelFromUi(model.model), - })), - ); - if (models.length === 0) { - choices.push({ - label: "No model list available.", - disabled: true, - onClick: () => undefined, - }); - } - return choices; - } - - private effortToolbarChoices(): ToolbarChoice[] { - const snapshot = this.runtimeSnapshot(); - return [ - { - label: "Default", - selected: this.state.requestedReasoningEffort.kind !== "set", - onClick: () => this.setRequestedReasoningEffortFromUi(null), - }, - ...supportedReasoningEfforts(snapshot).map((effort) => ({ - label: effort, - selected: currentReasoningEffort(snapshot) === effort, - onClick: () => this.setRequestedReasoningEffortFromUi(effort), - })), - ]; - } - - private toggleHistoryPanel(): void { - if (this.state.openDetails.has("history")) { - this.state.openDetails.delete("history"); - } else { - this.state.openDetails.delete("status-panel"); - this.state.runtimePicker = null; - this.state.openDetails.add("history"); - } - this.scheduleRender(); - } - - private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { - if (!this.hasOpenToolbarPanel()) return; - - const target = event.target; - if (target instanceof Element) { - const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); - if (insideToolbarPanel && this.containerEl.contains(insideToolbarPanel)) return; - } - - this.closeToolbarPanel(); - } - - private hasOpenToolbarPanel(): boolean { - return this.state.openDetails.has("history") || this.state.openDetails.has("status-panel") || this.state.runtimePicker !== null; - } - - private closeToolbarPanel(): void { - if (!this.hasOpenToolbarPanel()) return; - - this.state.openDetails.delete("history"); - this.state.openDetails.delete("status-panel"); - this.state.runtimePicker = null; - this.scheduleRender(); - } - - private scheduleRender(): void { - if (this.scheduledRenderTimer !== null) return; - this.scheduledRenderTimer = window.setTimeout(() => { - this.scheduledRenderTimer = null; - this.render(); - }, 50); - } - - private renderShell(root: HTMLElement): void { - root.empty(); - root.addClass("codex-panel"); - this.toolbarEl = root.createDiv({ cls: "codex-panel__toolbar" }); - const body = root.createDiv({ cls: "codex-panel__body" }); - this.configSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--config" }); - this.messagesSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--messages" }); - this.composerSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--composer" }); - } - - private toggleStatusPanel(): void { - if (this.state.openDetails.has("status-panel")) { - this.state.openDetails.delete("status-panel"); - } else { - this.state.openDetails.delete("history"); - this.state.runtimePicker = null; - this.state.openDetails.add("status-panel"); - } - this.scheduleRender(); - } - - private statusSummaryLines(): string[] { - const snapshot = this.runtimeSnapshot(); - const context = contextSummary(snapshot); - const limit = rateLimitSummary(snapshot); - return [ - "Session status", - `Session: ${this.state.activeThreadId ?? "(none)"}`, - context ? context.title : "Context: not available", - ...(limit ? usageLimitStatusLines(limit) : ["Usage limits: not available"]), - ]; - } - - private modelStatusLines(): string[] { - const snapshot = this.runtimeSnapshot(); - const config = configRecord(this.state.effectiveConfig); - return [ - `Model: ${currentModel(snapshot, config) ?? "(from default)"}`, - `Override: ${runtimeOverrideLabel(this.state.requestedModel)}`, - `Provider: ${statusValue(config.model_provider, "(from default)")}`, - `Effort: ${currentReasoningEffort(snapshot, config) ?? "(from default)"}`, - `Mode: ${this.collaborationModeLabel()}`, - `Service tier: ${serviceTierLabel(snapshot, config)}`, - ]; - } - - private effortStatusLines(): string[] { - const snapshot = this.runtimeSnapshot(); - const config = configRecord(this.state.effectiveConfig); - return [ - `Effort: ${currentReasoningEffort(snapshot, config) ?? "(from default)"}`, - `Override: ${runtimeOverrideLabel(this.state.requestedReasoningEffort)}`, - `Supported: ${supportedReasoningEfforts(snapshot).join(", ")}`, - ]; - } - - private connectionDiagnosticRows() { - return connectionDiagnosticRows({ - connected: this.connection.isConnected(), - configuredCommand: this.plugin.settings.codexPath, - initializeResponse: this.state.initializeResponse, - activeThreadCliVersion: this.state.activeThreadCliVersion, - compatibility: this.state.appServerCompatibility, - }); - } - - private connectionDiagnosticLines(): string[] { - return connectionDiagnosticLines(this.connectionDiagnosticRows()); - } - - private collaborationModeLabel(): string { - return formatCollaborationModeLabel(this.state.requestedCollaborationMode); - } - - private runtimeSnapshot(): RuntimeSnapshot { - return { - effectiveConfig: this.state.effectiveConfig, - activeThreadId: this.state.activeThreadId, - activeModel: this.state.activeModel, - activeServiceTier: this.state.activeServiceTier, - requestedModel: this.state.requestedModel, - requestedReasoningEffort: this.state.requestedReasoningEffort, - requestedCollaborationMode: this.state.requestedCollaborationMode, - requestedServiceTier: this.state.requestedServiceTier, - tokenUsage: this.state.tokenUsage, - rateLimit: this.state.rateLimit, - displayItems: this.state.displayItems, - availableModels: this.state.availableModels, - }; - } - - private renderPendingRequestMessage(parent: HTMLElement): void { - renderPendingRequestMessage( - parent, - this.state.approvals, - this.state.pendingUserInputs, - { - values: this.state.userInputDrafts, - draftKey: userInputDraftKey, - otherDraftKey: userInputOtherDraftKey, - }, - this.state.openDetails, - { - resolveApproval: (approval, action) => void this.resolveApproval(approval, action), - resolveUserInput: (input) => void this.resolveUserInput(input), - cancelUserInput: (input) => void this.cancelUserInput(input), - }, - ); - } - - private answersForUserInput(input: PendingUserInput): Record { - return Object.fromEntries( - input.params.questions.map((question) => [ - question.id, - this.state.userInputDrafts.get(userInputDraftKey(input.requestId, question.id)) ?? questionDefaultAnswer(question), - ]), - ); - } - - private async archiveThread(threadId: string): Promise { - if (this.state.busy) { - this.addSystemMessage("Finish or interrupt the current turn before archiving threads."); - return; - } - if (!this.client) return; - try { - await this.client.archiveThread(threadId); - if (this.state.activeThreadId === threadId) { - clearActiveThreadState(this.state); - this.threadRename.resetThreadTurnPresence(false); - } - await this.refreshThreads(); - this.render(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private async forkThread(threadId: string): Promise { - if (this.state.busy) { - this.addSystemMessage("Finish or interrupt the current turn before forking threads."); - return; - } - await this.ensureConnected(); - if (!this.client) return; - - try { - const sourceName = inheritedForkThreadName(threadId, this.state.listedThreads); - const response = await this.client.forkThread(threadId, this.plugin.vaultPath); - const forkedThreadId = response.thread.id; - if (sourceName) { - try { - await this.client.setThreadName(forkedThreadId, sourceName); - this.plugin.refreshOpenThreadLists(); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`); - } - } - try { - await this.plugin.openThreadInNewView(forkedThreadId); - } catch (error) { - const message = error instanceof Error ? error.message : String(error); - this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`); - } - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - } - } - - private async rollbackThread(threadId: string): Promise { - if (this.state.busy) { - this.addSystemMessage("Interrupt the current turn before rolling back."); - return; - } - await this.ensureConnected(); - if (!this.client) return; - - const candidate = rollbackCandidateFromItems(this.state.displayItems); - if (!candidate) { - this.addSystemMessage("No completed turn to roll back."); - return; - } - - try { - this.setStatus("Rolling back latest turn..."); - const response = await this.client.rollbackThread(threadId); - this.state.activeThreadId = response.thread.id; - this.state.activeThreadCwd = response.thread.cwd ?? this.state.activeThreadCwd; - this.state.activeTurnId = null; - this.state.tokenUsage = null; - this.state.historyCursor = null; - this.state.turnDiffs.clear(); - this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread); - await this.history.loadLatest(response.thread.id); - this.setComposerText(candidate.text); - this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); - this.setStatus("Rolled back latest turn."); - this.refreshTabHeader(); - await this.refreshThreads(); - } catch (error) { - this.addSystemMessage(error instanceof Error ? error.message : String(error)); - this.setStatus("Rollback failed."); - } - } - - private forceMessagesToBottom(): void { - this.state.messagesPinnedToBottom = true; - this.forceScrollMessagesToBottomOnNextRender = true; - } - - private renderMessages(parent: HTMLElement): void { - const messagesEl = parent.querySelector(".codex-panel__messages") ?? parent.createDiv({ cls: "codex-panel__messages" }); - messagesEl.onscroll = () => { - this.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); - }; - const wasNearBottom = isNearScrollBottom(messagesEl); - const shouldScrollToBottom = this.forceScrollMessagesToBottomOnNextRender || wasNearBottom; - const scrollAnchor = shouldScrollToBottom ? null : captureScrollAnchor(messagesEl); - this.forceScrollMessagesToBottomOnNextRender = false; - this.state.messagesPinnedToBottom = shouldScrollToBottom; - const rollbackCandidate = this.state.busy ? null : rollbackCandidateFromItems(this.state.displayItems); - - const blocks = messageRenderBlocks({ - activeThreadId: this.state.activeThreadId, - activeTurnId: this.state.activeTurnId, - historyCursor: this.state.historyCursor, - loadingHistory: this.state.loadingHistory, - busy: this.state.busy, - displayItems: this.state.displayItems, - turnDiffs: this.state.turnDiffs, - workspaceRoot: this.state.activeThreadCwd ?? this.plugin.vaultPath, - openDetails: this.state.openDetails, - onDetailsToggle: () => { - window.requestAnimationFrame(() => { - this.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); - }); - }, - loadOlderTurns: () => void this.history.loadOlder(), - renderMarkdown: (element, text) => this.renderMarkdownMessage(element, text), - renderTextWithWikiLinks: (element, text) => this.renderTextWithWikiLinks(element, text), - copyText: (text) => void this.copyMessageText(text), - canRollbackItem: (item) => isRollbackCandidateItem(item, rollbackCandidate), - onRollbackItem: () => { - if (this.state.activeThreadId) void this.rollbackThread(this.state.activeThreadId); - }, - openTurnDiff: (state) => void this.plugin.openTurnDiff(state), - pendingRequestsSignature: this.pendingRequestsSignature(), - renderPendingRequests: () => this.createPendingRequestsElement(), - }); - syncMessageRenderBlocks(messagesEl, blocks, this.blockSignatures); - - window.requestAnimationFrame(() => { - if (shouldScrollToBottom) { - messagesEl.scrollTop = bottomScrollTop(messagesEl); - } else { - restoreScrollAnchor(messagesEl, scrollAnchor); - } - this.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); - }); - } - - private async copyMessageText(text: string): Promise { - await copyTextWithNotice(text, "Copied message.", "Could not copy message."); - } - - private renderMarkdownMessage(parent: HTMLElement, text: string): void { - const sourcePath = this.app.workspace.getActiveFile()?.path ?? ""; - void MarkdownRenderer.render(this.app, text, parent, sourcePath, this).then(() => { - this.bindRenderedWikiLinks(parent, sourcePath); - this.scrollMarkdownMessageIntoPinnedBottom(parent); - }); - } - - private pendingRequestsSignature(): string { - return requestStateSignature(this.state.approvals, this.state.pendingUserInputs, this.state.userInputDrafts); - } - - private createPendingRequestsElement(): HTMLElement | null { - if (this.state.approvals.length === 0 && this.state.pendingUserInputs.length === 0) return null; - const container = createDiv(); - this.renderPendingRequestMessage(container); - return container.firstElementChild as HTMLElement | null; - } - - private scrollMarkdownMessageIntoPinnedBottom(parent: HTMLElement): void { - if (!this.state.messagesPinnedToBottom) return; - const messagesEl = parent.closest(".codex-panel__messages"); - if (!messagesEl) return; - window.requestAnimationFrame(() => { - if (!this.state.messagesPinnedToBottom) return; - messagesEl.scrollTop = bottomScrollTop(messagesEl); - this.state.messagesPinnedToBottom = isNearScrollBottom(messagesEl); - }); - } - - private bindRenderedWikiLinks(parent: HTMLElement, sourcePath: string): void { - parent.querySelectorAll("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.app.workspace.openLinkText(target, sourcePath, false); - } - }; - }); - } - - private renderTextWithWikiLinks(parent: HTMLElement, text: string): void { - renderInlineWikiLinks(parent, text, (target) => { - const sourcePath = this.app.workspace.getActiveFile()?.path ?? ""; - void this.app.workspace.openLinkText(target, sourcePath, false); - }); - } - - private renderComposer(parent: HTMLElement): void { - if (this.composer && parent.contains(this.composer)) { - return; - } - - const elements = renderComposerShell(parent, this.viewId, this.state.composerDraft, this.state.busy, { - onInput: () => { - this.state.composerDraft = this.composer?.value ?? ""; - this.state.composerSuggestionsDismissedSignature = null; - this.updateComposerSuggestions(); - this.syncComposerControls(); - }, - onUpdateSuggestions: () => this.updateComposerSuggestions(), - onKeydown: (event) => { - if (this.handleComposerSuggestionKeydown(event)) { - return; - } - if (isComposerSendKey(event, this.plugin.settings.sendShortcut)) { - event.preventDefault(); - void this.submitComposerAction(); - } - }, - onNewThread: () => void this.startNewThread(), - onSendOrInterrupt: () => void this.submitComposerAction(), - onSuggestionHover: (index) => { - if (this.state.composerSuggestSelected === index) return; - this.state.composerSuggestSelected = index; - this.renderComposerSuggestions(); - }, - onSuggestionInsert: (suggestion) => this.insertComposerSuggestion(suggestion), - }); - this.composer = elements.composer; - this.composerSuggestEl = elements.suggestions; - this.updateComposerSuggestions(); - } - - private setComposerDraft(text: string, options: { focus?: boolean; clearSuggestions?: boolean; renderIfDetached?: boolean } = {}): void { - this.state.composerDraft = text; - if (options.clearSuggestions) this.clearComposerSuggestions(); - if (!this.composer) { - if (options.renderIfDetached) this.render(); - return; - } - - this.composer.value = text; - syncComposerHeight(this.composer); - if (options.focus) this.composer.focus(); - } - - private syncComposerControls(): void { - const canInterrupt = this.state.busy && Boolean(this.state.activeThreadId && this.state.activeTurnId); - syncComposerControlElements(this.composerSlotEl, this.composer, this.state.busy, canInterrupt); - } - - private handleComposerSuggestionKeydown(event: KeyboardEvent): boolean { - if (event.isComposing) return false; - if (this.state.composerSuggestions.length === 0) return false; - - const direction = composerSuggestionNavigationDirection(event); - if (direction) { - event.preventDefault(); - this.state.composerSuggestSelected = nextComposerSuggestionIndex( - this.state.composerSuggestSelected, - this.state.composerSuggestions.length, - direction, - ); - this.renderComposerSuggestions(); - return true; - } - if (event.metaKey || event.ctrlKey) return false; - - if (event.key === "Enter" || event.key === "Tab") { - event.preventDefault(); - this.insertComposerSuggestion(this.state.composerSuggestions[this.state.composerSuggestSelected]); - return true; - } - - if (event.key === "Escape") { - event.preventDefault(); - this.dismissComposerSuggestions(); - return true; - } - - return false; - } - - private updateComposerSuggestions(): void { - if (!this.composer) { - this.clearComposerSuggestions(); - return; - } - - const cursor = this.composer.selectionStart; - const signature = this.composerSuggestionSignature(); - if (this.state.composerSuggestionsDismissedSignature === signature) { - this.state.composerSuggestions = []; - this.renderComposerSuggestions(); - return; - } - const beforeCursor = this.composer.value.slice(0, cursor); - const suggestions = activeComposerSuggestions( - beforeCursor, - this.noteCandidates(), - this.state.availableSkills, - this.state.listedThreads, - ); - - this.state.composerSuggestions = suggestions; - if (this.state.composerSuggestSelected >= this.state.composerSuggestions.length) { - this.state.composerSuggestSelected = 0; - } - this.renderComposerSuggestions(); - } - - private renderComposerSuggestions(): void { - renderComposerSuggestions( - this.composerSuggestEl, - this.composer, - this.viewId, - this.state.composerSuggestions, - this.state.composerSuggestSelected, - { - onSuggestionHover: (index) => { - if (this.state.composerSuggestSelected === index) return; - this.state.composerSuggestSelected = index; - this.renderComposerSuggestions(); - }, - onSuggestionInsert: (suggestion) => this.insertComposerSuggestion(suggestion), - }, - ); - } - - private insertComposerSuggestion(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.state.composerDraft = insertion.value; - this.composer.value = insertion.value; - syncComposerHeight(this.composer); - this.composer.focus(); - this.composer.setSelectionRange(insertion.cursor, insertion.cursor); - this.clearComposerSuggestions(); - } - - private clearComposerSuggestions(): void { - this.state.composerSuggestSelected = 0; - this.state.composerSuggestions = []; - this.composer?.setAttr("aria-expanded", "false"); - this.composer?.removeAttribute("aria-activedescendant"); - this.composerSuggestEl?.empty(); - this.composerSuggestEl?.hide(); - } - - private dismissComposerSuggestions(): void { - this.state.composerSuggestionsDismissedSignature = this.composerSuggestionSignature(); - this.clearComposerSuggestions(); - } - - private composerSuggestionSignature(): 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.app); - } - return this.noteCandidatesCache; - } - - private codexInput(text: string): UserInput[] { - return userInputWithWikiLinkMentions(text, (target) => resolveAppWikiLinkMention(this.app, target)); - } - - private registerNoteIndexInvalidation(): void { - if (this.noteEventsRegistered) return; - this.noteEventsRegistered = true; - const invalidate = () => { - this.noteCandidatesCache = null; - }; - this.registerEvent(this.app.vault.on("create", invalidate)); - this.registerEvent(this.app.vault.on("delete", invalidate)); - this.registerEvent(this.app.vault.on("rename", invalidate)); - this.registerEvent(this.app.vault.on("modify", invalidate)); + return workspace.createLeafInParent(existing.parent, Number.MAX_SAFE_INTEGER); } } diff --git a/src/panel/composer-controller.ts b/src/panel/composer-controller.ts new file mode 100644 index 00000000..92da8668 --- /dev/null +++ b/src/panel/composer-controller.ts @@ -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; + } +} diff --git a/src/panel/controller.ts b/src/panel/controller.ts index bfb0d3d4..1f91347f 100644 --- a/src/panel/controller.ts +++ b/src/panel/controller.ts @@ -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"; diff --git a/src/panel/message-renderer.ts b/src/panel/message-renderer.ts new file mode 100644 index 00000000..96c6caeb --- /dev/null +++ b/src/panel/message-renderer.ts @@ -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; + 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(".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 { + 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(".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("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); + }); + } +} diff --git a/src/panel/session-controller.ts b/src/panel/session-controller.ts index 07e1c14d..e0157b40 100644 --- a/src/panel/session-controller.ts +++ b/src/panel/session-controller.ts @@ -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; diff --git a/src/panel/slash-commands.ts b/src/panel/slash-commands.ts index f4bc0012..18a98c41 100644 --- a/src/panel/slash-commands.ts +++ b/src/panel/slash-commands.ts @@ -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; diff --git a/src/state/panel-state.ts b/src/panel/state.ts similarity index 99% rename from src/state/panel-state.ts rename to src/panel/state.ts index bc8b9c99..9c5cbe06 100644 --- a/src/state/panel-state.ts +++ b/src/panel/state.ts @@ -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; diff --git a/src/panel/status-lines.ts b/src/panel/status-lines.ts index 37ab2b93..a568e8c3 100644 --- a/src/panel/status-lines.ts +++ b/src/panel/status-lines.ts @@ -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; diff --git a/src/panel/thread-history.ts b/src/panel/thread-history.ts index 80f46cf0..3eb85bd8 100644 --- a/src/panel/thread-history.ts +++ b/src/panel/thread-history.ts @@ -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; diff --git a/src/panel/thread-naming.ts b/src/panel/thread-naming.ts index 58cfa220..cb88519f 100644 --- a/src/panel/thread-naming.ts +++ b/src/panel/thread-naming.ts @@ -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; diff --git a/src/panel/thread-rename.ts b/src/panel/thread-rename.ts index 4782d36b..91a4c40e 100644 --- a/src/panel/thread-rename.ts +++ b/src/panel/thread-rename.ts @@ -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, diff --git a/src/panel/turn-diff-view.ts b/src/panel/turn-diff-view.ts new file mode 100644 index 00000000..c1a301a2 --- /dev/null +++ b/src/panel/turn-diff-view.ts @@ -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 { + return this.metadata ? { ...this.metadata } : {}; + } + + async setState(state: unknown, result: ViewStateResult): Promise { + 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 { + 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 { + await copyTextWithNotice(diff, "Copied diff.", "Could not copy diff."); + } +} diff --git a/src/panel/view.ts b/src/panel/view.ts new file mode 100644 index 00000000..41c9611e --- /dev/null +++ b/src/panel/view.ts @@ -0,0 +1,1026 @@ +import { ItemView, Notice, type WorkspaceLeaf } from "obsidian"; + +import type { AppServerClient } from "../app-server/client"; +import { ConnectionManager, StaleConnectionError } from "../app-server/connection-manager"; +import type { ServiceTier } from "../app-server/service-tier"; +import type { ApprovalAction, PendingApproval } from "../approvals/model"; +import { parseSlashCommand } from "../composer/suggestions"; +import { VIEW_TYPE_CODEX_PANEL } from "../constants"; +import { createSystemItem } from "../display/model"; +import type { DisplayItem } from "../display/types"; +import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort"; +import { + collaborationModeLabel as formatCollaborationModeLabel, + collaborationModeToggleMessage, + nextCollaborationMode, +} from "../runtime/collaboration-mode"; +import { PanelController } from "./controller"; +import { connectionDiagnosticLines, connectionDiagnosticRows } from "./diagnostics"; +import { rollbackCandidateFromItems } from "./rollback"; +import { contextSummary, effectiveConfigSections, rateLimitSummary } from "../runtime/view"; +import { + commitRuntimeOverride, + configRecord, + currentModel, + currentReasoningEffort, + currentServiceTier, + requestedOrConfiguredServiceTier, + requestedTurnRuntimeSettings, + resetRuntimeOverride, + runtimeOverrideLabel, + runtimeSummaryLabel, + serviceTierLabel, + setRuntimeOverride, + sortedAvailableModels, + supportedReasoningEfforts, + type RuntimeSnapshot, +} from "../runtime/state"; +import { compactContextLabel, modelOverrideMessage, reasoningEffortOverrideMessage } from "../runtime/settings"; +import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type SlashCommandName } from "./slash-commands"; +import { PanelSessionController } from "./session-controller"; +import { statusValue, usageLimitStatusLines } from "./status-lines"; +import { ThreadHistoryLoader } from "./thread-history"; +import { ThreadRenameController } from "./thread-rename"; +import { pendingRequestsSignature as requestStateSignature, userInputDraftKey, userInputOtherDraftKey } from "./request-state"; +import type { CodexPanelSettings } from "../settings/model"; +import { questionDefaultAnswer, type PendingUserInput } from "../user-input/model"; +import { PanelComposerController } from "./composer-controller"; +import { clearActiveThreadState, clearConnectionScopedState, createPanelState, type PanelState } from "./state"; +import { codexPanelDisplayTitle, getThreadTitle, inheritedForkThreadName, upsertThread } from "../threads/model"; +import { renderPendingRequestMessage } from "../ui/pending-request-message"; +import { renderToolbar, toolbarSignature, type ToolbarChoice, type ToolbarViewModel } from "../ui/toolbar"; +import type { TurnDiffViewState } from "../ui/turn-diff"; +import { PanelMessageRenderer } from "./message-renderer"; + +export interface CodexPanelHost { + readonly settings: CodexPanelSettings; + readonly vaultPath: string; + openThreadInNewView(threadId: string): Promise; + openTurnDiff(state: TurnDiffViewState): Promise; + refreshOpenThreadLists(): void; +} + +export class CodexPanelView extends ItemView { + private client: AppServerClient | null = null; + private readonly connection: ConnectionManager; + private readonly controller: PanelController; + private readonly session: PanelSessionController; + private readonly history: ThreadHistoryLoader; + private readonly threadRename: ThreadRenameController; + private readonly state: PanelState = createPanelState(); + private readonly viewId = `codex-panel-${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`; + private readonly composerController: PanelComposerController; + private readonly messageRenderer: PanelMessageRenderer; + private readonly blockSignatures = new Map(); + private toolbarEl: HTMLElement | null = null; + private configSlotEl: HTMLElement | null = null; + private messagesSlotEl: HTMLElement | null = null; + private composerSlotEl: HTMLElement | null = null; + private scheduledRenderTimer: number | null = null; + private toolbarSignature: string | null = null; + private forceScrollMessagesToBottomOnNextRender = false; + + constructor( + leaf: WorkspaceLeaf, + private readonly plugin: CodexPanelHost, + ) { + super(leaf); + this.messageRenderer = new PanelMessageRenderer({ + app: this.app, + owner: this, + state: this.state, + vaultPath: this.plugin.vaultPath, + blockSignatures: this.blockSignatures, + consumeForceScrollToBottom: () => { + const value = this.forceScrollMessagesToBottomOnNextRender; + this.forceScrollMessagesToBottomOnNextRender = false; + return value; + }, + loadOlderTurns: () => void this.history.loadOlder(), + rollbackThread: (threadId) => void this.rollbackThread(threadId), + openTurnDiff: (state) => void this.plugin.openTurnDiff(state), + pendingRequestsSignature: () => this.pendingRequestsSignature(), + renderPendingRequests: () => this.createPendingRequestsElement(), + }); + this.composerController = new PanelComposerController({ + app: this.app, + state: this.state, + viewId: this.viewId, + sendShortcut: () => this.plugin.settings.sendShortcut, + canInterrupt: () => this.state.busy && Boolean(this.state.activeThreadId && this.state.activeTurnId), + renderIfDetached: () => this.render(), + onSubmit: () => void this.submitComposerAction(), + onNewThread: () => void this.startNewThread(), + }); + this.connection = new ConnectionManager(() => this.plugin.settings.codexPath, this.plugin.vaultPath, { + onNotification: (notification) => { + this.controller.handleNotification(notification); + this.scheduleRender(); + }, + onServerRequest: (request) => { + this.controller.handleServerRequest(request); + this.render(); + }, + onLog: (message) => { + this.controller.handleAppServerLog(message); + this.render(); + }, + onExit: () => { + this.setStatus("Codex app-server stopped."); + clearConnectionScopedState(this.state); + this.threadRename.resetThreadTurnPresence(false); + this.client = null; + this.render(); + }, + }); + this.controller = new PanelController(this.state, { + refreshThreads: () => void this.refreshThreads(), + maybeNameThread: (threadId, turn) => this.threadRename.maybeAutoNameThread(threadId, turn), + respondToServerRequest: (requestId, result) => this.respondToServerRequest(requestId, result), + rejectServerRequest: (requestId, code, message) => this.rejectServerRequest(requestId, code, message), + }); + this.session = new PanelSessionController({ + state: this.state, + vaultPath: this.plugin.vaultPath, + currentClient: () => this.connection.currentClient(), + runtimeSnapshot: () => this.runtimeSnapshot(), + setStatus: (status) => this.setStatus(status), + addSystemMessage: (text) => this.addSystemMessage(text), + addDedupedSystemMessage: (text) => this.addDedupedSystemMessage(text), + forceMessagesToBottom: () => this.forceMessagesToBottom(), + }); + this.history = new ThreadHistoryLoader({ + state: this.state, + currentClient: () => this.client, + render: () => this.render(), + addSystemMessage: (text) => this.addSystemMessage(text), + forceMessagesToBottom: () => this.forceMessagesToBottom(), + keepCurrentScrollPosition: () => { + this.forceScrollMessagesToBottomOnNextRender = false; + }, + setThreadTurnPresence: (hadTurns) => this.threadRename.resetThreadTurnPresence(hadTurns), + }); + this.threadRename = new ThreadRenameController({ + state: this.state, + vaultPath: this.plugin.vaultPath, + settings: () => this.plugin.settings, + ensureConnected: () => this.ensureConnected(), + currentClient: () => this.connection.currentClient(), + refreshThreads: () => this.refreshThreads(), + render: () => this.render(), + addSystemMessage: (text) => this.addSystemMessage(text), + }); + } + + getViewType(): string { + return VIEW_TYPE_CODEX_PANEL; + } + + getDisplayText(): string { + return codexPanelDisplayTitle(this.state.activeThreadId, this.state.listedThreads); + } + + getIcon(): string { + return "bot-message-square"; + } + + refreshSettings(): void { + this.render(); + } + + refreshThreadList(): void { + void this.refreshThreads(); + } + + async openThread(threadId: string): Promise { + await this.resumeThread(threadId); + } + + async onOpen(): Promise { + this.composerController.registerNoteIndexInvalidation((eventRef) => this.registerEvent(eventRef)); + this.registerDomEvent(activeDocument, "pointerdown", (event) => this.closeToolbarPanelOnOutsidePointer(event)); + this.render(); + await this.ensureConnected(); + } + + async onClose(): Promise { + if (this.scheduledRenderTimer !== null) { + window.clearTimeout(this.scheduledRenderTimer); + this.scheduledRenderTimer = null; + } + this.connection.disconnect(); + this.client = null; + } + + setComposerText(text: string): void { + this.composerController.setDraft(text, { focus: true, renderIfDetached: true }); + } + + private async ensureConnected(): Promise { + if (this.connection.isConnected()) { + this.client = this.connection.currentClient(); + return; + } + + this.setStatus("Starting Codex app-server..."); + try { + this.state.initializeResponse = await this.connection.connect(); + this.client = this.connection.currentClient(); + if (!this.client) throw new Error("Codex app-server connection did not initialize."); + await this.session.refreshSessionMetadata(); + await this.session.refreshThreadList(); + this.refreshTabHeader(); + this.setStatus("Connected."); + } catch (error) { + if (error instanceof StaleConnectionError) return; + this.setStatus("Connection failed."); + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + new Notice("Codex app-server connection failed."); + } + this.scheduleRender(); + } + + async startNewThread(): Promise { + if (this.state.busy) return; + + await this.ensureConnected(); + if (!this.client) return; + + try { + const response = await this.session.startThread(); + if (!response) return; + this.threadRename.resetThreadTurnPresence(false); + this.state.turnDiffs.clear(); + this.state.displayItems = [this.systemItem(`Started thread ${response.thread.id}`)]; + this.forceMessagesToBottom(); + await this.refreshThreads(); + this.refreshTabHeader(); + this.render(); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async refreshThreads(): Promise { + this.client = this.connection.currentClient(); + if (!this.client) return; + try { + await this.session.refreshThreadList(); + await this.session.refreshSessionMetadata(); + this.refreshTabHeader(); + this.render(); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async resumeThread(threadId: string): Promise { + if (this.state.busy && threadId !== this.state.activeThreadId) { + this.addSystemMessage("Finish or interrupt the current turn before switching threads."); + return; + } + await this.ensureConnected(); + if (!this.client) return; + + try { + const response = await this.client.resumeThread(threadId, this.plugin.vaultPath); + this.state.activeThreadId = response.thread.id; + this.state.activeThreadCwd = response.cwd ?? response.thread.cwd ?? this.plugin.vaultPath; + this.state.activeTurnId = null; + this.state.activeModel = response.model ?? null; + this.state.activeServiceTier = response.serviceTier ?? null; + this.state.activeThreadCliVersion = response.thread.cliVersion ?? null; + this.state.tokenUsage = null; + this.state.displayItems = []; + this.state.turnDiffs.clear(); + this.state.historyCursor = null; + this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread); + this.threadRename.resetThreadTurnPresence(false); + this.refreshTabHeader(); + this.forceMessagesToBottom(); + await this.history.loadLatest(response.thread.id); + if (this.state.displayItems.length === 0) { + this.state.displayItems.push(this.systemItem(`Resumed thread ${response.thread.id}`)); + this.forceMessagesToBottom(); + this.render(); + } + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private refreshTabHeader(): void { + const leaf = this.leaf as WorkspaceLeaf & { + updateHeader?: () => void; + updateDisplay?: () => void; + }; + if (typeof leaf.updateHeader === "function") { + leaf.updateHeader(); + } else if (typeof leaf.updateDisplay === "function") { + leaf.updateDisplay(); + } + } + + private async sendMessage(): Promise { + const text = this.composerController.trimmedDraft; + if (!text) return; + + await this.ensureConnected(); + if (!this.client) return; + + const slashCommand = parseSlashCommand(text); + if (slashCommand) { + this.composerController.setDraft("", { clearSuggestions: true }); + const result = await this.executeSlashCommand(slashCommand.command, slashCommand.args); + if (result?.sendText) { + await this.sendTurnText(result.sendText); + } + this.render(); + return; + } + + await this.sendTurnText(text); + } + + private async sendTurnText(text: string): Promise { + const client = this.client; + if (!client) return; + + if (this.state.busy) { + await this.steerCurrentTurn(text); + return; + } + + let optimisticUserId: string | null = null; + try { + if (!this.state.activeThreadId) { + const threadResponse = await this.session.startThread(); + if (!threadResponse) return; + this.refreshTabHeader(); + this.threadRename.resetThreadTurnPresence(false); + } + + optimisticUserId = `local-user-${Date.now()}`; + this.state.displayItems.push({ + id: optimisticUserId, + kind: "message", + role: "user", + text, + copyText: text, + markdown: true, + }); + this.forceMessagesToBottom(); + this.composerController.setDraft(""); + this.state.busy = true; + this.render(); + + const turnSettings = requestedTurnRuntimeSettings(this.runtimeSnapshot()); + if (turnSettings.warning) { + this.addSystemMessage(`${this.collaborationModeLabel()} mode is selected, but ${turnSettings.warning}`); + } + const codexInput = this.composerController.codexInput(text); + const activeThreadId = this.state.activeThreadId; + if (!activeThreadId) return; + const response = await client.startTurn( + activeThreadId, + this.plugin.vaultPath, + codexInput, + requestedOrConfiguredServiceTier(this.runtimeSnapshot()), + turnSettings.collaborationMode, + turnSettings.model, + turnSettings.effort, + ); + this.state.requestedModel = commitRuntimeOverride(this.state.requestedModel); + this.state.requestedReasoningEffort = commitRuntimeOverride(this.state.requestedReasoningEffort); + this.state.activeTurnId = response.turn.id; + this.state.displayItems = this.state.displayItems.map((item) => + item.id === optimisticUserId ? { ...item, turnId: response.turn.id } : item, + ); + this.setStatus("Turn running..."); + } catch (error) { + this.state.busy = false; + if (optimisticUserId) this.state.displayItems = this.state.displayItems.filter((item) => item.id !== optimisticUserId); + this.composerController.setDraft(text); + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + this.scheduleRender(); + } + + private async steerCurrentTurn(text: string): Promise { + if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) { + this.addSystemMessage("Current turn is not steerable yet."); + return; + } + + const threadId = this.state.activeThreadId; + const expectedTurnId = this.state.activeTurnId; + + this.composerController.setDraft("", { clearSuggestions: true }); + this.syncComposerControls(); + + try { + await this.client.steerTurn(threadId, expectedTurnId, this.composerController.codexInput(text)); + this.state.displayItems.push({ + id: `local-steer-${Date.now()}`, + kind: "message", + role: "user", + text, + copyText: text, + turnId: expectedTurnId, + markdown: true, + }); + this.forceMessagesToBottom(); + this.setStatus("Steered current turn."); + } catch (error) { + this.composerController.setDraft(text, { focus: true }); + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + + this.scheduleRender(); + } + + private async interruptTurn(): Promise { + if (!this.client || !this.state.activeThreadId || !this.state.activeTurnId) return; + try { + await this.client.interruptTurn(this.state.activeThreadId, this.state.activeTurnId); + this.setStatus("Interrupt requested."); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async submitComposerAction(): Promise { + const draft = this.composerController.trimmedDraft; + if (this.state.busy && this.state.activeThreadId && this.state.activeTurnId && draft.length === 0) { + await this.interruptTurn(); + return; + } + await this.sendMessage(); + } + + private async executeSlashCommand(command: SlashCommandName, args: string): Promise { + if (!this.client) return; + return runSlashCommand(command, args, { + activeThreadId: this.state.activeThreadId, + listedThreads: this.state.listedThreads, + startNewThread: () => this.startNewThread(), + resumeThread: (threadId) => this.resumeThread(threadId), + forkThread: (threadId) => this.forkThread(threadId), + rollbackThread: (threadId) => this.rollbackThread(threadId), + compactThread: async (threadId) => { + await this.client?.compactThread(threadId); + }, + busy: this.state.busy, + toggleFastMode: () => this.toggleFastMode(), + toggleCollaborationMode: () => this.toggleCollaborationMode(), + addSystemMessage: (text) => this.addSystemMessage(text), + setStatus: (status) => this.setStatus(status), + setRequestedModel: (model) => this.setRequestedModel(model), + setRequestedReasoningEffort: (effort) => this.setRequestedReasoningEffort(effort), + statusSummaryLines: () => this.statusSummaryLines(), + connectionDiagnosticLines: () => this.connectionDiagnosticLines(), + modelStatusLines: () => this.modelStatusLines(), + effortStatusLines: () => this.effortStatusLines(), + }); + } + + private toggleFastMode(): void { + const current = currentServiceTier(this.runtimeSnapshot(), configRecord(this.state.effectiveConfig)); + const next: ServiceTier = current === "fast" ? "standard" : "fast"; + this.state.requestedServiceTier = next; + this.state.activeServiceTier = next; + this.state.runtimePicker = null; + this.addSystemMessage(next === "fast" ? "Fast mode on for subsequent turns." : "Fast mode off for subsequent turns."); + } + + private toggleCollaborationMode(): void { + const next = nextCollaborationMode(this.state.requestedCollaborationMode); + this.state.requestedCollaborationMode = next; + this.state.runtimePicker = null; + this.addSystemMessage(collaborationModeToggleMessage(next)); + } + + private toggleRuntimePicker(picker: NonNullable): void { + this.state.runtimePicker = this.state.runtimePicker === picker ? null : picker; + if (this.state.runtimePicker !== null) { + this.state.openDetails.delete("history"); + this.state.openDetails.delete("status-panel"); + } + this.render(); + } + + private setRequestedModelFromUi(model: string | null): void { + this.setRequestedModel(model); + this.state.runtimePicker = null; + this.addSystemMessage(modelOverrideMessage(model)); + } + + private setRequestedModel(model: string | null): void { + this.state.requestedModel = model === null ? resetRuntimeOverride() : setRuntimeOverride(model); + } + + private setRequestedReasoningEffortFromUi(effort: ReasoningEffort | null): void { + this.setRequestedReasoningEffort(effort); + this.state.runtimePicker = null; + this.addSystemMessage(reasoningEffortOverrideMessage(effort)); + } + + private setRequestedReasoningEffort(effort: ReasoningEffort | null): void { + this.state.requestedReasoningEffort = effort === null ? resetRuntimeOverride() : setRuntimeOverride(effort); + } + + private async resolveApproval(approval: PendingApproval, action: ApprovalAction): Promise { + this.controller.resolveApproval(approval, action); + this.render(); + } + + private respondToServerRequest(requestId: Parameters[0], result: unknown): boolean { + try { + this.client?.respondToServerRequest(requestId, result); + return Boolean(this.client); + } catch { + return false; + } + } + + private rejectServerRequest(requestId: Parameters[0], code: number, message: string): boolean { + try { + this.client?.rejectServerRequest(requestId, code, message); + return Boolean(this.client); + } catch { + return false; + } + } + + private async resolveUserInput(input: PendingUserInput): Promise { + this.controller.resolveUserInput(input, this.answersForUserInput(input)); + this.render(); + } + + private async cancelUserInput(input: PendingUserInput): Promise { + this.controller.cancelUserInput(input); + this.render(); + } + + private systemItem(text: string): DisplayItem { + return createSystemItem(text); + } + + private addSystemMessage(text: string): void { + this.controller.addSystemMessage(text); + this.render(); + } + + private addDedupedSystemMessage(text: string): void { + this.controller.addDedupedSystemMessage(text); + this.render(); + } + + private setStatus(status: string): void { + this.state.status = status; + } + + private render(): void { + if (this.scheduledRenderTimer !== null) { + window.clearTimeout(this.scheduledRenderTimer); + this.scheduledRenderTimer = null; + } + const root = this.containerEl.children[1] as HTMLElement; + if (!this.toolbarEl || !this.configSlotEl || !this.messagesSlotEl || !this.composerSlotEl) { + this.renderShell(root); + } + if (!this.toolbarEl || !this.configSlotEl || !this.messagesSlotEl || !this.composerSlotEl) { + return; + } + + this.renderToolbarIfNeeded(this.toolbarEl); + + this.configSlotEl.empty(); + + this.renderMessages(this.messagesSlotEl); + this.renderComposer(this.composerSlotEl); + this.syncComposerControls(); + } + + private renderToolbarIfNeeded(toolbar: HTMLElement): void { + const model = this.toolbarViewModel(); + const signature = toolbarSignature(model); + if (this.toolbarSignature === signature) return; + + this.toolbarSignature = signature; + renderToolbar(toolbar, model, { + toggleHistory: () => this.toggleHistoryPanel(), + toggleStatusPanel: () => this.toggleStatusPanel(), + togglePlan: () => this.toggleCollaborationMode(), + toggleFast: () => this.toggleFastMode(), + toggleRuntime: () => this.toggleRuntimePicker("model"), + connect: () => void this.reconnectFromToolbar(), + refreshThreads: () => { + this.state.openDetails.delete("status-panel"); + void this.refreshThreads(); + }, + resumeThread: (threadId) => { + if (this.state.busy && threadId !== this.state.activeThreadId) return; + this.state.openDetails.delete("history"); + void this.resumeThread(threadId); + }, + archiveThread: (threadId) => void this.archiveThread(threadId), + startRenameThread: (threadId) => this.threadRename.start(threadId), + updateRenameDraft: (threadId, value) => this.threadRename.updateDraft(threadId, value), + saveRenameThread: (threadId, value) => void this.threadRename.save(threadId, value), + cancelRenameThread: (threadId) => this.threadRename.cancel(threadId), + autoNameThread: (threadId) => void this.threadRename.autoNameDraft(threadId), + }); + } + + private toolbarViewModel(): ToolbarViewModel { + const snapshot = this.runtimeSnapshot(); + const config = configRecord(this.state.effectiveConfig); + const context = contextSummary(snapshot); + const limit = rateLimitSummary(snapshot); + const historyOpen = this.state.openDetails.has("history"); + const statusPanelOpen = this.state.openDetails.has("status-panel"); + const runtimeOpen = this.state.runtimePicker !== null; + const statusState = this.state.busy ? "running" : this.connection.isConnected() ? "connected" : "offline"; + const model = currentModel(snapshot, config); + const effort = currentReasoningEffort(snapshot, config); + const threads = this.state.listedThreads; + return { + connected: this.connection.isConnected(), + status: this.state.status, + statusState, + historyOpen, + statusPanelOpen, + runtimeOpen, + planActive: this.state.requestedCollaborationMode === "plan", + fastActive: currentServiceTier(snapshot, config) === "fast", + runtimeSummary: runtimeSummaryLabel(model, effort), + runtimeTitle: `Model: ${model ?? "(from default)"}; Effort: ${effort ?? "(from default)"}`, + runtimeAriaLabel: `Runtime: ${model ?? "default"} ${effort ?? "default"}`, + runtimeEmphasized: this.state.requestedModel.kind !== "default" || this.state.requestedReasoningEffort.kind !== "default", + context: context ? { ...context, label: compactContextLabel(context.percent, context.label) } : null, + rateLimit: limit, + configSections: effectiveConfigSections(snapshot, this.plugin.vaultPath), + openPanel: historyOpen ? "history" : runtimeOpen ? "runtime" : statusPanelOpen ? "status" : null, + threads: threads.map((thread) => { + const threadId = thread.id; + return { + title: getThreadTitle(thread), + threadId, + selected: threadId === this.state.activeThreadId, + disabled: this.state.busy && threadId !== this.state.activeThreadId, + canArchive: true, + rename: this.threadRename.editState(threadId), + }; + }), + modelChoices: this.modelToolbarChoices(), + effortChoices: this.effortToolbarChoices(), + connectLabel: this.connection.isConnected() ? "Reconnect" : "Connect", + diagnostics: this.connectionDiagnosticRows(), + }; + } + + private async reconnectFromToolbar(): Promise { + const threadId = this.state.activeThreadId; + this.state.openDetails.delete("status-panel"); + this.connection.reconnect(); + this.client = null; + this.state.busy = false; + this.state.activeTurnId = null; + this.state.approvals = []; + this.state.pendingUserInputs = []; + this.state.userInputDrafts.clear(); + this.setStatus("Reconnecting..."); + this.render(); + + await this.ensureConnected(); + if (!threadId || !this.client) return; + + try { + await this.resumeThread(threadId); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private modelToolbarChoices(): ToolbarChoice[] { + const snapshot = this.runtimeSnapshot(); + const models = sortedAvailableModels(this.state.availableModels); + const choices: ToolbarChoice[] = [ + { + label: "Default", + selected: this.state.requestedModel.kind !== "set", + onClick: () => this.setRequestedModelFromUi(null), + }, + ]; + choices.push( + ...models.slice(0, 12).map((model) => ({ + label: model.model, + selected: currentModel(snapshot) === model.model, + onClick: () => this.setRequestedModelFromUi(model.model), + })), + ); + if (models.length === 0) { + choices.push({ + label: "No model list available.", + disabled: true, + onClick: () => undefined, + }); + } + return choices; + } + + private effortToolbarChoices(): ToolbarChoice[] { + const snapshot = this.runtimeSnapshot(); + return [ + { + label: "Default", + selected: this.state.requestedReasoningEffort.kind !== "set", + onClick: () => this.setRequestedReasoningEffortFromUi(null), + }, + ...supportedReasoningEfforts(snapshot).map((effort) => ({ + label: effort, + selected: currentReasoningEffort(snapshot) === effort, + onClick: () => this.setRequestedReasoningEffortFromUi(effort), + })), + ]; + } + + private toggleHistoryPanel(): void { + if (this.state.openDetails.has("history")) { + this.state.openDetails.delete("history"); + } else { + this.state.openDetails.delete("status-panel"); + this.state.runtimePicker = null; + this.state.openDetails.add("history"); + } + this.scheduleRender(); + } + + private closeToolbarPanelOnOutsidePointer(event: PointerEvent): void { + if (!this.hasOpenToolbarPanel()) return; + + const target = event.target; + if (target instanceof Element) { + const insideToolbarPanel = target.closest(".codex-panel__toolbar-primary, .codex-panel__toolbar-panel"); + if (insideToolbarPanel && this.containerEl.contains(insideToolbarPanel)) return; + } + + this.closeToolbarPanel(); + } + + private hasOpenToolbarPanel(): boolean { + return this.state.openDetails.has("history") || this.state.openDetails.has("status-panel") || this.state.runtimePicker !== null; + } + + private closeToolbarPanel(): void { + if (!this.hasOpenToolbarPanel()) return; + + this.state.openDetails.delete("history"); + this.state.openDetails.delete("status-panel"); + this.state.runtimePicker = null; + this.scheduleRender(); + } + + private scheduleRender(): void { + if (this.scheduledRenderTimer !== null) return; + this.scheduledRenderTimer = window.setTimeout(() => { + this.scheduledRenderTimer = null; + this.render(); + }, 50); + } + + private renderShell(root: HTMLElement): void { + root.empty(); + root.addClass("codex-panel"); + this.toolbarEl = root.createDiv({ cls: "codex-panel__toolbar" }); + const body = root.createDiv({ cls: "codex-panel__body" }); + this.configSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--config" }); + this.messagesSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--messages" }); + this.composerSlotEl = body.createDiv({ cls: "codex-panel__slot codex-panel__slot--composer" }); + } + + private toggleStatusPanel(): void { + if (this.state.openDetails.has("status-panel")) { + this.state.openDetails.delete("status-panel"); + } else { + this.state.openDetails.delete("history"); + this.state.runtimePicker = null; + this.state.openDetails.add("status-panel"); + } + this.scheduleRender(); + } + + private statusSummaryLines(): string[] { + const snapshot = this.runtimeSnapshot(); + const context = contextSummary(snapshot); + const limit = rateLimitSummary(snapshot); + return [ + "Session status", + `Session: ${this.state.activeThreadId ?? "(none)"}`, + context ? context.title : "Context: not available", + ...(limit ? usageLimitStatusLines(limit) : ["Usage limits: not available"]), + ]; + } + + private modelStatusLines(): string[] { + const snapshot = this.runtimeSnapshot(); + const config = configRecord(this.state.effectiveConfig); + return [ + `Model: ${currentModel(snapshot, config) ?? "(from default)"}`, + `Override: ${runtimeOverrideLabel(this.state.requestedModel)}`, + `Provider: ${statusValue(config.model_provider, "(from default)")}`, + `Effort: ${currentReasoningEffort(snapshot, config) ?? "(from default)"}`, + `Mode: ${this.collaborationModeLabel()}`, + `Service tier: ${serviceTierLabel(snapshot, config)}`, + ]; + } + + private effortStatusLines(): string[] { + const snapshot = this.runtimeSnapshot(); + const config = configRecord(this.state.effectiveConfig); + return [ + `Effort: ${currentReasoningEffort(snapshot, config) ?? "(from default)"}`, + `Override: ${runtimeOverrideLabel(this.state.requestedReasoningEffort)}`, + `Supported: ${supportedReasoningEfforts(snapshot).join(", ")}`, + ]; + } + + private connectionDiagnosticRows() { + return connectionDiagnosticRows({ + connected: this.connection.isConnected(), + configuredCommand: this.plugin.settings.codexPath, + initializeResponse: this.state.initializeResponse, + activeThreadCliVersion: this.state.activeThreadCliVersion, + compatibility: this.state.appServerCompatibility, + }); + } + + private connectionDiagnosticLines(): string[] { + return connectionDiagnosticLines(this.connectionDiagnosticRows()); + } + + private collaborationModeLabel(): string { + return formatCollaborationModeLabel(this.state.requestedCollaborationMode); + } + + private runtimeSnapshot(): RuntimeSnapshot { + return { + effectiveConfig: this.state.effectiveConfig, + activeThreadId: this.state.activeThreadId, + activeModel: this.state.activeModel, + activeServiceTier: this.state.activeServiceTier, + requestedModel: this.state.requestedModel, + requestedReasoningEffort: this.state.requestedReasoningEffort, + requestedCollaborationMode: this.state.requestedCollaborationMode, + requestedServiceTier: this.state.requestedServiceTier, + tokenUsage: this.state.tokenUsage, + rateLimit: this.state.rateLimit, + displayItems: this.state.displayItems, + availableModels: this.state.availableModels, + }; + } + + private renderPendingRequestMessage(parent: HTMLElement): void { + renderPendingRequestMessage( + parent, + this.state.approvals, + this.state.pendingUserInputs, + { + values: this.state.userInputDrafts, + draftKey: userInputDraftKey, + otherDraftKey: userInputOtherDraftKey, + }, + this.state.openDetails, + { + resolveApproval: (approval, action) => void this.resolveApproval(approval, action), + resolveUserInput: (input) => void this.resolveUserInput(input), + cancelUserInput: (input) => void this.cancelUserInput(input), + }, + ); + } + + private answersForUserInput(input: PendingUserInput): Record { + return Object.fromEntries( + input.params.questions.map((question) => [ + question.id, + this.state.userInputDrafts.get(userInputDraftKey(input.requestId, question.id)) ?? questionDefaultAnswer(question), + ]), + ); + } + + private async archiveThread(threadId: string): Promise { + if (this.state.busy) { + this.addSystemMessage("Finish or interrupt the current turn before archiving threads."); + return; + } + if (!this.client) return; + try { + await this.client.archiveThread(threadId); + if (this.state.activeThreadId === threadId) { + clearActiveThreadState(this.state); + this.threadRename.resetThreadTurnPresence(false); + } + await this.refreshThreads(); + this.render(); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async forkThread(threadId: string): Promise { + if (this.state.busy) { + this.addSystemMessage("Finish or interrupt the current turn before forking threads."); + return; + } + await this.ensureConnected(); + if (!this.client) return; + + try { + const sourceName = inheritedForkThreadName(threadId, this.state.listedThreads); + const response = await this.client.forkThread(threadId, this.plugin.vaultPath); + const forkedThreadId = response.thread.id; + if (sourceName) { + try { + await this.client.setThreadName(forkedThreadId, sourceName); + this.plugin.refreshOpenThreadLists(); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not copy the source thread name: ${message}`); + } + } + try { + await this.plugin.openThreadInNewView(forkedThreadId); + } catch (error) { + const message = error instanceof Error ? error.message : String(error); + this.addSystemMessage(`Forked thread ${forkedThreadId}, but could not open it in a new panel: ${message}`); + } + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + } + } + + private async rollbackThread(threadId: string): Promise { + if (this.state.busy) { + this.addSystemMessage("Interrupt the current turn before rolling back."); + return; + } + await this.ensureConnected(); + if (!this.client) return; + + const candidate = rollbackCandidateFromItems(this.state.displayItems); + if (!candidate) { + this.addSystemMessage("No completed turn to roll back."); + return; + } + + try { + this.setStatus("Rolling back latest turn..."); + const response = await this.client.rollbackThread(threadId); + this.state.activeThreadId = response.thread.id; + this.state.activeThreadCwd = response.thread.cwd ?? this.state.activeThreadCwd; + this.state.activeTurnId = null; + this.state.tokenUsage = null; + this.state.historyCursor = null; + this.state.turnDiffs.clear(); + this.state.listedThreads = upsertThread(this.state.listedThreads, response.thread); + await this.history.loadLatest(response.thread.id); + this.setComposerText(candidate.text); + this.addSystemMessage("Rolled back the latest turn. Local file changes were not reverted."); + this.setStatus("Rolled back latest turn."); + this.refreshTabHeader(); + await this.refreshThreads(); + } catch (error) { + this.addSystemMessage(error instanceof Error ? error.message : String(error)); + this.setStatus("Rollback failed."); + } + } + + private forceMessagesToBottom(): void { + this.state.messagesPinnedToBottom = true; + this.forceScrollMessagesToBottomOnNextRender = true; + } + + private renderMessages(parent: HTMLElement): void { + this.messageRenderer.render(parent); + } + + private pendingRequestsSignature(): string { + return requestStateSignature(this.state.approvals, this.state.pendingUserInputs, this.state.userInputDrafts); + } + + private createPendingRequestsElement(): HTMLElement | null { + if (this.state.approvals.length === 0 && this.state.pendingUserInputs.length === 0) return null; + const container = createDiv(); + this.renderPendingRequestMessage(container); + return container.firstElementChild as HTMLElement | null; + } + + private renderComposer(parent: HTMLElement): void { + this.composerController.render(parent); + } + + private syncComposerControls(): void { + this.composerController.syncControls(this.composerSlotEl); + } +} diff --git a/src/panel/collaboration-mode.ts b/src/runtime/collaboration-mode.ts similarity index 100% rename from src/panel/collaboration-mode.ts rename to src/runtime/collaboration-mode.ts diff --git a/src/panel/model-runtime.ts b/src/runtime/model.ts similarity index 100% rename from src/panel/model-runtime.ts rename to src/runtime/model.ts diff --git a/src/panel/runtime-settings.ts b/src/runtime/settings.ts similarity index 96% rename from src/panel/runtime-settings.ts rename to src/runtime/settings.ts index 8994a8d1..4dea51b8 100644 --- a/src/panel/runtime-settings.ts +++ b/src/runtime/settings.ts @@ -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"]); diff --git a/src/panel/runtime-state.ts b/src/runtime/state.ts similarity index 98% rename from src/panel/runtime-state.ts rename to src/runtime/state.ts index dcf8fa83..aa109eaf 100644 --- a/src/panel/runtime-state.ts +++ b/src/runtime/state.ts @@ -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 = { kind: "default" } | { kind: "set"; value: T } | { kind: "resetPending" }; diff --git a/src/panel/runtime-view.ts b/src/runtime/view.ts similarity index 99% rename from src/panel/runtime-view.ts rename to src/runtime/view.ts index ad309cf2..dcf50174 100644 --- a/src/panel/runtime-view.ts +++ b/src/runtime/view.ts @@ -9,7 +9,7 @@ import { runtimeOverrideLabel, serviceTierLabel, type RuntimeSnapshot, -} from "./runtime-state"; +} from "./state"; export interface ContextSummary { label: string; diff --git a/src/settings-data.ts b/src/settings/data.ts similarity index 90% rename from src/settings-data.ts rename to src/settings/data.ts index 7409dee2..c8c27349 100644 --- a/src/settings-data.ts +++ b/src/settings/data.ts @@ -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[]; diff --git a/src/settings/dynamic-sections.ts b/src/settings/dynamic-sections.ts new file mode 100644 index 00000000..abcee617 --- /dev/null +++ b/src/settings/dynamic-sections.ts @@ -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", + }); +} diff --git a/src/settings.ts b/src/settings/model.ts similarity index 92% rename from src/settings.ts rename to src/settings/model.ts index 8b7427e1..a0cbbe08 100644 --- a/src/settings.ts +++ b/src/settings/model.ts @@ -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; diff --git a/src/settings-tab.ts b/src/settings/tab.ts similarity index 63% rename from src/settings-tab.ts rename to src/settings/tab.ts index 856be1e4..8f1e1fed 100644 --- a/src/settings-tab.ts +++ b/src/settings/tab.ts @@ -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", - }); } diff --git a/src/threads.ts b/src/threads/model.ts similarity index 94% rename from src/threads.ts rename to src/threads/model.ts index e18365f2..dc47ac37 100644 --- a/src/threads.ts +++ b/src/threads/model.ts @@ -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; diff --git a/src/view/clipboard.ts b/src/ui/clipboard.ts similarity index 100% rename from src/view/clipboard.ts rename to src/ui/clipboard.ts diff --git a/src/view/components.ts b/src/ui/components.ts similarity index 100% rename from src/view/components.ts rename to src/ui/components.ts diff --git a/src/view/composer.ts b/src/ui/composer.ts similarity index 100% rename from src/view/composer.ts rename to src/ui/composer.ts diff --git a/src/view/config.ts b/src/ui/config.ts similarity index 90% rename from src/view/config.ts rename to src/ui/config.ts index c810a07a..a8a13b68 100644 --- a/src/view/config.ts +++ b/src/ui/config.ts @@ -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 { diff --git a/src/view/dom.ts b/src/ui/dom.ts similarity index 100% rename from src/view/dom.ts rename to src/ui/dom.ts diff --git a/src/view/execution-state.ts b/src/ui/execution-state.ts similarity index 100% rename from src/view/execution-state.ts rename to src/ui/execution-state.ts diff --git a/src/view/message-stream.ts b/src/ui/message-stream.ts similarity index 99% rename from src/view/message-stream.ts rename to src/ui/message-stream.ts index 62a602be..2d5dcc1e 100644 --- a/src/view/message-stream.ts +++ b/src/ui/message-stream.ts @@ -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"; diff --git a/src/view/pending-request-message.ts b/src/ui/pending-request-message.ts similarity index 100% rename from src/view/pending-request-message.ts rename to src/ui/pending-request-message.ts diff --git a/src/view/scroll.ts b/src/ui/scroll.ts similarity index 100% rename from src/view/scroll.ts rename to src/ui/scroll.ts diff --git a/src/view/tool-result.ts b/src/ui/tool-result.ts similarity index 100% rename from src/view/tool-result.ts rename to src/ui/tool-result.ts diff --git a/src/view/toolbar.ts b/src/ui/toolbar.ts similarity index 99% rename from src/view/toolbar.ts rename to src/ui/toolbar.ts index 05acc5b4..0f627a86 100644 --- a/src/view/toolbar.ts +++ b/src/ui/toolbar.ts @@ -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"; diff --git a/src/view/turn-diff.ts b/src/ui/turn-diff.ts similarity index 100% rename from src/view/turn-diff.ts rename to src/ui/turn-diff.ts diff --git a/src/view/work-items.ts b/src/ui/work-items.ts similarity index 100% rename from src/view/work-items.ts rename to src/ui/work-items.ts diff --git a/src/view/work-message.ts b/src/ui/work-message.ts similarity index 100% rename from src/view/work-message.ts rename to src/ui/work-message.ts diff --git a/tests/collaboration-mode.test.ts b/tests/collaboration-mode.test.ts index f9d544d4..bd8ea454 100644 --- a/tests/collaboration-mode.test.ts +++ b/tests/collaboration-mode.test.ts @@ -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", () => { diff --git a/tests/panel-controller.test.ts b/tests/panel-controller.test.ts index fb387860..ffd10fd8 100644 --- a/tests/panel-controller.test.ts +++ b/tests/panel-controller.test.ts @@ -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"; diff --git a/tests/runtime-settings.test.ts b/tests/runtime-settings.test.ts index 783e0c03..9740db9d 100644 --- a/tests/runtime-settings.test.ts +++ b/tests/runtime-settings.test.ts @@ -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", () => { diff --git a/tests/settings-tab.test.ts b/tests/settings-tab.test.ts index b2764680..bbd5831d 100644 --- a/tests/settings-tab.test.ts +++ b/tests/settings-tab.test.ts @@ -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); diff --git a/tests/settings.test.ts b/tests/settings.test.ts index ec283412..f4966c56 100644 --- a/tests/settings.test.ts +++ b/tests/settings.test.ts @@ -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", () => { diff --git a/tests/status-lines.test.ts b/tests/status-lines.test.ts index 0f85de0f..ccdc80ba 100644 --- a/tests/status-lines.test.ts +++ b/tests/status-lines.test.ts @@ -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", () => { diff --git a/tests/threads.test.ts b/tests/threads.test.ts index 7fb4a243..53aa4601 100644 --- a/tests/threads.test.ts +++ b/tests/threads.test.ts @@ -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", () => { diff --git a/tests/view-dom.test.ts b/tests/view-dom.test.ts index 3b4ba647..e3b34c38 100644 --- a/tests/view-dom.test.ts +++ b/tests/view-dom.test.ts @@ -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 { diff --git a/tests/view-renderers.test.ts b/tests/view-renderers.test.ts index 635c08fa..37ce2811 100644 --- a/tests/view-renderers.test.ts +++ b/tests/view-renderers.test.ts @@ -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 }): 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([]); }); diff --git a/tests/view-scroll.test.ts b/tests/view-scroll.test.ts index 81ded9d2..241872e0 100644 --- a/tests/view-scroll.test.ts +++ b/tests/view-scroll.test.ts @@ -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", () => {