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