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:
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user