diff --git a/apps/cli/src/ui/interactive-mode.ts b/apps/cli/src/ui/interactive-mode.ts index b04f9f3b..b4d20f53 100644 --- a/apps/cli/src/ui/interactive-mode.ts +++ b/apps/cli/src/ui/interactive-mode.ts @@ -1271,13 +1271,13 @@ export class InteractiveMode { } /** - * Update terminal title with session name and cwd. + * Show the session name, or product and cwd when unnamed. */ updateTerminalTitle(): void { const cwdBasename = path.basename(this.sessionManager.getCwd()); const sessionName = this.sessionManager.getSessionName(); if (sessionName) { - this.ui.terminal.setTitle(`${APP_TITLE} - ${sessionName} - ${cwdBasename}`); + this.ui.terminal.setTitle(sessionName); } else { this.ui.terminal.setTitle(`${APP_TITLE} - ${cwdBasename}`); } @@ -4795,6 +4795,7 @@ export class InteractiveMode { }, onShowTerminalProgressChange: (enabled) => { this.settingsManager.setShowTerminalProgress(enabled); + this.ui.terminal.setProgress(enabled && (this.session.isStreaming || this.session.isCompacting)); }, onStatusTipsChange: (enabled) => { this.settingsManager.setStatusTips(enabled); @@ -6826,9 +6827,7 @@ ${followUpRow}| \`${dequeue}\` | Restore queued messages | this.hideExtensionSelector(); this.hideExtensionInput(); this.disposeActiveSelector(); - if (this.settingsManager.getShowTerminalProgress()) { - this.ui.terminal.setProgress(false); - } + this.ui.terminal.setProgress(false); this.themeController.disableAutoSync(); this.clearExtensionTerminalInputListeners(); this.footer.dispose(); diff --git a/apps/cli/src/ui/runtime/session-events.ts b/apps/cli/src/ui/runtime/session-events.ts index 1dd70462..ed00ff16 100644 --- a/apps/cli/src/ui/runtime/session-events.ts +++ b/apps/cli/src/ui/runtime/session-events.ts @@ -17,7 +17,8 @@ * event; the intentional double-invalidate in compaction_end; the retry handler restored * TWICE (agent_start defensive @ retryEscapeHandler + auto_retry_end); the lazy init guard * before the first event; getShowTerminalProgress read LIVE (never snapshotted) at - * turn_start/compaction_start/compaction_end; per-case requestRender placement 1:1 with no + * turn_start/compaction_start; completion always clears progress even when disabled; + * per-case requestRender placement 1:1 with no * coalescing; message_end updateContent(false) before undefining streamingComponent; * agent_end removeChild before undefine. The subscribe/unsubscribe pair is owned by the * composition root and moved in lockstep with rebindCurrentSession. @@ -314,9 +315,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio } case "agent_end": - if (ctx.settingsManager.getShowTerminalProgress()) { - ctx.ui.terminal.setProgress(false); - } + ctx.ui.terminal.setProgress(false); ctx.clearStatusIndicator("working"); // Turn-done marker: only for normally-ended turns (aborted/error // turns already show an error line in the transcript); stays @@ -363,7 +362,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio } case "auto_clm_end": { - if (ctx.settingsManager.getShowTerminalProgress()) ctx.ui.terminal.setProgress(false); + ctx.ui.terminal.setProgress(false); if (ctx.autoCompactionEscapeHandler) { ctx.defaultEditor.onEscape = ctx.autoCompactionEscapeHandler; ctx.autoCompactionEscapeHandler = undefined; @@ -375,9 +374,7 @@ export async function handleSessionEvent(ctx: RuntimeContext, event: AgentSessio } case "compaction_end": { - if (ctx.settingsManager.getShowTerminalProgress()) { - ctx.ui.terminal.setProgress(false); - } + ctx.ui.terminal.setProgress(false); if (ctx.autoCompactionEscapeHandler) { ctx.defaultEditor.onEscape = ctx.autoCompactionEscapeHandler; ctx.autoCompactionEscapeHandler = undefined; diff --git a/apps/cli/src/ui/view/dialogs/settings-selector.ts b/apps/cli/src/ui/view/dialogs/settings-selector.ts index 1d884ce6..a573544e 100644 --- a/apps/cli/src/ui/view/dialogs/settings-selector.ts +++ b/apps/cli/src/ui/view/dialogs/settings-selector.ts @@ -746,7 +746,7 @@ export class SettingsSelectorComponent extends Container { items.splice(clearOnShrinkIndex + 1, 0, { id: "terminal-progress", label: "Terminal progress", - description: "Show OSC 9;4 progress indicators in the terminal tab bar", + description: "Show OSC 9;4 busy indicators in the terminal tab bar (auto: Otty, Ghostty)", currentValue: config.showTerminalProgress ? "true" : "false", values: ["true", "false"], }); diff --git a/apps/cli/src/ui/view/transcript/step-error-hints.ts b/apps/cli/src/ui/view/transcript/step-error-hints.ts deleted file mode 100644 index 638b7a53..00000000 --- a/apps/cli/src/ui/view/transcript/step-error-hints.ts +++ /dev/null @@ -1,33 +0,0 @@ -/** - * Step 工具失败时的「怎么办」建议——错误呈现三要素的第三段。 - * - * 三要素:`✗ 工具名`(发生了什么)→ 错误文本(为什么)→ `↳ 建议`(怎么办)。 - * 分类只做关键词匹配;宁可落到通用建议,也不对原因做臆测。 - */ - -const STEP_ERROR_HINTS: ReadonlyArray<{ match: RegExp; hint: string }> = [ - { - match: /command not found|spawn.*ENOENT|is not recognized as/iu, - hint: "命令不存在:检查拼写,或先安装对应依赖", - }, - { match: /permission denied|EACCES|EPERM/iu, hint: "权限不足:确认审批模式与文件权限后重试" }, - { match: /no such file or directory|ENOENT/iu, hint: "路径不存在:让模型先列目录确认结构" }, - { match: /EISDIR/iu, hint: "把目录当成了文件:检查目标路径类型" }, - { - match: /ETIMEDOUT|ECONNREFUSED|ECONNRESET|network error|timed? ?out/iu, - hint: "网络或超时:稍后重试,或检查代理配置", - }, - { match: /syntax error|unexpected token|unexpected end of/iu, hint: "命令语法有误:可让模型拆小步重试" }, -]; - -const GENERIC_HINT = "可回复「重试」让模型换一种方式,或补充说明预期结果"; - -/** Pick a recovery hint for a failed tool result's raw error text. */ -export function stepErrorHint(errorText: string): string { - const text = errorText.trim(); - if (!text) return GENERIC_HINT; - for (const { match, hint } of STEP_ERROR_HINTS) { - if (match.test(text)) return hint; - } - return GENERIC_HINT; -} diff --git a/apps/cli/src/ui/view/transcript/tool-execution.ts b/apps/cli/src/ui/view/transcript/tool-execution.ts index 8bf878e3..476f97f9 100644 --- a/apps/cli/src/ui/view/transcript/tool-execution.ts +++ b/apps/cli/src/ui/view/transcript/tool-execution.ts @@ -26,7 +26,6 @@ import { wrapTextWithAnsi, } from "@step-harness/pi-tui"; import { RenderLineCache } from "./render-line-cache.ts"; -import { stepErrorHint } from "./step-error-hints.ts"; import { prependAfterTerminalSequences } from "./step-message.ts"; import type { StepToolSpinnerState } from "./step-spinner.ts"; @@ -768,12 +767,6 @@ export class ToolExecutionComponent extends Container { } const body = this.buildStepBodyLines(resultLines, bodyWidth); - // 错误呈现三要素收尾:what(✗ 标题行)/ why(错误文本)之后补 how—— - // 一行可执行的恢复建议,让失败不只是被报告,还能被处理。 - if (!this.isPartial && this.result?.isError) { - const hint = stepErrorHint(this.getTextOutput()); - body.push(clampStepLine(` ${theme.fg("muted", `↳ ${hint}`)}`, safeWidth)); - } const lines = [...header, ...body]; this.appendImages(lines, safeWidth); return lines.length > 0 ? [...lines, ""] : []; diff --git a/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap b/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap index b93002df..26991c4f 100644 --- a/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap +++ b/apps/cli/test/__snapshots__/tui-acceptance-snapshot.test.ts.snap @@ -89,7 +89,6 @@ exports[`D. 工具执行流快照 > 进行中/成功/失败三态(D1/D6) > t [ "✗ scan ./src for todos", " └ boom", - " ↳ 可回复「重试」让模型换一种方式,或补充说明预期结果", "", ] `; diff --git a/apps/cli/test/session-events-working-tracker.test.ts b/apps/cli/test/session-events-working-tracker.test.ts index 1c6616d9..84bd0f5b 100644 --- a/apps/cli/test/session-events-working-tracker.test.ts +++ b/apps/cli/test/session-events-working-tracker.test.ts @@ -4,6 +4,34 @@ import { WorkingOutputTracker } from "../src/ui/view/chrome/status-indicator.ts" import type { RuntimeContext } from "../src/ui/runtime/context.ts"; import { handleSessionEvent } from "../src/ui/runtime/session-events.ts"; +test.each(["agent_end", "auto_clm_end", "compaction_end"] as const)( + "clears terminal progress on %s even after the setting is disabled", + async (type) => { + const setProgress = vi.fn(); + const ctx = { + isInitialized: true, + footer: { invalidate: vi.fn() }, + settingsManager: { getShowTerminalProgress: () => false }, + ui: { terminal: { setProgress } }, + clearStatusIndicator: vi.fn(), + pendingTools: new Map(), + redraw: { requestRender: vi.fn() }, + flushCompactionQueue: vi.fn(), + } as unknown as RuntimeContext; + if (type === "compaction_end") { + await handleSessionEvent(ctx, { type, reason: "manual", result: undefined, aborted: false, willRetry: false }); + } else if (type === "agent_end") { + await handleSessionEvent(ctx, { type, messages: [], willRetry: false }); + } else { + await handleSessionEvent(ctx, { + type, + result: { attempted: true, accepted: false, fallback: false, reason: "aborted", requests: 0 }, + }); + } + expect(setProgress).toHaveBeenCalledExactlyOnceWith(false); + }, +); + test("turn tips follow the current goal branch and respect the disabled setting", async () => { const now = new Date().toISOString(); const goal = { id: "goal-1", sessionId: "session-1", objective: "Ship it", status: "active", createdAt: now, updatedAt: now }; diff --git a/apps/cli/test/settings-selector.test.ts b/apps/cli/test/settings-selector.test.ts index 3afcc6e5..61dc82e8 100644 --- a/apps/cli/test/settings-selector.test.ts +++ b/apps/cli/test/settings-selector.test.ts @@ -1,4 +1,5 @@ import { setKeybindings } from "@step-harness/pi-tui"; +import { SettingsManager } from "@step-harness/coding-agent"; import { beforeAll, describe, expect, it, vi } from "vitest"; import { KeybindingsManager } from "../../../packages/coding-agent/src/core/keybindings.ts"; import { @@ -7,6 +8,7 @@ import { SettingsSelectorComponent, } from "../src/ui/view/dialogs/settings-selector.ts"; import { initTheme } from "../../../packages/coding-agent/src/theme/theme.ts"; +import { InteractiveMode } from "../src/ui/interactive-mode.ts"; describe("SettingsSelectorComponent", () => { beforeAll(() => { @@ -48,4 +50,34 @@ describe("SettingsSelectorComponent", () => { cycle("Fullscreen copy on select", 2); expect(onCopyOnSelectChange.mock.calls.flat()).toEqual([false, true]); }); + + it.each([ + { isStreaming: true, isCompacting: false }, + { isStreaming: false, isCompacting: true }, + { isStreaming: false, isCompacting: false }, + ])("applies terminal progress toggles immediately: %j", (activity) => { + const settingsManager = SettingsManager.inMemory({ terminal: { showTerminalProgress: true } }); + const setProgress = vi.fn(); + const mode = Object.create(InteractiveMode.prototype) as InteractiveMode; + let selector: SettingsSelectorComponent | undefined; + Object.assign(mode, { + runtimeHost: { + session: { ...activity, settingsManager, modelRuntime: { getAvailableSnapshot: () => [] } }, + }, + ui: { mode: "regular", terminal: { setProgress } }, + themeController: { getThemeSelection: () => "dark", getTerminalTheme: () => "dark" }, + showSelector: (create: (done: () => void) => { component: SettingsSelectorComponent }) => { + selector = create(() => {}).component; + }, + }); + mode.showSettingsSelector(); + const list = selector!.getSettingsList(); + for (const character of "Terminal progress") list.handleInput(character); + list.handleInput("\r"); + expect(settingsManager.getShowTerminalProgress()).toBe(false); + expect(setProgress).toHaveBeenLastCalledWith(false); + list.handleInput("\r"); + expect(settingsManager.getShowTerminalProgress()).toBe(true); + expect(setProgress).toHaveBeenLastCalledWith(activity.isStreaming || activity.isCompacting); + }); }); diff --git a/apps/cli/test/step-error-hints.test.ts b/apps/cli/test/step-error-hints.test.ts deleted file mode 100644 index cfce4114..00000000 --- a/apps/cli/test/step-error-hints.test.ts +++ /dev/null @@ -1,41 +0,0 @@ -import { describe, expect, test } from "vitest"; -import { stepErrorHint } from "../src/ui/view/transcript/step-error-hints.ts"; - -describe("stepErrorHint 错误恢复建议", () => { - test("命令不存在 → 检查拼写或安装依赖", () => { - expect(stepErrorHint("bash: fd: command not found")).toBe("命令不存在:检查拼写,或先安装对应依赖"); - expect(stepErrorHint("Error: spawn rg ENOENT")).toBe("命令不存在:检查拼写,或先安装对应依赖"); - }); - - test("权限不足 → 审批模式与文件权限", () => { - expect(stepErrorHint("EACCES: permission denied, open '/root/secret'")).toBe( - "权限不足:确认审批模式与文件权限后重试", - ); - }); - - test("路径不存在 → 先列目录", () => { - expect(stepErrorHint("ENOENT: no such file or directory, open 'src/missing.ts'")).toBe( - "路径不存在:让模型先列目录确认结构", - ); - }); - - test("网络/超时 → 重试或代理", () => { - expect(stepErrorHint("fetch failed: ETIMEDOUT")).toBe("网络或超时:稍后重试,或检查代理配置"); - }); - - test("语法错误 → 拆小步", () => { - expect(stepErrorHint("/bin/sh: -c: line 1: syntax error near unexpected token `|'")).toBe( - "命令语法有误:可让模型拆小步重试", - ); - }); - - test("未知错误 → 通用建议(不臆测原因)", () => { - expect(stepErrorHint("something totally unexpected happened")).toBe( - "可回复「重试」让模型换一种方式,或补充说明预期结果", - ); - }); - - test("空文本 → 通用建议", () => { - expect(stepErrorHint(" ")).toBe("可回复「重试」让模型换一种方式,或补充说明预期结果"); - }); -}); diff --git a/apps/cli/test/terminal-session-title.test.ts b/apps/cli/test/terminal-session-title.test.ts new file mode 100644 index 00000000..7147ff40 --- /dev/null +++ b/apps/cli/test/terminal-session-title.test.ts @@ -0,0 +1,58 @@ +import { APP_TITLE, initTheme, SessionManager } from "@step-harness/coding-agent"; +import { Container } from "@step-harness/pi-tui"; +import { describe, expect, it, vi } from "vitest"; +import { InteractiveMode } from "../src/ui/interactive-mode.ts"; + +function createMode() { + initTheme("dark", false); + const sessionManager = SessionManager.inMemory("/workspace/project"); + const setTitle = vi.fn(); + const showWarning = vi.fn(); + const mode = Object.create(InteractiveMode.prototype) as InteractiveMode; + Object.assign(mode, { + runtimeHost: { session: { sessionManager, setSessionName: (name: string) => sessionManager.appendSessionInfo(name) } }, + ui: { terminal: { setTitle } }, + chatContainer: new Container(), + _redraw: { requestRender: vi.fn() }, + showWarning, + }); + return { mode, sessionManager, setTitle, showWarning }; +} + +describe("terminal session title", () => { + it.each(["Login timeout investigation", "登录接口超时排查"])("shows only the session name: %s", (name) => { + const h = createMode(); + h.sessionManager.appendSessionInfo(name); + h.mode.updateTerminalTitle(); + expect(h.setTitle).toHaveBeenCalledExactlyOnceWith(name); + }); + + it("keeps the product and directory fallback when the name is absent or cleared", () => { + const h = createMode(); + h.mode.updateTerminalTitle(); + expect(h.setTitle).toHaveBeenLastCalledWith(`${APP_TITLE} - project`); + h.sessionManager.appendSessionInfo("Manual title"); + h.mode.updateTerminalTitle(); + expect(h.setTitle).toHaveBeenLastCalledWith("Manual title"); + h.sessionManager.appendSessionInfo(""); + h.mode.updateTerminalTitle(); + expect(h.setTitle).toHaveBeenLastCalledWith(`${APP_TITLE} - project`); + }); + + it("shows the current name without changing metadata or requiring a model", () => { + const h = createMode(); + h.sessionManager.appendSessionInfo("人工名称"); + h.mode.handleNameCommand("/name"); + expect(h.mode.chatContainer.render(80).join("\n")).toContain("Session name: 人工名称"); + expect(h.sessionManager.getEntries().filter((entry) => entry.type === "session_info")).toHaveLength(1); + }); + + it("shows usage when unnamed and persists explicit names synchronously", () => { + const h = createMode(); + h.mode.handleNameCommand("/name"); + expect(h.showWarning).toHaveBeenCalledWith("Usage: /name "); + h.mode.handleNameCommand("/name 人工名称"); + expect(h.sessionManager.getSessionName()).toBe("人工名称"); + expect(h.mode.chatContainer.render(80).join("\n")).toContain("Session name set: 人工名称"); + }); +}); diff --git a/apps/cli/test/tui-acceptance-snapshot.test.ts b/apps/cli/test/tui-acceptance-snapshot.test.ts index ead4406c..3c255a5b 100644 --- a/apps/cli/test/tui-acceptance-snapshot.test.ts +++ b/apps/cli/test/tui-acceptance-snapshot.test.ts @@ -290,9 +290,9 @@ describe("D. 工具执行流快照", () => { const failure = stepToolComponent(); failure.updateResult({ content: [{ type: "text", text: "boom" }], details: {}, isError: true }, false); expect(failure.render(72).join("\n")).toContain(stripTerminalSequences(theme.fg("error", "✗"))); - // 6-2 错误三要素:why(错误文本 error 色)+ how(↳ 恢复建议) + // 6-2 失败态只渲染 why(错误文本 error 色):不再追加「↳ 恢复建议」行。 expect(failure.render(72).join("\n")).toContain(stripTerminalSequences(theme.fg("error", "boom"))); - expect(failure.render(72).join("\n")).toContain("↳ 可回复「重试」"); + expect(failure.render(72).join("\n")).not.toContain("↳"); expect(plain(failure.render(72))).toMatchSnapshot("tool-error-72"); }); }); diff --git a/packages/coding-agent/docs/session-format.md b/packages/coding-agent/docs/session-format.md index b5c301b9..87e74fb4 100644 --- a/packages/coding-agent/docs/session-format.md +++ b/packages/coding-agent/docs/session-format.md @@ -311,6 +311,8 @@ Session metadata (e.g., user-defined display name). Set via `/name`, `--name` / The session name is displayed in the session selector (`/resume`) instead of the first message when set. +The existing `session_info_changed` UI path updates the terminal title using OSC 0, with control characters removed from dynamic title text. Named sessions display only their name; unnamed sessions retain the product and directory fallback. This protocol is used by Apple Terminal, iTerm2, Ghostty, Otty, and other compatible terminals; visibility depends on the terminal's tab/window title settings. Per-tab busy state is a separate OSC 9;4 indicator controlled by `terminal.showTerminalProgress` (see [settings](settings.md)); no notification, bell, or completion status is added. + ## Tree Structure Entries form a tree: diff --git a/packages/coding-agent/docs/settings.md b/packages/coding-agent/docs/settings.md index 985e1668..bbe2fe1f 100644 --- a/packages/coding-agent/docs/settings.md +++ b/packages/coding-agent/docs/settings.md @@ -199,12 +199,15 @@ A callback that catches an agent-node failure and rethrows a **new** error is co | `terminal.showImages` | boolean | `true` | Show images in terminal (if supported) | | `terminal.imageWidthCells` | number | `60` | Preferred inline image width in terminal cells | | `terminal.clearOnShrink` | boolean | `false` | Clear empty rows when content shrinks (can cause flicker) | +| `terminal.showTerminalProgress` | boolean | auto | Show an OSC 9;4 busy indicator on the terminal tab while the agent works or compacts. Unset enables it in Otty and Ghostty only, outside tmux/screen; set `true` to force it in other terminals that support OSC 9;4, `false` to disable | | `terminal.hyperlinks` | boolean or `"auto"` | `"auto"` | Override OSC 8 hyperlink support (advanced, JSON-only) | | `terminal.images` | string or boolean | `"auto"` | Override image protocol support with `"kitty"`, `"iterm2"`, `false`, or `"auto"` (advanced, JSON-only) | | `terminal.trueColor` | boolean or `"auto"` | `"auto"` | Override truecolor support (advanced, JSON-only) | | `images.autoResize` | boolean | `true` | Resize images to 2000x2000 max. Applies to `@file` attachments, `read`, and images returned by tools | | `images.blockImages` | boolean | `false` | Block all images from being sent to LLM | +Changing terminal progress in `/settings` applies immediately. Disabling it clears any active indicator and stops its keepalive; agent and compaction completion always clear progress even if the setting changed during the operation. + ### Shell | Setting | Type | Default | Description | diff --git a/packages/coding-agent/src/core/settings-manager.ts b/packages/coding-agent/src/core/settings-manager.ts index 99e09c0c..95e3dc10 100644 --- a/packages/coding-agent/src/core/settings-manager.ts +++ b/packages/coding-agent/src/core/settings-manager.ts @@ -1,5 +1,6 @@ import type { ThinkingLevel } from "@step-harness/agent-core"; import type { TuiMode as RendererTuiMode, ScrollViewScrollbar, TerminalCapabilities } from "@step-harness/pi-tui"; +import { supportsTerminalProgress } from "@step-harness/pi-tui"; import type { Transport } from "@step-harness/providers"; import { randomUUID } from "crypto"; import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs"; @@ -48,7 +49,7 @@ export interface TerminalSettings { showImages?: boolean; // default: true (only relevant if terminal supports images) imageWidthCells?: number; // default: 60 (preferred inline image width in terminal cells) clearOnShrink?: boolean; // default: false (clear empty rows when content shrinks) - showTerminalProgress?: boolean; // default: false (OSC 9;4 terminal progress indicators) + showTerminalProgress?: boolean; // default: auto, on in Otty and Ghostty (OSC 9;4 terminal progress indicators) hyperlinks?: boolean | "auto"; images?: "kitty" | "iterm2" | "auto" | false; trueColor?: boolean | "auto"; @@ -1235,7 +1236,7 @@ export class SettingsManager { } getShowTerminalProgress(): boolean { - return this.settings.terminal?.showTerminalProgress ?? false; + return this.settings.terminal?.showTerminalProgress ?? supportsTerminalProgress(); } setShowTerminalProgress(enabled: boolean): void { diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index 68524325..2293d0f8 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -1,7 +1,7 @@ import { existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "fs"; import { homedir } from "os"; import { join } from "path"; -import { afterEach, beforeEach, describe, expect, it } from "vitest"; +import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { DEFAULT_HTTP_IDLE_TIMEOUT_MS } from "../src/core/http-dispatcher.ts"; import { type Settings, SettingsManager } from "../src/core/settings-manager.ts"; @@ -354,6 +354,34 @@ describe("SettingsManager", () => { }); }); + describe("terminal progress", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it("follows terminal support when unset", () => { + vi.stubEnv("TMUX", ""); + vi.stubEnv("STY", ""); + vi.stubEnv("TERM_PROGRAM", "otty"); + expect(SettingsManager.inMemory().getShowTerminalProgress()).toBe(true); + vi.stubEnv("TERM_PROGRAM", "Apple_Terminal"); + expect(SettingsManager.inMemory().getShowTerminalProgress()).toBe(false); + }); + + it("keeps an explicit setting over terminal support", () => { + vi.stubEnv("TMUX", ""); + vi.stubEnv("STY", ""); + vi.stubEnv("TERM_PROGRAM", "otty"); + expect(SettingsManager.inMemory({ terminal: { showTerminalProgress: false } }).getShowTerminalProgress()).toBe( + false, + ); + vi.stubEnv("TERM_PROGRAM", "Apple_Terminal"); + expect(SettingsManager.inMemory({ terminal: { showTerminalProgress: true } }).getShowTerminalProgress()).toBe( + true, + ); + }); + }); + describe("httpIdleTimeoutMs", () => { it("should default to 5 minutes", () => { const manager = SettingsManager.create(projectDir, agentDir); diff --git a/packages/tui/README.md b/packages/tui/README.md index c2bc68fd..7188b8bb 100644 --- a/packages/tui/README.md +++ b/packages/tui/README.md @@ -685,6 +685,8 @@ interface Terminal { - `ProcessTerminal` - Uses `process.stdin/stdout` - `VirtualTerminal` - For testing (uses `@xterm/headless`) +`ProcessTerminal.setProgress(true)` emits OSC 9;4 busy state and keeps it alive until cleared or stopped. `setProgress(false)` clears only an indicator activated by that instance and stops its keepalive; clearing an inactive indicator emits no OSC 9, so callers can safely request cleanup even when progress is disabled. + ## Utilities ```typescript diff --git a/packages/tui/src/index.ts b/packages/tui/src/index.ts index a6df1dc6..3055df0c 100644 --- a/packages/tui/src/index.ts +++ b/packages/tui/src/index.ts @@ -77,7 +77,7 @@ export { type RenderLatexOptions, renderLatex } from "./latex.ts"; // Input buffering for batch splitting export { StdinBuffer, type StdinBufferEventMap, type StdinBufferOptions } from "./stdin-buffer.ts"; // Terminal interface and implementations -export { ProcessTerminal, type Terminal } from "./terminal.ts"; +export { ProcessTerminal, supportsTerminalProgress, type Terminal } from "./terminal.ts"; // Terminal colors export { parseOsc11BackgroundColor, diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index ecf211d8..c6543a5a 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -40,6 +40,16 @@ export function isAppleTerminalSession(): boolean { return process.platform === "darwin" && process.env.TERM_PROGRAM === "Apple_Terminal"; } +/** + * Terminals verified to render OSC 9;4 as per-tab progress. Others may treat + * OSC 9 as a desktop notification, and multiplexers drop it, so stay off there. + */ +export function supportsTerminalProgress(env: NodeJS.ProcessEnv = process.env): boolean { + if (env.TMUX || env.STY) return false; + const termProgram = env.TERM_PROGRAM?.toLowerCase(); + return termProgram === "otty" || termProgram === "ghostty"; +} + export function normalizeNativeShiftEnterInput( data: string, shouldDetectNativeShiftEnter: boolean, @@ -499,7 +509,8 @@ export class ProcessTerminal implements Terminal { setTitle(title: string): void { // OSC 0;title BEL - set terminal window title - process.stdout.write(`\x1b]0;${title}\x07`); + const safeTitle = title.replace(/\s+/g, " ").replace(/[\x00-\x1f\x7f-\x9f]/g, ""); + process.stdout.write(`\x1b]0;${safeTitle}\x07`); } setProgress(active: boolean): void { @@ -511,9 +522,8 @@ export class ProcessTerminal implements Terminal { process.stdout.write(TERMINAL_PROGRESS_ACTIVE_SEQUENCE); }, TERMINAL_PROGRESS_KEEPALIVE_MS); } - } else { - this.clearProgressInterval(); - // OSC 9;4;0 - clear progress + } else if (this.clearProgressInterval()) { + // Only clear an indicator we activated: other terminals may interpret OSC 9 as a notification. process.stdout.write(TERMINAL_PROGRESS_CLEAR_SEQUENCE); } } diff --git a/packages/tui/test/terminal.test.ts b/packages/tui/test/terminal.test.ts index cb4ef539..2a054dbe 100644 --- a/packages/tui/test/terminal.test.ts +++ b/packages/tui/test/terminal.test.ts @@ -6,8 +6,26 @@ import { normalizeNativeShiftEnterInput, ProcessTerminal, resolveEscapeTimeoutMs, + supportsTerminalProgress, } from "../src/terminal.ts"; +describe("supportsTerminalProgress", () => { + it("enables OSC 9;4 for terminals verified to render tab progress", () => { + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "otty" }), true); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "ghostty" }), true); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "Ghostty" }), true); + }); + + it("stays off for unknown terminals and multiplexers", () => { + assert.equal(supportsTerminalProgress({}), false); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "Apple_Terminal" }), false); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "iTerm.app" }), false); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "tmux" }), false); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "otty", TMUX: "/tmp/tmux-1/default,1,0" }), false); + assert.equal(supportsTerminalProgress({ TERM_PROGRAM: "ghostty", STY: "1234.pts-0" }), false); + }); +}); + describe("resolveEscapeTimeoutMs", () => { it("defaults to 100ms over SSH", () => { assert.equal(resolveEscapeTimeoutMs({ SSH_CONNECTION: "10.0.0.1 22" }), 100); @@ -225,8 +243,25 @@ describe("ProcessTerminal Kitty keyboard protocol negotiation", () => { }); }); +describe("ProcessTerminal title", () => { + it("writes OSC 0 without allowing title text to terminate or inject control sequences", () => { + const writes: string[] = []; + const previousWrite = process.stdout.write; + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + try { + new ProcessTerminal().setTitle("Fix\nlogin\x07\x1b]2;injected\x9c"); + assert.deepEqual(writes, ["\x1b]0;Fix login]2;injected\x07"]); + } finally { + process.stdout.write = previousWrite; + } + }); +}); + describe("ProcessTerminal progress", () => { - it("writes a valid OSC 9;4 clear sequence", () => { + it("does not emit OSC 9 when progress was never activated", () => { const terminal = new ProcessTerminal(); const writes: string[] = []; const previousWrite = process.stdout.write; @@ -238,11 +273,45 @@ describe("ProcessTerminal progress", () => { try { terminal.setProgress(false); - assert.deepEqual(writes, ["\x1b]9;4;0\x07"]); + terminal.setProgress(false); + assert.deepEqual(writes, []); } finally { process.stdout.write = previousWrite; } }); + + it("clears active progress once and stops its keepalive", () => { + const terminal = new ProcessTerminal(); + const writes: string[] = []; + const previousWrite = process.stdout.write; + mock.timers.enable({ apis: ["setInterval"] }); + + process.stdout.write = ((chunk: string | Uint8Array) => { + writes.push(String(chunk)); + return true; + }) as typeof process.stdout.write; + + try { + terminal.setProgress(true); + mock.timers.tick(1000); + assert.deepEqual(writes, ["\x1b]9;4;3\x07", "\x1b]9;4;3\x07"]); + + terminal.setProgress(false); + terminal.setProgress(false); + mock.timers.tick(3000); + assert.deepEqual(writes, ["\x1b]9;4;3\x07", "\x1b]9;4;3\x07", "\x1b]9;4;0\x07"]); + + // A later operation can activate and clear its own indicator. + terminal.setProgress(true); + terminal.setProgress(false); + mock.timers.tick(3000); + assert.deepEqual(writes.slice(3), ["\x1b]9;4;3\x07", "\x1b]9;4;0\x07"]); + } finally { + terminal.setProgress(false); + process.stdout.write = previousWrite; + mock.timers.reset(); + } + }); }); describe("ProcessTerminal dimensions", () => {