diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 7db02602d..1da2da85f 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -415,6 +415,10 @@ - Fixed image paste placeholders falling through to terminal hyperlink settings before `Settings.init()`, so early editor rendering falls back to plain text instead of crashing. ([#3064](https://github.com/can1357/oh-my-pi/issues/3064)) - Fixed `omp plugin install github:owner/repo` silently keeping the user on a stale commit when re-run on an already-installed GitHub plugin. `bun install ` respects the existing `bun.lock` pin when the spec is unchanged and never re-resolves the remote ref, so the manager now follows a git re-install with `bun update ` to refresh the lockfile pin against the upstream. The install transaction also snapshots `bun.lock` up front and routes feature validation, extension validation, and runtime-config save through one rollback path so a failed install can never leave the rejected commit pinned in the active tree or lockfile. First-time installs are unaffected. ([#3063](https://github.com/can1357/oh-my-pi/issues/3063)) +### 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 c70287524..768910620 100644 --- a/packages/coding-agent/src/config/settings-schema.ts +++ b/packages/coding-agent/src/config/settings-schema.ts @@ -778,6 +778,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 3026277f4..5b66804a7 100644 --- a/packages/coding-agent/src/modes/controllers/event-controller.ts +++ b/packages/coding-agent/src/modes/controllers/event-controller.ts @@ -88,6 +88,7 @@ export class EventController { #toolArgsReveal: ToolArgsRevealController; #prevHideThinking = false; #handlers: AgentSessionEventHandlers; + #terminalProgressActive = false; constructor(private ctx: InteractiveModeContext) { this.#streamingReveal = new StreamingRevealController({ @@ -155,6 +156,7 @@ export class EventController { this.#streamingReveal.stop(); this.#toolArgsReveal.stop(); this.#cancelIdleCompaction(); + this.#setTerminalProgress(false); for (const timer of this.#ircExpiryTimers.values()) { clearTimeout(timer); } @@ -269,6 +271,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(); @@ -287,6 +301,7 @@ export class EventController { this.ctx.statusContainer.clear(); } this.#cancelIdleCompaction(); + this.#setTerminalProgress(true); this.ctx.ensureLoadingAnimation(); this.ctx.ui.requestRender(); } @@ -954,6 +969,7 @@ export class EventController { } async #finishAgentEnd(): Promise { + this.#setTerminalProgress(false); this.#streamingReveal.stop(); this.#toolArgsReveal.flushAll(); if (this.ctx.loadingAnimation) { @@ -1040,6 +1056,7 @@ export class EventController { event: Extract, ): Promise { this.#cancelIdleCompaction(); + this.#setTerminalProgress(true); this.#stopWorkingLoader(); this.ctx.statusContainer.clear(); const reasonText = @@ -1071,6 +1088,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 8241fb9fc..b70fe1420 100644 --- a/packages/coding-agent/test/settings-manager.test.ts +++ b/packages/coding-agent/test/settings-manager.test.ts @@ -60,6 +60,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");