From 31281ae1375f443aaff44b92d2acb11988a5c30b Mon Sep 17 00:00:00 2001 From: murashit Date: Sun, 17 May 2026 16:47:13 +0900 Subject: [PATCH] Split display item routing --- src/display/plan.ts | 23 ++ src/display/signature.ts | 2 +- src/display/system.ts | 10 + src/display/{model.ts => thread-items.ts} | 475 +++++++++++----------- src/display/tool-view.ts | 2 +- src/panel/controller.ts | 8 +- src/panel/slash-commands.ts | 1 - src/panel/thread-history.ts | 2 +- src/panel/view.ts | 7 +- src/runtime/state.ts | 1 - src/ui/message-stream.ts | 2 +- src/ui/work-items.ts | 3 +- tests/display-model.test.ts | 83 +++- tests/slash-commands.test.ts | 3 +- 14 files changed, 353 insertions(+), 269 deletions(-) create mode 100644 src/display/system.ts rename src/display/{model.ts => thread-items.ts} (58%) diff --git a/src/display/plan.ts b/src/display/plan.ts index bb494869..4b237f4c 100644 --- a/src/display/plan.ts +++ b/src/display/plan.ts @@ -1,6 +1,29 @@ +import type { DisplayItem } from "./types"; +import type { TurnPlanStep } from "../generated/app-server/v2/TurnPlanStep"; +import { taskStatusMarker } from "./labels"; +import { classifyExecutionState } from "./state"; + export function normalizeProposedPlanMarkdown(text: string): string { return text .replace(/^\s*\s*\n?/i, "") .replace(/\n?\s*<\/proposed_plan>\s*$/i, "") .trim(); } + +export function planProgressDisplayItem(turnId: string, explanation: string | null, plan: TurnPlanStep[]): DisplayItem { + const lines = plan.map((step) => `${taskStatusMarker(step.status)} ${step.step}`); + const body = [explanation?.trim(), ...lines].filter((line): line is string => Boolean(line && line.length > 0)).join("\n"); + const status = plan.some((step) => step.status === "inProgress" || step.status === "pending") ? "inProgress" : "completed"; + return { + id: `plan-progress-${turnId}`, + kind: "taskProgress", + role: "tool", + text: body || "Plan updated", + turnId, + itemId: `plan-progress-${turnId}`, + explanation: explanation?.trim() || null, + steps: plan.map((step) => ({ step: step.step, status: step.status })), + status, + state: classifyExecutionState({ status }), + }; +} diff --git a/src/display/signature.ts b/src/display/signature.ts index 9ddf7cfe..7a38a3e5 100644 --- a/src/display/signature.ts +++ b/src/display/signature.ts @@ -1,4 +1,4 @@ -import { executionState } from "./model"; +import { executionState } from "./state"; import type { DisplayItem } from "./types"; export interface DisplayItemSignatureContext { diff --git a/src/display/system.ts b/src/display/system.ts new file mode 100644 index 00000000..a6b50b2f --- /dev/null +++ b/src/display/system.ts @@ -0,0 +1,10 @@ +import type { DisplayItem } from "./types"; + +export function createSystemItem(text: string): DisplayItem { + return { + id: `system-${Date.now()}-${Math.random().toString(36).slice(2)}`, + kind: "system", + role: "system", + text, + }; +} diff --git a/src/display/model.ts b/src/display/thread-items.ts similarity index 58% rename from src/display/model.ts rename to src/display/thread-items.ts index 916af0f8..aa663224 100644 --- a/src/display/model.ts +++ b/src/display/thread-items.ts @@ -1,9 +1,7 @@ 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"; import { inputToText, truncate } from "../utils"; -import { taskStatusMarker } from "./labels"; import { agentDisplayItem } from "./agent"; import { pathRelativeToRoot } from "./paths"; import { normalizeProposedPlanMarkdown } from "./plan"; @@ -17,20 +15,22 @@ import { metaDetail, 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"; + +type UserMessageItem = Extract; +type AgentMessageItem = Extract; +type PlanItem = Extract; +type HookPromptItem = Extract; +type ReasoningItem = Extract; +type CommandExecutionItem = Extract; +type CommandAction = CommandExecutionItem["commandActions"][number]; +type FileChangeItem = Extract; +type McpToolCallItem = Extract; +type DynamicToolCallItem = Extract; +type WebSearchItem = Extract; +type ImageViewItem = Extract; +type ImageGenerationItem = Extract; +type ReviewModeItem = Extract | Extract; +type ContextCompactionItem = Extract; export function displayItemsFromTurns(turns: Turn[]): DisplayItem[] { const sortedTurns = [...turns].sort((a, b) => (a.startedAt ?? 0) - (b.startedAt ?? 0)); @@ -47,209 +47,225 @@ export function displayItemsFromTurns(turns: Turn[]): DisplayItem[] { export function displayItemFromThreadItem(item: ThreadItem, turnId?: string): DisplayItem | null { if (shouldSuppressThreadItem(item)) return null; - if (item.type === "userMessage") { - const text = inputToText(item.content); - return { - id: item.id, - kind: "message", - role: "user", - text, - copyText: text, - turnId, - itemId: item.id, - markdown: true, - }; + switch (item.type) { + case "userMessage": + return userMessageDisplayItem(item, turnId); + case "agentMessage": + return agentMessageDisplayItem(item, turnId); + case "commandExecution": + return commandDisplayItem(item, turnId); + case "fileChange": + return fileChangeDisplayItem(item, turnId); + case "plan": + return planDisplayItem(item, turnId); + case "hookPrompt": + return hookPromptDisplayItem(item, turnId); + case "reasoning": + return reasoningDisplayItem(item, turnId); + case "mcpToolCall": + return mcpToolCallDisplayItem(item, turnId); + case "dynamicToolCall": + return dynamicToolCallDisplayItem(item, turnId); + case "collabAgentToolCall": + return agentDisplayItem(item, turnId); + case "webSearch": + return webSearchDisplayItem(item, turnId); + case "imageView": + return imageViewDisplayItem(item, turnId); + case "imageGeneration": + return imageGenerationDisplayItem(item, turnId); + case "enteredReviewMode": + case "exitedReviewMode": + return reviewModeDisplayItem(item, turnId); + case "contextCompaction": + return contextCompactionDisplayItem(item, turnId); + default: + return assertNever(item); } - - if (item.type === "agentMessage") { - return { - id: item.id, - kind: "message", - role: "assistant", - text: item.text, - copyText: item.text, - turnId, - itemId: item.id, - markdown: true, - }; - } - - if (item.type === "commandExecution") { - return commandDisplayItem(item, turnId); - } - - if (item.type === "fileChange") { - return fileChangeDisplayItem(item, turnId); - } - - if (item.type === "plan") { - const text = normalizeProposedPlanMarkdown(item.text); - return { - id: item.id, - kind: "message", - role: "assistant", - text, - copyText: text, - turnId, - itemId: item.id, - markdown: true, - proposedPlan: true, - }; - } - - if (item.type === "hookPrompt") { - return { - id: item.id, - kind: "hook", - role: "tool", - text: item.fragments.map((fragment) => fragment.text).join("\n\n") || "Hook prompt", - turnId, - itemId: item.id, - }; - } - - if (item.type === "reasoning") { - return { - id: item.id, - kind: "reasoning", - role: "tool", - text: reasoningText(item), - turnId, - itemId: item.id, - }; - } - - if (item.type === "mcpToolCall") { - const name = `${item.server}.${item.tool}`; - const target = jsonTargetLabel(item.arguments); - const failure = item.error?.message ? truncate(item.error.message, 96) : failedStatusLabel(item.status); - return { - id: item.id, - kind: "tool", - role: "tool", - text: compactToolSummary(null, target, statusQualifier(item.status, failure)), - toolLabel: name, - turnId, - itemId: item.id, - status: item.status, - details: jsonDetails([ - ["Arguments JSON", item.arguments], - ["Result JSON", item.result], - ["Error JSON", item.error], - ]), - output: "", - state: classifyExecutionState({ status: item.status }), - }; - } - - if (item.type === "dynamicToolCall") { - const name = `${item.namespace ? `${item.namespace}.` : ""}${item.tool}`; - const target = jsonTargetLabel(item.arguments); - const failure = item.success === false ? "failed" : failedStatusLabel(item.status); - return { - id: item.id, - kind: "tool", - role: "tool", - text: compactToolSummary(null, target, statusQualifier(item.status, failure)), - toolLabel: name, - turnId, - itemId: item.id, - status: item.status, - details: jsonDetails([ - ["Arguments JSON", item.arguments], - ["Result JSON", item.contentItems], - ]), - output: "", - state: item.success === false ? "failed" : classifyExecutionState({ status: item.status }), - }; - } - - if (item.type === "collabAgentToolCall") { - return agentDisplayItem(item, turnId); - } - - if (item.type === "webSearch") { - return { - id: item.id, - kind: "tool", - role: "tool", - text: webSearchSummary(item), - toolLabel: "web search", - turnId, - itemId: item.id, - details: webSearchDetails(item), - output: "", - }; - } - - if (item.type === "imageView") { - return { - id: item.id, - kind: "tool", - role: "tool", - text: compactToolSummary(null, item.path), - toolLabel: "imageView", - summaryPath: true, - turnId, - itemId: item.id, - }; - } - - if (item.type === "imageGeneration") { - const target = item.savedPath ?? item.result ?? item.revisedPrompt ?? null; - return { - id: item.id, - kind: "tool", - role: "tool", - text: compactToolSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))), - toolLabel: "imageGeneration", - summaryPath: Boolean(item.savedPath), - turnId, - itemId: item.id, - status: item.status, - details: [ - ...bodyDetail("Saved path", item.savedPath), - ...bodyDetail("Revised prompt", item.revisedPrompt), - ...bodyDetail("Result", item.result), - ], - output: "", - state: classifyExecutionState({ status: item.status }), - }; - } - - if (item.type === "enteredReviewMode" || item.type === "exitedReviewMode") { - return { - id: item.id, - kind: "tool", - role: "tool", - text: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode", - toolLabel: item.type, - turnId, - itemId: item.id, - output: item.review, - }; - } - - if (item.type === "contextCompaction") { - return { - id: item.id, - kind: "tool", - role: "tool", - text: "Context compaction", - toolLabel: "contextCompaction", - turnId, - itemId: item.id, - }; - } - - return null; } -type CommandExecutionItem = Extract; -type CommandAction = CommandExecutionItem["commandActions"][number]; -type FileChangeItem = Extract; -type ReasoningItem = Extract; -type WebSearchItem = Extract; +function userMessageDisplayItem(item: UserMessageItem, turnId?: string): DisplayItem { + const text = inputToText(item.content); + return { + id: item.id, + kind: "message", + role: "user", + text, + copyText: text, + turnId, + itemId: item.id, + markdown: true, + }; +} + +function agentMessageDisplayItem(item: AgentMessageItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "message", + role: "assistant", + text: item.text, + copyText: item.text, + turnId, + itemId: item.id, + markdown: true, + }; +} + +function planDisplayItem(item: PlanItem, turnId?: string): DisplayItem { + const text = normalizeProposedPlanMarkdown(item.text); + return { + id: item.id, + kind: "message", + role: "assistant", + text, + copyText: text, + turnId, + itemId: item.id, + markdown: true, + proposedPlan: true, + }; +} + +function hookPromptDisplayItem(item: HookPromptItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "hook", + role: "tool", + text: item.fragments.map((fragment) => fragment.text).join("\n\n") || "Hook prompt", + turnId, + itemId: item.id, + }; +} + +function reasoningDisplayItem(item: ReasoningItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "reasoning", + role: "tool", + text: reasoningText(item), + turnId, + itemId: item.id, + }; +} + +function mcpToolCallDisplayItem(item: McpToolCallItem, turnId?: string): DisplayItem { + const name = `${item.server}.${item.tool}`; + const target = jsonTargetLabel(item.arguments); + const failure = item.error?.message ? truncate(item.error.message, 96) : failedStatusLabel(item.status); + return { + id: item.id, + kind: "tool", + role: "tool", + text: compactToolSummary(null, target, statusQualifier(item.status, failure)), + toolLabel: name, + turnId, + itemId: item.id, + status: item.status, + details: jsonDetails([ + ["Arguments JSON", item.arguments], + ["Result JSON", item.result], + ["Error JSON", item.error], + ]), + output: "", + state: classifyExecutionState({ status: item.status }), + }; +} + +function dynamicToolCallDisplayItem(item: DynamicToolCallItem, turnId?: string): DisplayItem { + const name = `${item.namespace ? `${item.namespace}.` : ""}${item.tool}`; + const target = jsonTargetLabel(item.arguments); + const failure = item.success === false ? "failed" : failedStatusLabel(item.status); + return { + id: item.id, + kind: "tool", + role: "tool", + text: compactToolSummary(null, target, statusQualifier(item.status, failure)), + toolLabel: name, + turnId, + itemId: item.id, + status: item.status, + details: jsonDetails([ + ["Arguments JSON", item.arguments], + ["Result JSON", item.contentItems], + ]), + output: "", + state: item.success === false ? "failed" : classifyExecutionState({ status: item.status }), + }; +} + +function webSearchDisplayItem(item: WebSearchItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "tool", + role: "tool", + text: webSearchSummary(item), + toolLabel: "web search", + turnId, + itemId: item.id, + details: webSearchDetails(item), + output: "", + }; +} + +function imageViewDisplayItem(item: ImageViewItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "tool", + role: "tool", + text: compactToolSummary(null, item.path), + toolLabel: "imageView", + summaryPath: true, + turnId, + itemId: item.id, + }; +} + +function imageGenerationDisplayItem(item: ImageGenerationItem, turnId?: string): DisplayItem { + const target = item.savedPath ?? item.result ?? item.revisedPrompt ?? null; + return { + id: item.id, + kind: "tool", + role: "tool", + text: compactToolSummary(null, target, statusQualifier(item.status, failedStatusLabel(item.status))), + toolLabel: "imageGeneration", + summaryPath: Boolean(item.savedPath), + turnId, + itemId: item.id, + status: item.status, + details: [ + ...bodyDetail("Saved path", item.savedPath), + ...bodyDetail("Revised prompt", item.revisedPrompt), + ...bodyDetail("Result", item.result), + ], + output: "", + state: classifyExecutionState({ status: item.status }), + }; +} + +function reviewModeDisplayItem(item: ReviewModeItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "tool", + role: "tool", + text: item.type === "enteredReviewMode" ? "Entered review mode" : "Exited review mode", + toolLabel: item.type, + turnId, + itemId: item.id, + output: item.review, + }; +} + +function contextCompactionDisplayItem(item: ContextCompactionItem, turnId?: string): DisplayItem { + return { + id: item.id, + kind: "tool", + role: "tool", + text: "Context compaction", + toolLabel: "contextCompaction", + turnId, + itemId: item.id, + }; +} function reasoningText(item: ReasoningItem): string { return [...item.summary, ...item.content] @@ -430,24 +446,6 @@ export function fileChangeDisplayItem(item: FileChangeItem, turnId?: string): Di }; } -export function planProgressDisplayItem(turnId: string, explanation: string | null, plan: TurnPlanStep[]): DisplayItem { - const lines = plan.map((step) => `${taskStatusMarker(step.status)} ${step.step}`); - const body = [explanation?.trim(), ...lines].filter((line): line is string => Boolean(line && line.length > 0)).join("\n"); - const status = plan.some((step) => step.status === "inProgress" || step.status === "pending") ? "inProgress" : "completed"; - return { - id: `plan-progress-${turnId}`, - kind: "taskProgress", - role: "tool", - text: body || "Plan updated", - turnId, - itemId: `plan-progress-${turnId}`, - explanation: explanation?.trim() || null, - steps: plan.map((step) => ({ step: step.step, status: step.status })), - status, - state: classifyExecutionState({ status }), - }; -} - export function normalizeFileChanges(changes: unknown[]): DisplayFileChange[] { return changes.flatMap((change) => { if (!change || typeof change !== "object") return []; @@ -474,11 +472,6 @@ export function pathRelativeToWorkspace(path: string, workspaceRoot?: string | n return pathRelativeToRoot(path, workspaceRoot); } -export function createSystemItem(text: string): DisplayItem { - return { - id: `system-${Date.now()}-${Math.random().toString(36).slice(2)}`, - kind: "system", - role: "system", - text, - }; +function assertNever(_item: never): null { + return null; } diff --git a/src/display/tool-view.ts b/src/display/tool-view.ts index 7cc81675..e9843581 100644 --- a/src/display/tool-view.ts +++ b/src/display/tool-view.ts @@ -1,5 +1,5 @@ -import { executionState } from "./model"; import { pathRelativeToRoot } from "./paths"; +import { executionState } from "./state"; import type { ApprovalResultDisplayItem, CommandDisplayItem, diff --git a/src/panel/controller.ts b/src/panel/controller.ts index b9413370..021d9c92 100644 --- a/src/panel/controller.ts +++ b/src/panel/controller.ts @@ -1,5 +1,6 @@ import { approvalResponse, type ApprovalAction, type PendingApproval } from "../approvals/model"; -import { createAutoReviewResultItem, createReviewResultItem } from "../display/model"; +import { planProgressDisplayItem } from "../display/plan"; +import { createAutoReviewResultItem, createReviewResultItem } from "../display/review"; import { appendAssistantDelta, appendItemOutput, @@ -9,15 +10,14 @@ import { completeReasoningItems, upsertDisplayItem, } from "../display/stream-updates"; +import { createSystemItem } from "../display/system"; import { - createSystemItem, displayItemFromThreadItem, displayItemsFromTurns, normalizeFileChanges, - planProgressDisplayItem, shouldSuppressLifecycleItem, shouldSuppressThreadItem, -} from "../display/model"; +} from "../display/thread-items"; import type { DisplayItem, DisplayKind, MessageDisplayItem } from "../display/types"; import type { RequestId } from "../generated/app-server/RequestId"; import type { ServerNotification } from "../generated/app-server/ServerNotification"; diff --git a/src/panel/slash-commands.ts b/src/panel/slash-commands.ts index 18a98c41..cd7d5acc 100644 --- a/src/panel/slash-commands.ts +++ b/src/panel/slash-commands.ts @@ -8,7 +8,6 @@ import { parseReasoningEffortOverride, reasoningEffortOverrideMessage, } from "../runtime/settings"; -export { slashCommandHelpLines, type SlashCommandName } from "../composer/slash-commands"; export interface SlashCommandExecutionContext { activeThreadId: string | null; diff --git a/src/panel/thread-history.ts b/src/panel/thread-history.ts index 3eb85bd8..adaef7ab 100644 --- a/src/panel/thread-history.ts +++ b/src/panel/thread-history.ts @@ -1,5 +1,5 @@ import type { AppServerClient } from "../app-server/client"; -import { displayItemsFromTurns } from "../display/model"; +import { displayItemsFromTurns } from "../display/thread-items"; import type { PanelState } from "./state"; export interface ThreadHistoryLoaderHost { diff --git a/src/panel/view.ts b/src/panel/view.ts index 8b6d1cdb..168c8e3c 100644 --- a/src/panel/view.ts +++ b/src/panel/view.ts @@ -4,9 +4,10 @@ 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 type { SlashCommandName } from "../composer/slash-commands"; import { parseSlashCommand } from "../composer/suggestions"; import { VIEW_TYPE_CODEX_PANEL } from "../constants"; -import { createSystemItem } from "../display/model"; +import { createSystemItem } from "../display/system"; import type { DisplayItem } from "../display/types"; import type { ReasoningEffort } from "../generated/app-server/ReasoningEffort"; import { @@ -31,12 +32,12 @@ import { runtimeSummaryLabel, serviceTierLabel, setRuntimeOverride, - sortedAvailableModels, supportedReasoningEfforts, type RuntimeSnapshot, } from "../runtime/state"; +import { sortedAvailableModels } from "../runtime/model"; import { compactContextLabel, modelOverrideMessage, reasoningEffortOverrideMessage } from "../runtime/settings"; -import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult, type SlashCommandName } from "./slash-commands"; +import { executeSlashCommand as runSlashCommand, type SlashCommandExecutionResult } from "./slash-commands"; import { PanelSessionController } from "./session-controller"; import { statusValue, usageLimitStatusLines } from "./status-lines"; import { ThreadHistoryLoader } from "./thread-history"; diff --git a/src/runtime/state.ts b/src/runtime/state.ts index aa109eaf..cac0350d 100644 --- a/src/runtime/state.ts +++ b/src/runtime/state.ts @@ -10,7 +10,6 @@ import { parseServiceTier, serviceTierRequestValue, type ServiceTier, type Servi import { defaultCollaborationMode, planCollaborationMode } from "./collaboration-mode"; 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/ui/message-stream.ts b/src/ui/message-stream.ts index 61059efa..0131a3bc 100644 --- a/src/ui/message-stream.ts +++ b/src/ui/message-stream.ts @@ -1,6 +1,6 @@ import { displayBlocksForItems } from "../display/blocks"; -import { executionState } from "../display/model"; import { displayItemSignature } from "../display/signature"; +import { executionState } from "../display/state"; import type { DisplayBlock, DisplayDetailSection, DisplayItem } from "../display/types"; import { createIconButton, createMetaPair, createRememberedDetails } from "./components"; import { shortSignature } from "./dom"; diff --git a/src/ui/work-items.ts b/src/ui/work-items.ts index 9abdb334..8bfe3394 100644 --- a/src/ui/work-items.ts +++ b/src/ui/work-items.ts @@ -1,5 +1,6 @@ -import { activeAgentRunSummary, executionState } from "../display/model"; +import { activeAgentRunSummary } from "../display/agent"; import { isReasoningActive } from "../display/signature"; +import { executionState } from "../display/state"; import type { AgentDisplayItem, AgentRunSummary, AgentRunSummaryAgent, DisplayItem, TaskProgressDisplayItem } from "../display/types"; import { agentActivityMetaLabel, agentMessagePreview, agentRunSummaryLabel, taskStatusMarker } from "../display/labels"; import type { MessageStreamContext } from "./message-stream"; diff --git a/tests/display-model.test.ts b/tests/display-model.test.ts index 22c40970..767d17d0 100644 --- a/tests/display-model.test.ts +++ b/tests/display-model.test.ts @@ -1,22 +1,19 @@ import { describe, expect, it } from "vitest"; +import { activeAgentRunSummary } from "../src/display/agent"; +import { displayBlocksForItems } from "../src/display/blocks"; import { appendAssistantDelta, - activeAgentRunSummary, appendItemOutput, appendItemText, appendPlanDelta, appendToolOutput, - createAutoReviewResultItem, - createReviewResultItem, - displayBlocksForItems, - displayItemFromThreadItem, - displayItemsFromTurns, - executionState, - normalizeProposedPlanMarkdown, - planProgressDisplayItem, upsertDisplayItem, -} from "../src/display/model"; +} from "../src/display/stream-updates"; +import { normalizeProposedPlanMarkdown, planProgressDisplayItem } from "../src/display/plan"; +import { createAutoReviewResultItem, createReviewResultItem } from "../src/display/review"; +import { executionState } from "../src/display/state"; +import { displayItemFromThreadItem, displayItemsFromTurns } from "../src/display/thread-items"; import type { DisplayItem } from "../src/display/types"; import type { ThreadItem } from "../src/generated/app-server/v2/ThreadItem"; import type { Turn } from "../src/generated/app-server/v2/Turn"; @@ -76,6 +73,11 @@ describe("display model", () => { }); }); + it("suppresses legacy output thread items", () => { + expect(displayItemFromThreadItem({ type: "outputMessage", id: "out-1" } as unknown as ThreadItem)).toBeNull(); + expect(displayItemFromThreadItem({ type: "toolOutputMessage", id: "tool-out-1" } as unknown as ThreadItem)).toBeNull(); + }); + it("preserves reasoning text", () => { const item: ThreadItem = { type: "reasoning", id: "r1", summary: ["summary"], content: ["detail"] }; expect(displayItemFromThreadItem(item)?.text).toBe("summary\n\ndetail"); @@ -362,7 +364,7 @@ describe("display model", () => { source: "agent", status: "completed", commandActions: [{ type: "listFiles", command: "rg", path: "src/display" }], - aggregatedOutput: "src/display/model.ts", + aggregatedOutput: "src/display/thread-items.ts", exitCode: 0, durationMs: 10, }; @@ -558,6 +560,31 @@ describe("display model", () => { }); }); + it("preserves image generation status, details, and state", () => { + const item: ThreadItem = { + type: "imageGeneration", + id: "image-gen-1", + status: "completed", + revisedPrompt: "A precise UI mockup.", + result: "image result", + savedPath: "/vault/project/assets/generated.png", + }; + + expect(displayItemFromThreadItem(item, "t1")).toMatchObject({ + kind: "tool", + text: "/vault/project/assets/generated.png", + toolLabel: "imageGeneration", + summaryPath: true, + status: "completed", + details: [ + { title: "Saved path", body: "/vault/project/assets/generated.png" }, + { title: "Revised prompt", body: "A precise UI mockup." }, + { title: "Result", body: "image result" }, + ], + state: "completed", + }); + }); + it("uses details as the summary when a tool target cannot be extracted", () => { const item: ThreadItem = { type: "dynamicToolCall", @@ -603,6 +630,36 @@ describe("display model", () => { }); }); + it("preserves review mode items with their review output", () => { + const entered: ThreadItem = { type: "enteredReviewMode", id: "review-entered", review: "Review started" }; + const exited: ThreadItem = { type: "exitedReviewMode", id: "review-exited", review: "Review finished" }; + + expect(displayItemFromThreadItem(entered, "t1")).toMatchObject({ + kind: "tool", + text: "Entered review mode", + toolLabel: "enteredReviewMode", + output: "Review started", + }); + expect(displayItemFromThreadItem(exited, "t1")).toMatchObject({ + kind: "tool", + text: "Exited review mode", + toolLabel: "exitedReviewMode", + output: "Review finished", + }); + }); + + it("preserves context compaction items as short tool items", () => { + const item: ThreadItem = { type: "contextCompaction", id: "compact-1" }; + + expect(displayItemFromThreadItem(item, "t1")).toMatchObject({ + kind: "tool", + text: "Context compaction", + toolLabel: "contextCompaction", + turnId: "t1", + itemId: "compact-1", + }); + }); + it("structures automatic approval review summary messages", () => { expect( createReviewResultItem( @@ -637,7 +694,7 @@ describe("display model", () => { targetItemId: "patch-1", decisionSource: "agent", review: { status: "approved", riskLevel: "low", userAuthorization: "medium", rationale: "Allowed by policy." }, - action: { type: "applyPatch", cwd: "/vault", files: ["/vault/src/display/model.ts", "/vault/tests/display-model.test.ts"] }, + action: { type: "applyPatch", cwd: "/vault", files: ["/vault/src/display/thread-items.ts", "/vault/tests/display-model.test.ts"] }, }), ).toMatchObject({ kind: "reviewResult", @@ -648,7 +705,7 @@ describe("display model", () => { rows: expect.arrayContaining([ { key: "action", value: "apply patch" }, { key: "cwd", value: "/vault" }, - { key: "files", value: "src/display/model.ts\ntests/display-model.test.ts" }, + { key: "files", value: "src/display/thread-items.ts\ntests/display-model.test.ts" }, ]), }, ], diff --git a/tests/slash-commands.test.ts b/tests/slash-commands.test.ts index 6458a446..355be7ba 100644 --- a/tests/slash-commands.test.ts +++ b/tests/slash-commands.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; +import { slashCommandHelpLines } from "../src/composer/slash-commands"; import type { Thread } from "../src/generated/app-server/v2/Thread"; -import { executeSlashCommand, slashCommandHelpLines, type SlashCommandExecutionContext } from "../src/panel/slash-commands"; +import { executeSlashCommand, type SlashCommandExecutionContext } from "../src/panel/slash-commands"; function context(overrides: Partial = {}): SlashCommandExecutionContext { return {