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
This commit is contained in:
Mario Zechner
2026-04-22 11:54:46 +02:00
parent df84e3d22f
commit a900d25119
6 changed files with 62 additions and 28 deletions

View File

@@ -2,6 +2,18 @@
## [Unreleased] ## [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 ## [0.68.1] - 2026-04-22
### New Features ### New Features

View File

@@ -50,6 +50,7 @@ import {
import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js"; import { type AgentSession, type AgentSessionEvent, parseSkillBlock } from "../../core/agent-session.js";
import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js"; import { type AgentSessionRuntime, SessionImportFileNotFoundError } from "../../core/agent-session-runtime.js";
import type { import type {
ExtensionCommandContext,
ExtensionContext, ExtensionContext,
ExtensionRunner, ExtensionRunner,
ExtensionUIContext, ExtensionUIContext,
@@ -307,6 +308,9 @@ export class InteractiveMode {
private options: InteractiveModeOptions = {}, private options: InteractiveModeOptions = {},
) { ) {
this.runtimeHost = runtimeHost; this.runtimeHost = runtimeHost;
this.runtimeHost.setRebindSession(async () => {
await this.rebindCurrentSession();
});
this.version = VERSION; this.version = VERSION;
this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor()); this.ui = new TUI(new ProcessTerminal(), this.settingsManager.getShowHardwareCursor());
this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink()); this.ui.setClearOnShrink(this.settingsManager.getClearOnShrink());
@@ -594,17 +598,11 @@ export class InteractiveMode {
this.isInitialized = true; this.isInitialized = true;
// Initialize extensions first so resources are shown before messages // Initialize extensions first so resources are shown before messages
await this.bindCurrentSessionExtensions(); await this.rebindCurrentSession();
// Render initial messages AFTER showing loaded resources // Render initial messages AFTER showing loaded resources
this.renderInitialMessages(); this.renderInitialMessages();
// Set terminal title
this.updateTerminalTitle();
// Subscribe to agent events
this.subscribeToAgent();
// Set up theme file watcher // Set up theme file watcher
onThemeChange(() => { onThemeChange(() => {
this.ui.invalidate(); this.ui.invalidate();
@@ -1434,7 +1432,6 @@ export class InteractiveMode {
try { try {
const result = await this.runtimeHost.newSession(options); const result = await this.runtimeHost.newSession(options);
if (!result.cancelled) { if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.ui.requestRender(); this.ui.requestRender();
} }
@@ -1447,7 +1444,6 @@ export class InteractiveMode {
try { try {
const result = await this.runtimeHost.fork(entryId, options); const result = await this.runtimeHost.fork(entryId, options);
if (!result.cancelled) { if (!result.cancelled) {
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? ""); this.editor.setText(result.selectedText ?? "");
this.showStatus("Forked to new session"); this.showStatus("Forked to new session");
@@ -1477,9 +1473,8 @@ export class InteractiveMode {
void this.flushCompactionQueue({ willRetry: false }); void this.flushCompactionQueue({ willRetry: false });
return { cancelled: false }; return { cancelled: false };
}, },
switchSession: async (sessionPath) => { switchSession: async (sessionPath, options) => {
await this.handleResumeSession(sessionPath); return this.handleResumeSession(sessionPath, options);
return { cancelled: false };
}, },
reload: async () => { reload: async () => {
await this.handleReloadCommand(); await this.handleReloadCommand();
@@ -1522,7 +1517,7 @@ export class InteractiveMode {
} }
} }
private async handleRuntimeSessionChange(): Promise<void> { private async rebindCurrentSession(): Promise<void> {
this.resetExtensionUI(); this.resetExtensionUI();
this.unsubscribe?.(); this.unsubscribe?.();
this.unsubscribe = undefined; this.unsubscribe = undefined;
@@ -2556,6 +2551,7 @@ export class InteractiveMode {
switch (event.type) { switch (event.type) {
case "agent_start": case "agent_start":
this.ui.terminal.setProgress(true);
// Restore main escape handler if retry handler is still active // Restore main escape handler if retry handler is still active
// (retry success event fires later, but we need main handler now) // (retry success event fires later, but we need main handler now)
if (this.retryEscapeHandler) { if (this.retryEscapeHandler) {
@@ -2737,6 +2733,7 @@ export class InteractiveMode {
} }
case "agent_end": case "agent_end":
this.ui.terminal.setProgress(false);
if (this.loadingAnimation) { if (this.loadingAnimation) {
this.loadingAnimation.stop(); this.loadingAnimation.stop();
this.loadingAnimation = undefined; this.loadingAnimation = undefined;
@@ -2755,6 +2752,7 @@ export class InteractiveMode {
break; break;
case "compaction_start": { case "compaction_start": {
this.ui.terminal.setProgress(true);
// Keep editor active; submissions are queued during compaction. // Keep editor active; submissions are queued during compaction.
this.autoCompactionEscapeHandler = this.defaultEditor.onEscape; this.autoCompactionEscapeHandler = this.defaultEditor.onEscape;
this.defaultEditor.onEscape = () => { this.defaultEditor.onEscape = () => {
@@ -2778,6 +2776,7 @@ export class InteractiveMode {
} }
case "compaction_end": { case "compaction_end": {
this.ui.terminal.setProgress(false);
if (this.autoCompactionEscapeHandler) { if (this.autoCompactionEscapeHandler) {
this.defaultEditor.onEscape = this.autoCompactionEscapeHandler; this.defaultEditor.onEscape = this.autoCompactionEscapeHandler;
this.autoCompactionEscapeHandler = undefined; this.autoCompactionEscapeHandler = undefined;
@@ -4005,7 +4004,6 @@ export class InteractiveMode {
return; return;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.editor.setText(result.selectedText ?? ""); this.editor.setText(result.selectedText ?? "");
done(); done();
@@ -4039,7 +4037,6 @@ export class InteractiveMode {
return; return;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.editor.setText(""); this.editor.setText("");
this.showStatus("Cloned to new session"); this.showStatus("Cloned to new session");
@@ -4212,37 +4209,44 @@ export class InteractiveMode {
}); });
} }
private async handleResumeSession(sessionPath: string): Promise<void> { private async handleResumeSession(
sessionPath: string,
options?: Parameters<ExtensionCommandContext["switchSession"]>[1],
): Promise<{ cancelled: boolean }> {
if (this.loadingAnimation) { if (this.loadingAnimation) {
this.loadingAnimation.stop(); this.loadingAnimation.stop();
this.loadingAnimation = undefined; this.loadingAnimation = undefined;
} }
this.statusContainer.clear(); this.statusContainer.clear();
try { try {
const result = await this.runtimeHost.switchSession(sessionPath); const result = await this.runtimeHost.switchSession(sessionPath, {
withSession: options?.withSession,
});
if (result.cancelled) { if (result.cancelled) {
return; return result;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.showStatus("Resumed session"); this.showStatus("Resumed session");
return result;
} catch (error: unknown) { } catch (error: unknown) {
if (error instanceof MissingSessionCwdError) { if (error instanceof MissingSessionCwdError) {
const selectedCwd = await this.promptForMissingSessionCwd(error); const selectedCwd = await this.promptForMissingSessionCwd(error);
if (!selectedCwd) { if (!selectedCwd) {
this.showStatus("Resume cancelled"); 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) { if (result.cancelled) {
return; return result;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.showStatus("Resumed session in current cwd"); 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"); this.showStatus("Import cancelled");
return; return;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`); this.showStatus(`Session imported from: ${inputPath}`);
} catch (error: unknown) { } catch (error: unknown) {
@@ -4593,7 +4596,6 @@ export class InteractiveMode {
this.showStatus("Import cancelled"); this.showStatus("Import cancelled");
return; return;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.showStatus(`Session imported from: ${inputPath}`); this.showStatus(`Session imported from: ${inputPath}`);
return; return;
@@ -4949,7 +4951,6 @@ export class InteractiveMode {
if (result.cancelled) { if (result.cancelled) {
return; return;
} }
await this.handleRuntimeSessionChange();
this.renderCurrentSessionState(); this.renderCurrentSessionState();
this.chatContainer.addChild(new Spacer(1)); this.chatContainer.addChild(new Spacer(1));
this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1)); this.chatContainer.addChild(new Text(`${theme.fg("accent", "✓ New session started")}`, 1, 1));
@@ -5127,6 +5128,7 @@ export class InteractiveMode {
stop(): void { stop(): void {
this.unregisterSignalHandlers(); this.unregisterSignalHandlers();
this.ui.terminal.setProgress(false);
if (this.loadingAnimation) { if (this.loadingAnimation) {
this.loadingAnimation.stop(); this.loadingAnimation.stop();
this.loadingAnimation = undefined; this.loadingAnimation = undefined;

View File

@@ -27,6 +27,7 @@ class FakeTerminal implements Terminal {
clearFromCursor(): void {} clearFromCursor(): void {}
clearScreen(): void {} clearScreen(): void {}
setTitle(_title: string): void {} setTitle(_title: string): void {}
setProgress(_active: boolean): void {}
get fullClearCount(): number { get fullClearCount(): number {
return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length; return this.writes.filter((write) => write.includes("\x1b[2J\x1b[H\x1b[3J")).length;

View File

@@ -2,6 +2,10 @@
## [Unreleased] ## [Unreleased]
### Added
- Added `setProgress(active: boolean)` to the `Terminal` interface for OSC 9;4 progress indicator support
## [0.68.1] - 2026-04-22 ## [0.68.1] - 2026-04-22
### Fixed ### Fixed

View File

@@ -48,6 +48,9 @@ export interface Terminal {
// Title operations // Title operations
setTitle(title: string): void; // Set terminal window title 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 // OSC 0;title BEL - set terminal window title
process.stdout.write(`\x1b]0;${title}\x07`); 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");
}
}
} }

View File

@@ -100,6 +100,8 @@ export class VirtualTerminal implements Terminal {
this.xterm.write(`\x1b]0;${title}\x07`); this.xterm.write(`\x1b]0;${title}\x07`);
} }
setProgress(_active: boolean): void {}
// Test-specific methods not in Terminal interface // Test-specific methods not in Terminal interface
/** /**