From a900d25119f1930ca8d91166a1ede085b2a56361 Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Wed, 22 Apr 2026 11:54:46 +0200 Subject: [PATCH] feat(tui,coding-agent): add OSC 9;4 terminal progress indicators Emit OSC 9;4 progress start/end sequences during agent streaming and compaction so terminals (iTerm2, WezTerm, Windows Terminal, Kitty, etc.) show activity in their tab bar. Made with love for @lucasmeijer --- packages/coding-agent/CHANGELOG.md | 12 ++++ .../src/modes/interactive/interactive-mode.ts | 58 ++++++++++--------- .../test/edit-tool-no-full-redraw.test.ts | 1 + packages/tui/CHANGELOG.md | 4 ++ packages/tui/src/terminal.ts | 13 +++++ packages/tui/test/virtual-terminal.ts | 2 + 6 files changed, 62 insertions(+), 28 deletions(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index df709e25..f78a8bf5 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,18 @@ ## [Unreleased] +### Added + +- Added OSC 9;4 terminal progress indicators during agent streaming and compaction, so terminals like iTerm2, WezTerm, Windows Terminal, and Kitty show activity in their tab bar + +### Breaking Changes + +- Session-replacement commands now invalidate captured pre-replacement session-bound extension objects after `ctx.newSession()`, `ctx.fork()`, and `ctx.switchSession()`. Old `pi` and command `ctx` references now throw instead of silently targeting the replaced session. Migration: move post-switch work into `withSession`, and use only the `ReplacedSessionContext` passed to that callback for session-bound operations such as `sendUserMessage()`, `sendMessage()`, and `sessionManager` access. + +### Fixed + +- Fixed extension session-replacement flows so `ctx.newSession()`, `ctx.fork()`, `ctx.switchSession()`, and imported-session replacements fully rebind before post-switch work runs, added `withSession` replacement callbacks with fresh `ReplacedSessionContext` helpers, and make stale pre-replacement `pi` / `ctx` session-bound accesses throw instead of silently targeting the wrong session ([#2860](https://github.com/badlogic/pi-mono/issues/2860)) + ## [0.68.1] - 2026-04-22 ### New Features diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index 0c218d4f..24a700a1 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -50,6 +50,7 @@ import { import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; import type { + ExtensionCommandContext, ExtensionContext, ExtensionRunner, ExtensionUIContext, @@ -307,6 +308,9 @@ export class InteractiveMode { private options: InteractiveModeOptions = {}, ) { this.runtimeHost = runtimeHost; + this.runtimeHost.setRebindSession(async () => { + await this.rebindCurrentSession(); + }); this.version = VERSION; this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); @@ -594,17 +598,11 @@ export class InteractiveMode { this.isInitialized = true; // Initialize extensions first so resources are shown before messages - await this.bindCurrentSessionExtensions(); + await this.rebindCurrentSession(); // Render initial messages AFTER showing loaded resources this.renderInitialMessages(); - // Set terminal title - this.updateTerminalTitle(); - - // Subscribe to agent events - this.subscribeToAgent(); - // Set up theme file watcher onThemeChange(() => { this.ui.invalidate(); @@ -1434,7 +1432,6 @@ export class InteractiveMode { try { const result = await this.runtimeHost.newSession(options); if (!result.cancelled) { - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.ui.requestRender(); } @@ -1447,7 +1444,6 @@ export class InteractiveMode { try { const result = await this.runtimeHost.fork(entryId, options); if (!result.cancelled) { - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.editor.setText(result.selectedText ?? ""); this.showStatus("Forked to new session"); @@ -1477,9 +1473,8 @@ export class InteractiveMode { void this.flushCompactionQueue({ willRetry: false }); return { cancelled: false }; }, - switchSession: async (sessionPath) => { - await this.handleResumeSession(sessionPath); - return { cancelled: false }; + switchSession: async (sessionPath, options) => { + return this.handleResumeSession(sessionPath, options); }, reload: async () => { await this.handleReloadCommand(); @@ -1522,7 +1517,7 @@ export class InteractiveMode { } } - private async handleRuntimeSessionChange(): Promise { + private async rebindCurrentSession(): Promise { this.resetExtensionUI(); this.unsubscribe?.(); this.unsubscribe = undefined; @@ -2556,6 +2551,7 @@ export class InteractiveMode { switch (event.type) { case "agent_start": + this.ui.terminal.setProgress(true); // Restore main escape handler if retry handler is still active // (retry success event fires later, but we need main handler now) if (this.retryEscapeHandler) { @@ -2737,6 +2733,7 @@ export class InteractiveMode { } case "agent_end": + this.ui.terminal.setProgress(false); if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; @@ -2755,6 +2752,7 @@ export class InteractiveMode { break; case "compaction_start": { + this.ui.terminal.setProgress(true); // Keep editor active; submissions are queued during compaction. this.autoCompactionEscapeHandler = this.defaultEditor.onEscape; this.defaultEditor.onEscape = () => { @@ -2778,6 +2776,7 @@ export class InteractiveMode { } case "compaction_end": { + this.ui.terminal.setProgress(false); if (this.autoCompactionEscapeHandler) { this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.autoCompactionEscapeHandler = undefined; @@ -4005,7 +4004,6 @@ export class InteractiveMode { return; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.editor.setText(result.selectedText ?? ""); done(); @@ -4039,7 +4037,6 @@ export class InteractiveMode { return; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.editor.setText(""); this.showStatus("Cloned to new session"); @@ -4212,37 +4209,44 @@ export class InteractiveMode { }); } - private async handleResumeSession(sessionPath: string): Promise { + private async handleResumeSession( + sessionPath: string, + options?: Parameters[1], + ): Promise<{ cancelled: boolean }> { if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; } this.statusContainer.clear(); try { - const result = await this.runtimeHost.switchSession(sessionPath); + const result = await this.runtimeHost.switchSession(sessionPath, { + withSession: options?.withSession, + }); if (result.cancelled) { - return; + return result; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.showStatus("Resumed session"); + return result; } catch (error: unknown) { if (error instanceof MissingSessionCwdError) { const selectedCwd = await this.promptForMissingSessionCwd(error); if (!selectedCwd) { this.showStatus("Resume cancelled"); - return; + return { cancelled: true }; } - const result = await this.runtimeHost.switchSession(sessionPath, selectedCwd); + const result = await this.runtimeHost.switchSession(sessionPath, { + cwdOverride: selectedCwd, + withSession: options?.withSession, + }); if (result.cancelled) { - return; + return result; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.showStatus("Resumed session in current cwd"); - return; + return result; } - await this.handleFatalRuntimeError("Failed to resume session", error); + return this.handleFatalRuntimeError("Failed to resume session", error); } } @@ -4578,7 +4582,6 @@ export class InteractiveMode { this.showStatus("Import cancelled"); return; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.showStatus(`Session imported from: ${inputPath}`); } catch (error: unknown) { @@ -4593,7 +4596,6 @@ export class InteractiveMode { this.showStatus("Import cancelled"); return; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.showStatus(`Session imported from: ${inputPath}`); return; @@ -4949,7 +4951,6 @@ export class InteractiveMode { if (result.cancelled) { return; } - await this.handleRuntimeSessionChange(); this.renderCurrentSessionState(); this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); @@ -5127,6 +5128,7 @@ export class InteractiveMode { stop(): void { this.unregisterSignalHandlers(); + this.ui.terminal.setProgress(false); if (this.loadingAnimation) { this.loadingAnimation.stop(); this.loadingAnimation = undefined; diff --git a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts index e4be669d..47ead7a8 100644 --- a/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts +++ b/packages/coding-agent/test/edit-tool-no-full-redraw.test.ts @@ -27,6 +27,7 @@ class FakeTerminal implements Terminal { clearFromCursor(): void {} clearScreen(): void {} setTitle(_title: string): void {} + setProgress(_active: boolean): void {} get fullClearCount(): number { return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length; diff --git a/packages/tui/CHANGELOG.md b/packages/tui/CHANGELOG.md index 13b434f9..650967dd 100644 --- a/packages/tui/CHANGELOG.md +++ b/packages/tui/CHANGELOG.md @@ -2,6 +2,10 @@ ## [Unreleased] +### Added + +- Added `setProgress(active: boolean)` to the `Terminal` interface for OSC 9;4 progress indicator support + ## [0.68.1] - 2026-04-22 ### Fixed diff --git a/packages/tui/src/terminal.ts b/packages/tui/src/terminal.ts index d98d1954..e7a64dc8 100644 --- a/packages/tui/src/terminal.ts +++ b/packages/tui/src/terminal.ts @@ -48,6 +48,9 @@ export interface Terminal { // Title operations setTitle(title: string): void; // Set terminal window title + + // Progress indicator (OSC 9;4) + setProgress(active: boolean): void; } /** @@ -357,4 +360,14 @@ export class ProcessTerminal implements Terminal { // OSC 0;title BEL - set terminal window title process.stdout.write(`\x1b]0;${title}\x07`); } + + setProgress(active: boolean): void { + if (active) { + // OSC 9;4;1;0 - indeterminate progress + process.stdout.write("\x1b]9;4;1;0\x07"); + } else { + // OSC 9;4;0 - clear progress + process.stdout.write("\x1b]9;4;0;\x07"); + } + } } diff --git a/packages/tui/test/virtual-terminal.ts b/packages/tui/test/virtual-terminal.ts index 60756b74..c7ca4510 100644 --- a/packages/tui/test/virtual-terminal.ts +++ b/packages/tui/test/virtual-terminal.ts @@ -100,6 +100,8 @@ export class VirtualTerminal implements Terminal { this.xterm.write(`\x1b]0;${title}\x07`); } + setProgress(_active: boolean): void {} + // Test-specific methods not in Terminal interface /**