Wire EventBus through task runtime so subagent lifecycle and progress events propagate to the TUI. Add SessionObserverRegistry to track active sessions, and a session observer overlay accessible via Ctrl+S that shows a picker of running subagents and a read-only transcript viewer that reads the subagent's session JSONL file to display thinking, text, tool calls, and results. - Add TASK_SUBAGENT_LIFECYCLE_CHANNEL for start/end events - Add SubagentProgressPayload.sessionFile for session file tracking - Pass eventBus through sdk.ts -> main.ts -> InteractiveMode - SessionObserverRegistry with multi-listener onChange pattern - Overlay picker preserves selection position across live refreshes - Viewer renders full transcript from session JSONL (thinking, text, tool calls with smart arg summaries, inline tool results)
494 lines
13 KiB
TypeScript
494 lines
13 KiB
TypeScript
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
|
import * as path from "node:path";
|
|
import {
|
|
type Keybinding,
|
|
type KeybindingDefinitions,
|
|
type KeybindingsConfig,
|
|
type KeyId,
|
|
setKeybindings,
|
|
TUI_KEYBINDINGS,
|
|
KeybindingsManager as TuiKeybindingsManager,
|
|
} from "@oh-my-pi/pi-tui";
|
|
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
|
|
|
/**
|
|
* Application-level keybindings (coding agent specific).
|
|
* Values are always `true` — used for declaration merging.
|
|
*/
|
|
interface AppKeybindings {
|
|
"app.interrupt": true;
|
|
"app.clear": true;
|
|
"app.exit": true;
|
|
"app.suspend": true;
|
|
"app.thinking.cycle": true;
|
|
"app.thinking.toggle": true;
|
|
"app.model.cycleForward": true;
|
|
"app.model.cycleBackward": true;
|
|
"app.model.select": true;
|
|
"app.model.selectTemporary": true;
|
|
"app.tools.expand": true;
|
|
"app.editor.external": true;
|
|
"app.message.followUp": true;
|
|
"app.message.dequeue": true;
|
|
"app.clipboard.pasteImage": true;
|
|
"app.clipboard.copyLine": true;
|
|
"app.clipboard.copyPrompt": true;
|
|
"app.session.new": true;
|
|
"app.session.tree": true;
|
|
"app.session.fork": true;
|
|
"app.session.resume": true;
|
|
"app.session.observe": true;
|
|
"app.session.togglePath": true;
|
|
"app.session.toggleSort": true;
|
|
"app.session.rename": true;
|
|
"app.session.delete": true;
|
|
"app.session.deleteNoninvasive": true;
|
|
"app.tree.foldOrUp": true;
|
|
"app.tree.unfoldOrDown": true;
|
|
"app.plan.toggle": true;
|
|
"app.history.search": true;
|
|
"app.stt.toggle": true;
|
|
}
|
|
|
|
export type AppKeybinding = keyof AppKeybindings;
|
|
|
|
declare module "@oh-my-pi/pi-tui" {
|
|
interface Keybindings extends AppKeybindings {}
|
|
}
|
|
|
|
/**
|
|
* All keybindings definitions: TUI + app-specific.
|
|
*/
|
|
export const KEYBINDINGS = {
|
|
...TUI_KEYBINDINGS,
|
|
"app.interrupt": {
|
|
defaultKeys: "escape",
|
|
description: "Interrupt current operation",
|
|
},
|
|
"app.clear": {
|
|
defaultKeys: "ctrl+c",
|
|
description: "Clear screen or cancel",
|
|
},
|
|
"app.exit": {
|
|
defaultKeys: "ctrl+d",
|
|
description: "Exit application",
|
|
},
|
|
"app.suspend": {
|
|
defaultKeys: "ctrl+z",
|
|
description: "Suspend application",
|
|
},
|
|
"app.thinking.cycle": {
|
|
defaultKeys: "shift+tab",
|
|
description: "Cycle thinking level",
|
|
},
|
|
"app.thinking.toggle": {
|
|
defaultKeys: "ctrl+t",
|
|
description: "Toggle thinking mode",
|
|
},
|
|
"app.model.cycleForward": {
|
|
defaultKeys: "ctrl+p",
|
|
description: "Cycle to next model",
|
|
},
|
|
"app.model.cycleBackward": {
|
|
defaultKeys: "shift+ctrl+p",
|
|
description: "Cycle to previous model",
|
|
},
|
|
"app.model.select": {
|
|
defaultKeys: "ctrl+l",
|
|
description: "Select model",
|
|
},
|
|
"app.model.selectTemporary": {
|
|
defaultKeys: "alt+p",
|
|
description: "Select temporary model for current session",
|
|
},
|
|
"app.tools.expand": {
|
|
defaultKeys: "ctrl+o",
|
|
description: "Expand tools",
|
|
},
|
|
"app.editor.external": {
|
|
defaultKeys: "ctrl+g",
|
|
description: "Open external editor",
|
|
},
|
|
"app.message.followUp": {
|
|
defaultKeys: "ctrl+enter",
|
|
description: "Send follow-up message",
|
|
},
|
|
"app.message.dequeue": {
|
|
defaultKeys: "alt+up",
|
|
description: "Dequeue message",
|
|
},
|
|
"app.clipboard.pasteImage": {
|
|
defaultKeys: process.platform === "win32" ? "alt+v" : "ctrl+v",
|
|
description: "Paste image from clipboard",
|
|
},
|
|
"app.clipboard.copyLine": {
|
|
defaultKeys: "alt+shift+l",
|
|
description: "Copy current line",
|
|
},
|
|
"app.clipboard.copyPrompt": {
|
|
defaultKeys: "alt+shift+c",
|
|
description: "Copy prompt",
|
|
},
|
|
"app.session.new": {
|
|
defaultKeys: [],
|
|
description: "Create new session",
|
|
},
|
|
"app.session.tree": {
|
|
defaultKeys: [],
|
|
description: "Show session tree",
|
|
},
|
|
"app.session.fork": {
|
|
defaultKeys: [],
|
|
description: "Fork session",
|
|
},
|
|
"app.session.resume": {
|
|
defaultKeys: [],
|
|
description: "Resume session",
|
|
},
|
|
"app.session.observe": {
|
|
defaultKeys: "ctrl+s",
|
|
description: "Observe subagent sessions",
|
|
},
|
|
"app.session.togglePath": {
|
|
defaultKeys: "ctrl+p",
|
|
description: "Toggle session path display",
|
|
},
|
|
"app.session.toggleSort": {
|
|
defaultKeys: "ctrl+s",
|
|
description: "Toggle session sort order",
|
|
},
|
|
"app.session.rename": {
|
|
defaultKeys: "ctrl+r",
|
|
description: "Rename session",
|
|
},
|
|
"app.session.delete": {
|
|
defaultKeys: "ctrl+d",
|
|
description: "Delete session",
|
|
},
|
|
"app.session.deleteNoninvasive": {
|
|
defaultKeys: "ctrl+backspace",
|
|
description: "Delete session (non-invasive)",
|
|
},
|
|
"app.tree.foldOrUp": {
|
|
defaultKeys: ["ctrl+left", "alt+left"],
|
|
description: "Fold or move up",
|
|
},
|
|
"app.tree.unfoldOrDown": {
|
|
defaultKeys: ["ctrl+right", "alt+right"],
|
|
description: "Unfold or move down",
|
|
},
|
|
"app.plan.toggle": {
|
|
defaultKeys: "alt+shift+p",
|
|
description: "Toggle plan mode",
|
|
},
|
|
"app.history.search": {
|
|
defaultKeys: "ctrl+r",
|
|
description: "Search history",
|
|
},
|
|
"app.stt.toggle": {
|
|
defaultKeys: "alt+h",
|
|
description: "Toggle speech-to-text",
|
|
},
|
|
} as const satisfies KeybindingDefinitions;
|
|
|
|
/**
|
|
* Migration map from old keybinding names to new namespaced IDs.
|
|
*/
|
|
const KEYBINDING_NAME_MIGRATIONS = {
|
|
// App-specific (old names)
|
|
interrupt: "app.interrupt",
|
|
clear: "app.clear",
|
|
exit: "app.exit",
|
|
suspend: "app.suspend",
|
|
cycleThinkingLevel: "app.thinking.cycle",
|
|
cycleModelForward: "app.model.cycleForward",
|
|
cycleModelBackward: "app.model.cycleBackward",
|
|
selectModel: "app.model.select",
|
|
selectModelTemporary: "app.model.selectTemporary",
|
|
togglePlanMode: "app.plan.toggle",
|
|
historySearch: "app.history.search",
|
|
expandTools: "app.tools.expand",
|
|
toggleThinking: "app.thinking.toggle",
|
|
externalEditor: "app.editor.external",
|
|
followUp: "app.message.followUp",
|
|
dequeue: "app.message.dequeue",
|
|
pasteImage: "app.clipboard.pasteImage",
|
|
copyLine: "app.clipboard.copyLine",
|
|
copyPrompt: "app.clipboard.copyPrompt",
|
|
newSession: "app.session.new",
|
|
tree: "app.session.tree",
|
|
fork: "app.session.fork",
|
|
resume: "app.session.resume",
|
|
observeSessions: "app.session.observe",
|
|
toggleSTT: "app.stt.toggle",
|
|
// TUI editor (old names for backward compatibility)
|
|
cursorUp: "tui.editor.cursorUp",
|
|
cursorDown: "tui.editor.cursorDown",
|
|
cursorLeft: "tui.editor.cursorLeft",
|
|
cursorRight: "tui.editor.cursorRight",
|
|
cursorWordLeft: "tui.editor.cursorWordLeft",
|
|
cursorWordRight: "tui.editor.cursorWordRight",
|
|
cursorLineStart: "tui.editor.cursorLineStart",
|
|
cursorLineEnd: "tui.editor.cursorLineEnd",
|
|
jumpForward: "tui.editor.jumpForward",
|
|
jumpBackward: "tui.editor.jumpBackward",
|
|
pageUp: "tui.editor.pageUp",
|
|
pageDown: "tui.editor.pageDown",
|
|
deleteCharBackward: "tui.editor.deleteCharBackward",
|
|
deleteCharForward: "tui.editor.deleteCharForward",
|
|
deleteWordBackward: "tui.editor.deleteWordBackward",
|
|
deleteWordForward: "tui.editor.deleteWordForward",
|
|
deleteToLineStart: "tui.editor.deleteToLineStart",
|
|
deleteToLineEnd: "tui.editor.deleteToLineEnd",
|
|
yank: "tui.editor.yank",
|
|
yankPop: "tui.editor.yankPop",
|
|
undo: "tui.editor.undo",
|
|
// TUI input (old names for backward compatibility)
|
|
newLine: "tui.input.newLine",
|
|
submit: "tui.input.submit",
|
|
tab: "tui.input.tab",
|
|
copy: "tui.input.copy",
|
|
// TUI select (old names for backward compatibility)
|
|
selectUp: "tui.select.up",
|
|
selectDown: "tui.select.down",
|
|
selectPageUp: "tui.select.pageUp",
|
|
selectPageDown: "tui.select.pageDown",
|
|
selectConfirm: "tui.select.confirm",
|
|
selectCancel: "tui.select.cancel",
|
|
// Upstream additional migrations
|
|
toggleSessionNamedFilter: "app.session.togglePath",
|
|
} as const satisfies Record<string, Keybinding>;
|
|
|
|
/**
|
|
* Check if a key is a legacy keybinding name.
|
|
*/
|
|
function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS {
|
|
return key in KEYBINDING_NAME_MIGRATIONS;
|
|
}
|
|
|
|
function toKeybindingsConfig(value: unknown): KeybindingsConfig {
|
|
if (typeof value !== "object" || value === null) {
|
|
return {};
|
|
}
|
|
|
|
const config: KeybindingsConfig = {};
|
|
for (const [key, val] of Object.entries(value)) {
|
|
if (val === undefined) {
|
|
config[key] = undefined;
|
|
} else if (typeof val === "string") {
|
|
config[key] = val as KeyId;
|
|
} else if (Array.isArray(val) && val.every(v => typeof v === "string")) {
|
|
config[key] = val as KeyId[];
|
|
}
|
|
}
|
|
return config;
|
|
}
|
|
|
|
/**
|
|
* Migrate old keybinding names to new namespaced IDs.
|
|
* Returns both the migrated config and a flag indicating if migration occurred.
|
|
*/
|
|
function migrateKeybindingNames(rawConfig: unknown): {
|
|
config: KeybindingsConfig;
|
|
migrated: boolean;
|
|
} {
|
|
const config = toKeybindingsConfig(rawConfig);
|
|
const migrated: KeybindingsConfig = {};
|
|
let didMigrate = false;
|
|
|
|
for (const [key, value] of Object.entries(config)) {
|
|
if (isLegacyKeybindingName(key)) {
|
|
const newKey = KEYBINDING_NAME_MIGRATIONS[key];
|
|
migrated[newKey] = value;
|
|
didMigrate = true;
|
|
} else {
|
|
// Already a new-style key
|
|
migrated[key] = value;
|
|
}
|
|
}
|
|
|
|
return { config: migrated, migrated: didMigrate };
|
|
}
|
|
|
|
/**
|
|
* Order keybindings config to match KEYBINDINGS key order.
|
|
*/
|
|
function orderKeybindingsConfig(config: KeybindingsConfig): KeybindingsConfig {
|
|
const ordered: KeybindingsConfig = {};
|
|
for (const key of Object.keys(KEYBINDINGS)) {
|
|
const value = config[key];
|
|
if (value !== undefined) {
|
|
ordered[key] = value;
|
|
}
|
|
}
|
|
// Add any remaining keys that aren't in KEYBINDINGS
|
|
for (const key of Object.keys(config)) {
|
|
if (!(key in ordered)) {
|
|
ordered[key] = config[key];
|
|
}
|
|
}
|
|
return ordered;
|
|
}
|
|
|
|
/**
|
|
* Load raw config from a file synchronously.
|
|
* Returns parsed JSON or null if file doesn't exist or is invalid.
|
|
*/
|
|
function loadRawConfig(filePath: string): unknown {
|
|
try {
|
|
if (!existsSync(filePath)) {
|
|
return null;
|
|
}
|
|
const content = readFileSync(filePath, "utf-8");
|
|
return JSON.parse(content);
|
|
} catch (error) {
|
|
if (isEnoent(error)) {
|
|
return null;
|
|
}
|
|
logger.warn("Failed to parse keybindings config", { path: filePath, error: String(error) });
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Migrate keybindings config file from old format to new.
|
|
* Reads from agentDir/keybindings.json, migrates old names, and writes back.
|
|
*/
|
|
function loadKeybindingsConfig(filePath: string, writeBack: boolean): KeybindingsConfig {
|
|
const rawConfig = loadRawConfig(filePath);
|
|
|
|
if (rawConfig === null) {
|
|
return {};
|
|
}
|
|
|
|
const { config: migratedConfig, migrated } = migrateKeybindingNames(rawConfig);
|
|
if (writeBack && migrated) {
|
|
const ordered = orderKeybindingsConfig(migratedConfig);
|
|
try {
|
|
writeFileSync(filePath, `${JSON.stringify(ordered, null, 2)}\n`, "utf-8");
|
|
logger.debug("Migrated keybindings config", { path: filePath });
|
|
} catch (error) {
|
|
logger.warn("Failed to write migrated keybindings config", { path: filePath, error: String(error) });
|
|
}
|
|
}
|
|
|
|
return migratedConfig;
|
|
}
|
|
|
|
function migrateKeybindingsConfigFile(agentDir: string): void {
|
|
const configPath = path.join(agentDir, "keybindings.json");
|
|
loadKeybindingsConfig(configPath, true);
|
|
}
|
|
|
|
/**
|
|
* Manages all keybindings (app + TUI).
|
|
* Extends the TUI KeybindingsManager with app-specific functionality.
|
|
*/
|
|
export class KeybindingsManager extends TuiKeybindingsManager {
|
|
#configPath: string | undefined;
|
|
|
|
constructor(userBindings: KeybindingsConfig = {}, configPath?: string) {
|
|
super(KEYBINDINGS, userBindings);
|
|
this.#configPath = configPath;
|
|
}
|
|
|
|
/**
|
|
* Create from config file at agentDir/keybindings.json.
|
|
*/
|
|
static create(agentDir: string = getAgentDir()): KeybindingsManager {
|
|
const configPath = path.join(agentDir, "keybindings.json");
|
|
const userBindings = KeybindingsManager.#loadFromFile(configPath);
|
|
const manager = new KeybindingsManager(userBindings, configPath);
|
|
// Set globally so getKeybindings() returns this manager
|
|
setKeybindings(manager);
|
|
return manager;
|
|
}
|
|
|
|
/**
|
|
* Create an in-memory keybindings manager without file persistence.
|
|
*/
|
|
static inMemory(userBindings: KeybindingsConfig = {}): KeybindingsManager {
|
|
return new KeybindingsManager(userBindings);
|
|
}
|
|
|
|
/**
|
|
* Reload keybindings from the config file.
|
|
*/
|
|
reload(): void {
|
|
if (!this.#configPath) return;
|
|
this.setUserBindings(KeybindingsManager.#loadFromFile(this.#configPath));
|
|
}
|
|
|
|
/**
|
|
* Get the effective resolved bindings (defaults + user overrides).
|
|
*/
|
|
getEffectiveConfig(): KeybindingsConfig {
|
|
return this.getResolvedBindings();
|
|
}
|
|
|
|
/**
|
|
* Get display string for a keybinding (e.g., "ctrl+c/escape").
|
|
*/
|
|
getDisplayString(keybinding: Keybinding): string {
|
|
const keys = this.getKeys(keybinding);
|
|
return formatKeyHints(keys.length === 0 ? [] : keys);
|
|
}
|
|
|
|
/**
|
|
* Load user bindings from a file, migrating old names if needed.
|
|
*/
|
|
static #loadFromFile(filePath: string): KeybindingsConfig {
|
|
return loadKeybindingsConfig(filePath, true);
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Key hint formatting utilities for UI labels.
|
|
*/
|
|
const MODIFIER_LABELS: Record<string, string> = {
|
|
ctrl: "Ctrl",
|
|
shift: "Shift",
|
|
alt: "Alt",
|
|
};
|
|
|
|
const KEY_LABELS: Record<string, string> = {
|
|
esc: "Esc",
|
|
escape: "Esc",
|
|
enter: "Enter",
|
|
return: "Enter",
|
|
space: "Space",
|
|
tab: "Tab",
|
|
backspace: "Backspace",
|
|
delete: "Delete",
|
|
home: "Home",
|
|
end: "End",
|
|
pageup: "PgUp",
|
|
pagedown: "PgDn",
|
|
up: "Up",
|
|
down: "Down",
|
|
left: "Left",
|
|
right: "Right",
|
|
};
|
|
|
|
function formatKeyPart(part: string): string {
|
|
const lower = part.toLowerCase();
|
|
const modifier = MODIFIER_LABELS[lower];
|
|
if (modifier) return modifier;
|
|
const label = KEY_LABELS[lower];
|
|
if (label) return label;
|
|
if (part.length === 1) return part.toUpperCase();
|
|
return `${part.charAt(0).toUpperCase()}${part.slice(1)}`;
|
|
}
|
|
|
|
export function formatKeyHint(key: KeyId): string {
|
|
return key.split("+").map(formatKeyPart).join("+");
|
|
}
|
|
|
|
export function formatKeyHints(keys: KeyId | KeyId[]): string {
|
|
const list = Array.isArray(keys) ? keys : [keys];
|
|
return list.map(formatKeyHint).join("/");
|
|
}
|
|
|
|
export type { Keybinding, KeybindingsConfig, KeyId };
|
|
export { migrateKeybindingsConfigFile };
|