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]
### 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

View File

@@ -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<void> {
private async rebindCurrentSession(): Promise<void> {
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<void> {
private async handleResumeSession(
sessionPath: string,
options?: Parameters<ExtensionCommandContext["switchSession"]>[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;

View File

@@ -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;

View File

@@ -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

View File

@@ -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");
}
}
}

View File

@@ -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
/**