From 40b498ffb673326bbd60315609c179e3a232997a Mon Sep 17 00:00:00 2001 From: Magicien <162632566+lederniermagicien@users.noreply.github.com> Date: Fri, 19 Jun 2026 17:42:35 +0100 Subject: [PATCH] feat(coding-agent): add native terminal progress setting --- packages/coding-agent/CHANGELOG.md | 4 +++ .../src/config/settings-schema.ts | 11 +++++++ .../src/modes/controllers/event-controller.ts | 18 +++++++++++ .../modes/components/settings-layout.test.ts | 10 ++++++ .../event-controller-loader-recovery.test.ts | 32 ++++++++++++++++--- .../test/settings-manager.test.ts | 17 ++++++++++ 6 files changed, 88 insertions(+), 4 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index caafcff95..b23fbc746 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added an Appearance setting for OSC 9;4 native terminal progress indicators during active agent turns and context maintenance. + ## [16.1.3] - 2026-06-19 ### Changed diff --git a/packages/coding-agent/src/config/settings-schema.ts b/packages/coding-agent/src/config/settings-schema.ts index ed1623c68..2431d7735 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -772,6 +772,17 @@ export const SETTINGS_SCHEMA = { "Maximum number of inline images kept as live terminal graphics (default 8). Older images fall back to a text placeholder via a full redraw once the limit is exceeded. Set to 0 to keep every image (no limit).", }, + "terminal.showProgress": { + type: "boolean", + default: false, + ui: { + tab: "appearance", + group: "Display", + label: "Native Terminal Progress", + description: "Emit OSC 9;4 indeterminate progress while the agent or context maintenance is running", + }, + }, + "tui.textSizing": { type: "boolean", default: false, diff --git a/packages/coding-agent/src/modes/controllers/event-controller.ts b/packages/coding-agent/src/modes/controllers/event-controller.ts index b7016eb9c..aa7a7d7aa 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -82,6 +82,7 @@ export class EventController { #streamingReveal: StreamingRevealController; #toolArgsReveal: ToolArgsRevealController; #handlers: AgentSessionEventHandlers; + #terminalProgressActive = false; constructor(private ctx: InteractiveModeContext) { this.#streamingReveal = new StreamingRevealController({ @@ -128,6 +129,7 @@ export class EventController { this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#cancelIdleCompaction(); + this.#setTerminalProgress(false); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); } @@ -241,6 +243,18 @@ export class EventController { await run(event); } + #setTerminalProgress(active: boolean): void { + if (active) { + if (this.#terminalProgressActive || this.ctx.settings?.get("terminal.showProgress") !== true) return; + this.ctx.ui.terminal.setProgress(true); + this.#terminalProgressActive = true; + return; + } + if (!this.#terminalProgressActive) return; + this.ctx.ui.terminal.setProgress(false); + this.#terminalProgressActive = false; + } + async #handleAgentStart(_event: Extract): Promise { this.#lastIntent = undefined; this.#readToolCallArgs.clear(); @@ -258,6 +272,7 @@ export class EventController { this.ctx.statusContainer.clear(); } this.#cancelIdleCompaction(); + this.#setTerminalProgress(true); this.ctx.ensureLoadingAnimation(); this.ctx.ui.requestRender(); } @@ -861,6 +876,7 @@ export class EventController { } async #finishAgentEnd(): Promise { + this.#setTerminalProgress(false); this.#streamingReveal.stop(); this.#toolArgsReveal.flushAll(); if (this.ctx.loadingAnimation) { @@ -924,6 +940,7 @@ export class EventController { event: Extract, ): Promise { this.#cancelIdleCompaction(); + this.#setTerminalProgress(true); this.#stopWorkingLoader(); this.ctx.statusContainer.clear(); const reasonText = @@ -953,6 +970,7 @@ export class EventController { async #handleAutoCompactionEnd(event: Extract): Promise { this.#cancelIdleCompaction(); + this.#setTerminalProgress(false); if (this.ctx.autoCompactionLoader) { this.ctx.autoCompactionLoader.stop(); this.ctx.autoCompactionLoader = undefined; diff --git a/packages/coding-agent/test/modes/components/settings-layout.test.ts b/packages/coding-agent/test/modes/components/settings-layout.test.ts index f9fec2699..7720b5d9c 100644 --- a/packages/coding-agent/test/modes/components/settings-layout.test.ts +++ b/packages/coding-agent/test/modes/components/settings-layout.test.ts @@ -60,6 +60,16 @@ describe("settings layout", () => { } }); + it("exposes native terminal progress in the appearance settings menu", () => { + const def = getSettingsForTab("appearance").find(def => def.path === "terminal.showProgress"); + + expect(def).toMatchObject({ + type: "boolean", + label: "Native Terminal Progress", + group: "Display", + }); + }); + it("hides advisor dependent settings when advisor is disabled", () => { const advisorDependentPaths: SettingPath[] = ["advisor.subagents", "advisor.syncBacklog", "advisor.immuneTurns"]; const advisorDependentPathSet = new Set(advisorDependentPaths); diff --git a/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts b/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts index f880831dd..254cfb58f 100644 --- a/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts +++ b/packages/coding-agent/test/modes/controllers/event-controller-loader-recovery.test.ts @@ -24,7 +24,7 @@ interface FakeWorkingLoader { * kept streaming. The fix tears the working loader down (stop + dereference) so * the next `agent_start` recreates and re-attaches it. */ -function createContext() { +function createContext(options: { terminalProgress?: boolean } = {}) { const streamState = { isStreaming: false }; const children: unknown[] = []; const statusContainer = { @@ -41,9 +41,12 @@ function createContext() { }, }; const workingLoaders: FakeWorkingLoader[] = []; + const setProgress = vi.fn(); const ctx = { isInitialized: true, - settings: { get: () => false }, + settings: { + get: (path: string) => path === "terminal.showProgress" && options.terminalProgress === true, + }, statusLine: { invalidate: vi.fn() }, updateEditorTopBorder: vi.fn(), pendingTools: new Map(), @@ -66,7 +69,7 @@ function createContext() { showError: vi.fn(), editor: { getText: () => "" }, sessionManager: { getSessionName: () => "test-session" }, - ui: { requestRender: vi.fn(), requestComponentRender: vi.fn() }, + ui: { requestRender: vi.fn(), requestComponentRender: vi.fn(), terminal: { setProgress } }, viewSession: { isCompacting: false, getLastAssistantMessage: () => undefined }, session: { get isStreaming() { @@ -83,10 +86,11 @@ function createContext() { ctx.loadingAnimation = working as unknown as typeof ctx.loadingAnimation; statusContainer.addChild(ctx.loadingAnimation); }); - return { ctx, streamState, statusContainer, workingLoaders }; + return { ctx, streamState, statusContainer, workingLoaders, setProgress }; } const AGENT_START = { type: "agent_start" } as unknown as AgentSessionEvent; +const AGENT_END = { type: "agent_end" } as unknown as AgentSessionEvent; const COMPACTION_START = { type: "auto_compaction_start", reason: "overflow", @@ -174,4 +178,24 @@ describe("EventController loader recovery after overflow maintenance", () => { expect(ctx.loadingAnimation).toBeDefined(); expect(statusContainer.children).toContain(ctx.loadingAnimation); }); + + it("mirrors agent and auto-compaction activity to OSC 9;4 when enabled", async () => { + const { ctx, setProgress } = createContext({ terminalProgress: true }); + const controller = new EventController(ctx); + + await controller.handleEvent(AGENT_START); + expect(setProgress).toHaveBeenCalledTimes(1); + expect(setProgress).toHaveBeenLastCalledWith(true); + + await controller.handleEvent(COMPACTION_START); + expect(setProgress).toHaveBeenCalledTimes(1); + + await controller.handleEvent(COMPACTION_END); + expect(setProgress).toHaveBeenCalledTimes(2); + expect(setProgress).toHaveBeenLastCalledWith(false); + + await controller.handleEvent(AGENT_START); + await controller.handleEvent(AGENT_END); + expect(setProgress.mock.calls.map(call => call[0])).toEqual([true, false, true, false]); + }); }); diff --git a/packages/coding-agent/test/settings-manager.test.ts b/packages/coding-agent/test/settings-manager.test.ts index ebde01131..6dacfdad3 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -66,6 +66,12 @@ describe("Settings", () => { expect(settings.get("tui.maxInlineImages")).toBe(8); }); + it("keeps native terminal progress disabled by default", async () => { + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(settings.get("terminal.showProgress")).toBe(false); + expect(getDefault("terminal.showProgress")).toBe(false); + }); + it("keeps the normal startup splash disabled by default", async () => { const settings = await Settings.init({ cwd: projectDir, agentDir }); expect(settings.get("startup.showSplash")).toBe(false); @@ -245,6 +251,17 @@ describe("Settings", () => { expect((savedSettings.modelRoles as { default?: string } | undefined)?.default).toBe("claude-sonnet"); }); + it("persists native terminal progress only after the user changes it", async () => { + const settings = await Settings.init({ cwd: projectDir, agentDir }); + expect(await readSettings()).toEqual({}); + + settings.set("terminal.showProgress", true); + await settings.flush(); + + const savedSettings = await readSettings(); + expect(savedSettings.terminal).toEqual({ showProgress: true }); + }); + it("filters model allow-list and disabled providers by current path prefix", async () => { const workDir = path.join(projectDir, "work", "service"); const privateDir = path.join(projectDir, "private", "app");