From 09e2ce808321b5fc7a3e7b72ab47f50481004cbf Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 9 Jul 2026 01:18:13 -0700 Subject: [PATCH 1/4] fix(ui): clean up queued restore and MCP keybinding copy Assistant-model: GPT-5.5 --- packages/coding-agent/CHANGELOG.md | 1 + .../components/chat-session-host-actions.ts | 4 -- .../interactive-process-lifecycle.ts | 4 -- packages/mcp/CHANGELOG.md | 4 ++ packages/mcp/README.md | 2 +- packages/mcp/tool-result-renderer.ts | 2 +- test/unit/chat-session-host-02.test.ts | 7 +++ test/unit/mcp-tool-result-renderer.test.ts | 58 +++++++++++++++++++ 8 files changed, 72 insertions(+), 10 deletions(-) create mode 100644 test/unit/mcp-tool-result-renderer.test.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 9b56a829e..bc46654f9 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -11,6 +11,7 @@ - Fixed Windows native filesystem watchers to canonicalize watched paths before calling `fs.watch`, reject unresolved 8.3 short-name paths, and fall back to polling for unsafe watcher paths. This prevents libuv fs-event assertion crashes when temp, session, footer, or theme paths contain short-name components such as `USERNA~1`. - Fixed responses truncated at the output-token cap (`stopReason: "length"`) dead-ending the model's work with a "maximum output token limit" error when the context was still below the auto-compaction threshold. Previously auto-continuation only happened as a side effect of threshold compaction, so a length truncation with input room to spare left the task half-finished. Length-truncated turns now continue directly without compacting: the incomplete assistant is removed from retry context and the generation resumes automatically so the model finishes where it left off, bounded by a small consecutive-continuation cap so a turn that keeps exceeding the per-turn output cap still terminates. Compaction-driven continuation and fresh user prompts are unaffected. - Fixed bundled workflow durable resume for reusable `git_worktree_dir` worktrees so resumed runs reuse the original invocation repository/cwd and report slow Git subprocess timeouts as Git timeouts instead of repository-detection failures. +- Removed the queued-message restore success status from the interactive UI so restoring queued messages silently returns them to the editor without showing transient restore copy. ## [0.9.5-alpha.8] - 2026-07-08 diff --git a/packages/coding-agent/src/modes/interactive/components/chat-session-host-actions.ts b/packages/coding-agent/src/modes/interactive/components/chat-session-host-actions.ts index 5150046a2..ec23e4437 100644 --- a/packages/coding-agent/src/modes/interactive/components/chat-session-host-actions.ts +++ b/packages/coding-agent/src/modes/interactive/components/chat-session-host-actions.ts @@ -192,10 +192,6 @@ export function restoreQueuedMessagesToEditor< state.compactionQueuedMessages = []; setChatSessionEditorText(state, restoredText); state.getAgentSession?.()?.clearQueue(); - notifyChatSessionStatus( - state, - `Restored ${queuedMessages.length} queued message${queuedMessages.length === 1 ? "" : "s"} to editor`, - ); state.requestRender?.(); return true; } diff --git a/packages/coding-agent/src/modes/interactive/interactive-process-lifecycle.ts b/packages/coding-agent/src/modes/interactive/interactive-process-lifecycle.ts index 490340b9e..36ef991cf 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-process-lifecycle.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-process-lifecycle.ts @@ -246,9 +246,5 @@ InteractiveModeBase.prototype.handleDequeue = function(this: InteractiveModeBase const restored = this.restoreQueuedMessagesToEditor(); if (restored === 0) { this.showStatus("No queued messages to restore"); - } else { - this.showStatus( - `Restored ${restored} queued message${restored > 1 ? "s" : ""} to editor`, - ); } }; diff --git a/packages/mcp/CHANGELOG.md b/packages/mcp/CHANGELOG.md index 671331d08..b6c9e9f7f 100644 --- a/packages/mcp/CHANGELOG.md +++ b/packages/mcp/CHANGELOG.md @@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- Aligned MCP tool result expansion hints with the CLI-wide `Ctrl+o` keybinding copy. + ## [0.9.4] - 2026-07-03 ### Changed diff --git a/packages/mcp/README.md b/packages/mcp/README.md index ff0c6beb1..f5ca885ca 100644 --- a/packages/mcp/README.md +++ b/packages/mcp/README.md @@ -332,7 +332,7 @@ Prefer `.mcp.json` for project-local shared MCP config. Use `.pi/mcp.json` only | Connect | `mcp({ connect: "server-name" })` | | UI messages | `mcp({ action: "ui-messages" })` | -MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `CTRL+O Expand` hint, while the full result remains available when expanded and is still returned unchanged to the model. +MCP proxy and direct-tool results render compactly by default: long text shows the first three lines plus a `Ctrl+o Expand` hint, while the full result remains available when expanded and is still returned unchanged to the model. Search includes both MCP tools and Pi tools (from extensions). Pi tools appear first with `[pi tool]` prefix. Space-separated words are OR'd. diff --git a/packages/mcp/tool-result-renderer.ts b/packages/mcp/tool-result-renderer.ts index d08151fa3..a1dd1a4ac 100644 --- a/packages/mcp/tool-result-renderer.ts +++ b/packages/mcp/tool-result-renderer.ts @@ -58,7 +58,7 @@ export function renderMcpToolResult( .map((line) => line === "…" ? theme.fg("muted", line) : theme.fg("toolOutput", line)) .join("\n"); const hint = display.truncated && !options.expanded - ? `\n${theme.fg("muted", "(CTRL+O Expand)")}` + ? `\n${theme.fg("muted", "(Ctrl+o Expand)")}` : ""; return new Text(`${output}${hint}`, 0, 0); diff --git a/test/unit/chat-session-host-02.test.ts b/test/unit/chat-session-host-02.test.ts index 7ef4b6f99..0870d310d 100644 --- a/test/unit/chat-session-host-02.test.ts +++ b/test/unit/chat-session-host-02.test.ts @@ -66,6 +66,7 @@ test("ChatSessionHost clears busy state when model fallback fails", () => { host.dispose(); }); test("ChatSessionHost preserves compaction queued messages when flush fails", async () => { + const statusMessages: string[] = []; const host = makeHost({ getActionKeyDisplay: (action) => (action === "app.message.dequeue" ? "⌥↑" : action), commands: { @@ -74,6 +75,7 @@ test("ChatSessionHost preserves compaction queued messages when flush fails", as }, followUp: async () => {}, }, + showStatus: (message) => statusMessages.push(message), }); host.applyAgentEvent({ type: "compaction_start", reason: "manual" } as never); @@ -99,6 +101,11 @@ test("ChatSessionHost preserves compaction queued messages when flush fails", as assert.match(pending, /second/); assert.equal(host.restoreQueuedMessagesToEditor(), true); assert.equal(host.inputText(), "first\n\nsecond"); + assert.doesNotMatch(host.statusText(), /Restored .*queued message/); + assert.deepEqual( + statusMessages.filter((message) => /Restored .*queued message/.test(message)), + [], + ); host.dispose(); }); test("ChatSessionHost delegates handled slash commands before prompt routing", async () => { diff --git a/test/unit/mcp-tool-result-renderer.test.ts b/test/unit/mcp-tool-result-renderer.test.ts new file mode 100644 index 000000000..93ae45b0e --- /dev/null +++ b/test/unit/mcp-tool-result-renderer.test.ts @@ -0,0 +1,58 @@ +import { describe, test } from "bun:test"; +import assert from "node:assert/strict"; +import type { AgentToolResult, ToolRenderResultOptions } from "../../packages/coding-agent/src/index.ts"; +import { + formatMcpToolResultLines, + renderMcpToolResult, +} from "../../packages/mcp/tool-result-renderer.ts"; + +type McpDetails = Record; + +const theme = { + fg: (_name: string, text: string) => text, +}; + +function textResult(text: string): AgentToolResult { + return { + content: [{ type: "text", text }], + details: {}, + terminate: false, + }; +} + +function renderResult(text: string, options: ToolRenderResultOptions): string { + return renderMcpToolResult(textResult(text), options, theme).render(80).join("\n"); +} + +describe("MCP tool result rendering", () => { + test("formats collapsed result hint with CLI-wide Ctrl+o branding", () => { + const rendered = renderResult("one\ntwo\nthree\nfour", { + expanded: false, + isPartial: false, + }); + + assert.match(rendered, /\(Ctrl\+o Expand\)/); + assert.doesNotMatch(rendered, /CTRL\+O/); + }); + + test("omits expand hint when result is already expanded", () => { + const rendered = renderResult("one\ntwo\nthree\nfour", { + expanded: true, + isPartial: false, + }); + + assert.doesNotMatch(rendered, /Ctrl\+o Expand/); + assert.match(rendered, /four/); + }); + + test("reports truncation only when collapsed content exceeds the line budget", () => { + assert.deepEqual( + formatMcpToolResultLines(textResult("one\ntwo\nthree\nfour"), false), + { lines: ["one", "two", "three", "…"], truncated: true }, + ); + assert.deepEqual( + formatMcpToolResultLines(textResult("one\ntwo\nthree\nfour"), true), + { lines: ["one", "two", "three", "four"], truncated: false }, + ); + }); +}); From 1711e1601f66e9725075fda7045fd8a27cc7582f Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 9 Jul 2026 09:18:49 -0700 Subject: [PATCH 2/4] test: cover deferred startup in interactive command fakes Assistant-model: GPT-5.5 --- packages/coding-agent/test/first-run-onboarding.test.ts | 2 ++ .../test/interactive-mode-clone-command.test.ts | 7 +++++++ 2 files changed, 9 insertions(+) diff --git a/packages/coding-agent/test/first-run-onboarding.test.ts b/packages/coding-agent/test/first-run-onboarding.test.ts index de28ec520..f89545cca 100644 --- a/packages/coding-agent/test/first-run-onboarding.test.ts +++ b/packages/coding-agent/test/first-run-onboarding.test.ts @@ -334,6 +334,7 @@ describe("first-run onboarding", () => { }, ui: { requestRender: vi.fn() }, handleFatalRuntimeError: vi.fn(), + ensureDeferredStartupComplete: vi.fn(async () => {}), }; const clear = Reflect.get(InteractiveMode.prototype, "clearFirstRunOnboardingUi") as (this: typeof host) => void; const handleClearCommand = Reflect.get(InteractiveMode.prototype, "handleClearCommand") as (this: typeof host & { clearFirstRunOnboardingUi: () => void }) => Promise; @@ -341,6 +342,7 @@ describe("first-run onboarding", () => { hostWithClear.clearFirstRunOnboardingUi = () => clear.call(hostWithClear); await handleClearCommand.call(hostWithClear); + expect(host.ensureDeferredStartupComplete).toHaveBeenCalledTimes(1); expect(host.firstRunNoticeVisible).toBe(false); expect(host.firstRunOnboardingNoticeComponents).toEqual([]); diff --git a/packages/coding-agent/test/interactive-mode-clone-command.test.ts b/packages/coding-agent/test/interactive-mode-clone-command.test.ts index fea69c5d3..444cc3f3d 100644 --- a/packages/coding-agent/test/interactive-mode-clone-command.test.ts +++ b/packages/coding-agent/test/interactive-mode-clone-command.test.ts @@ -11,6 +11,7 @@ type CloneCommandContext = { showStatus: (message: string) => void; showError: (message: string) => void; ui: { requestRender: () => void }; + ensureDeferredStartupComplete: () => Promise; }; type InteractiveModePrototype = { @@ -27,6 +28,7 @@ describe("InteractiveMode /clone", () => { const showStatus = vi.fn(); const showError = vi.fn(); const requestRender = vi.fn(); + const ensureDeferredStartupComplete = vi.fn(async () => {}); const context: CloneCommandContext = { sessionManager: { getLeafId: () => "leaf-123" }, @@ -36,10 +38,12 @@ describe("InteractiveMode /clone", () => { showStatus, showError, ui: { requestRender }, + ensureDeferredStartupComplete, }; await interactiveModePrototype.handleCloneCommand.call(context); + expect(ensureDeferredStartupComplete).toHaveBeenCalledTimes(1); expect(fork).toHaveBeenCalledWith("leaf-123", { position: "at" }); expect(renderCurrentSessionState).toHaveBeenCalled(); expect(setText).toHaveBeenCalledWith(""); @@ -52,6 +56,7 @@ describe("InteractiveMode /clone", () => { const fork = vi.fn(async () => ({ cancelled: false })); const showStatus = vi.fn(); const showError = vi.fn(); + const ensureDeferredStartupComplete = vi.fn(async () => {}); const context: CloneCommandContext = { sessionManager: { getLeafId: () => null }, @@ -61,10 +66,12 @@ describe("InteractiveMode /clone", () => { showStatus, showError, ui: { requestRender: vi.fn() }, + ensureDeferredStartupComplete, }; await interactiveModePrototype.handleCloneCommand.call(context); + expect(ensureDeferredStartupComplete).toHaveBeenCalledTimes(1); expect(fork).not.toHaveBeenCalled(); expect(showStatus).toHaveBeenCalledWith("Nothing to clone yet"); expect(showError).not.toHaveBeenCalled(); From 5f77b7b73af756894de476fd75d671779f22be40 Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 9 Jul 2026 09:35:51 -0700 Subject: [PATCH 3/4] test: align MCP renderer fixture details Assistant-model: GPT-5.5 --- packages/mcp/tool-result-renderer.ts | 2 +- test/unit/mcp-tool-result-renderer.test.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/mcp/tool-result-renderer.ts b/packages/mcp/tool-result-renderer.ts index a1dd1a4ac..fdc104fda 100644 --- a/packages/mcp/tool-result-renderer.ts +++ b/packages/mcp/tool-result-renderer.ts @@ -1,7 +1,7 @@ import type { AgentToolResult, ToolRenderResultOptions } from "@bastani/atomic"; import { Text } from "@earendil-works/pi-tui"; -type McpToolResultDetails = Record & { error?: unknown }; +export type McpToolResultDetails = Record & { error?: unknown }; type McpToolContentBlock = AgentToolResult["content"][number]; interface RenderTheme { diff --git a/test/unit/mcp-tool-result-renderer.test.ts b/test/unit/mcp-tool-result-renderer.test.ts index 93ae45b0e..2b2f2b450 100644 --- a/test/unit/mcp-tool-result-renderer.test.ts +++ b/test/unit/mcp-tool-result-renderer.test.ts @@ -4,15 +4,15 @@ import type { AgentToolResult, ToolRenderResultOptions } from "../../packages/co import { formatMcpToolResultLines, renderMcpToolResult, + type McpToolResultDetails, } from "../../packages/mcp/tool-result-renderer.ts"; -type McpDetails = Record; const theme = { fg: (_name: string, text: string) => text, }; -function textResult(text: string): AgentToolResult { +function textResult(text: string): AgentToolResult { return { content: [{ type: "text", text }], details: {}, From a5a8a30701f4bae8858e5d1aa16177720a22ca5a Mon Sep 17 00:00:00 2001 From: Alex Lavaee Date: Thu, 9 Jul 2026 09:45:11 -0700 Subject: [PATCH 4/4] fix: use configured MCP expand key hint Assistant-model: GPT-5.5 --- packages/mcp/tool-result-renderer.ts | 4 ++-- test/unit/mcp-tool-result-renderer.test.ts | 10 +++++----- 2 files changed, 7 insertions(+), 7 deletions(-) diff --git a/packages/mcp/tool-result-renderer.ts b/packages/mcp/tool-result-renderer.ts index fdc104fda..34154cedf 100644 --- a/packages/mcp/tool-result-renderer.ts +++ b/packages/mcp/tool-result-renderer.ts @@ -1,4 +1,4 @@ -import type { AgentToolResult, ToolRenderResultOptions } from "@bastani/atomic"; +import { keyText, type AgentToolResult, type ToolRenderResultOptions } from "@bastani/atomic"; import { Text } from "@earendil-works/pi-tui"; export type McpToolResultDetails = Record & { error?: unknown }; @@ -58,7 +58,7 @@ export function renderMcpToolResult( .map((line) => line === "…" ? theme.fg("muted", line) : theme.fg("toolOutput", line)) .join("\n"); const hint = display.truncated && !options.expanded - ? `\n${theme.fg("muted", "(Ctrl+o Expand)")}` + ? `\n${theme.fg("muted", `(${keyText("app.tools.expand")} Expand)`)}` : ""; return new Text(`${output}${hint}`, 0, 0); diff --git a/test/unit/mcp-tool-result-renderer.test.ts b/test/unit/mcp-tool-result-renderer.test.ts index 2b2f2b450..4bdda68ee 100644 --- a/test/unit/mcp-tool-result-renderer.test.ts +++ b/test/unit/mcp-tool-result-renderer.test.ts @@ -1,13 +1,12 @@ import { describe, test } from "bun:test"; import assert from "node:assert/strict"; -import type { AgentToolResult, ToolRenderResultOptions } from "../../packages/coding-agent/src/index.ts"; +import { keyText, type AgentToolResult, type ToolRenderResultOptions } from "../../packages/coding-agent/src/index.ts"; import { formatMcpToolResultLines, renderMcpToolResult, type McpToolResultDetails, } from "../../packages/mcp/tool-result-renderer.ts"; - const theme = { fg: (_name: string, text: string) => text, }; @@ -25,14 +24,15 @@ function renderResult(text: string, options: ToolRenderResultOptions): string { } describe("MCP tool result rendering", () => { - test("formats collapsed result hint with CLI-wide Ctrl+o branding", () => { + test("formats collapsed result hint with the configured expand keybinding", () => { const rendered = renderResult("one\ntwo\nthree\nfour", { expanded: false, isPartial: false, }); - assert.match(rendered, /\(Ctrl\+o Expand\)/); + assert.match(rendered, new RegExp(`\\(${keyText("app.tools.expand")} Expand\\)`)); assert.doesNotMatch(rendered, /CTRL\+O/); + assert.doesNotMatch(rendered, /Ctrl\+o/); }); test("omits expand hint when result is already expanded", () => { @@ -41,7 +41,7 @@ describe("MCP tool result rendering", () => { isPartial: false, }); - assert.doesNotMatch(rendered, /Ctrl\+o Expand/); + assert.doesNotMatch(rendered, new RegExp(`${keyText("app.tools.expand")} Expand`)); assert.match(rendered, /four/); });