Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 4 additions & 5 deletions apps/cli/src/ui/interactive-mode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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}`);
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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();
Expand Down
13 changes: 5 additions & 8 deletions apps/cli/src/ui/runtime/session-events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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;
Expand All @@ -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;
Expand Down
2 changes: 1 addition & 1 deletion apps/cli/src/ui/view/dialogs/settings-selector.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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"],
});
Expand Down
33 changes: 0 additions & 33 deletions apps/cli/src/ui/view/transcript/step-error-hints.ts

This file was deleted.

7 changes: 0 additions & 7 deletions apps/cli/src/ui/view/transcript/tool-execution.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

Expand Down Expand Up @@ -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, ""] : [];
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,6 @@ exports[`D. 工具执行流快照 > 进行中/成功/失败三态(D1/D6) > t
[
"✗ scan ./src for todos",
" └ boom",
" ↳ 可回复「重试」让模型换一种方式,或补充说明预期结果",
"",
]
`;
Expand Down
28 changes: 28 additions & 0 deletions apps/cli/test/session-events-working-tracker.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
32 changes: 32 additions & 0 deletions apps/cli/test/settings-selector.test.ts
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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(() => {
Expand Down Expand Up @@ -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);
});
});
41 changes: 0 additions & 41 deletions apps/cli/test/step-error-hints.test.ts

This file was deleted.

58 changes: 58 additions & 0 deletions apps/cli/test/terminal-session-title.test.ts
Original file line number Diff line number Diff line change
@@ -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 <name>");
h.mode.handleNameCommand("/name 人工名称");
expect(h.sessionManager.getSessionName()).toBe("人工名称");
expect(h.mode.chatContainer.render(80).join("\n")).toContain("Session name set: 人工名称");
});
});
4 changes: 2 additions & 2 deletions apps/cli/test/tui-acceptance-snapshot.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
});
});
Expand Down
2 changes: 2 additions & 0 deletions packages/coding-agent/docs/session-format.md
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
3 changes: 3 additions & 0 deletions packages/coding-agent/docs/settings.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 |
Expand Down
5 changes: 3 additions & 2 deletions packages/coding-agent/src/core/settings-manager.ts
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -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";
Expand Down Expand Up @@ -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 {
Expand Down
Loading
Loading