fix(coding-agent): backported pi-mono changes (1feccfed..b21b42d0)
packages/ai: - feat: expose provider responseId on AssistantMessage - feat: lazy-load provider modules for faster startup - fix: hash foreign Responses API tool call IDs exceeding 64-char limit - fix: ignore null chunks in openai-completions streams - fix: keep image tool results inline for Gemini 3+ and Antigravity - fix: correct Bedrock Claude 4.6 context window to 200k - fix: support prompt caching for Bedrock application inference profiles - fix: add OpenRouter reasoning payload format - fix: ignore placeholder Vertex API keys - fix: skip AJV validation in restricted runtimes - fix: Anthropic OAuth client injection and responseId extraction - fix: Codex incomplete/failed response status handling packages/agent: - fix: defer steering until after tool execution completes packages/tui: - feat: namespaced keybinding IDs with KeybindingsManager conflict detection - feat: configurable select list column sizing (#2154 by @markusylisiurunen) - fix: stream truncateToWidth for large strings - fix: skip Termux height redraws - fix: stop evicting unrelated default keybindings - fix: resolve raw backspace ambiguity on Windows Terminal - fix: clear stale scrollback on session switch (#2155 by @Perlence) - fix: remove trailing markdown block spacing (#2152 by @markusylisiurunen) packages/coding-agent: - feat: add resizable share sidebar (#2435 by @dmmulroy) - feat: emit OSC 133 command-executed marker - feat: reload custom themes from disk watcher - feat: add --fork session flag - feat: file mutation queue for serialized writes - feat: initial message consolidation utility - fix: keybindings migrated to namespaced IDs - fix: resolve waitForRetry() race when auto-retry produces tool calls - fix: handle slash-delimited /model refs - fix: refresh active model after provider updates - fix: extended transient error patterns for retry
This commit is contained in:
@@ -28,6 +28,7 @@ export interface Args {
|
||||
mode?: Mode;
|
||||
noSession?: boolean;
|
||||
sessionDir?: string;
|
||||
fork?: string;
|
||||
models?: string[];
|
||||
tools?: string[];
|
||||
noTools?: boolean;
|
||||
@@ -79,6 +80,8 @@ export function parseArgs(args: string[], extensionFlags?: Map<string, { type: "
|
||||
} else {
|
||||
result.resume = true;
|
||||
}
|
||||
} else if (arg === "--fork" && i + 1 < args.length) {
|
||||
result.fork = args[++i];
|
||||
} else if (arg === "--provider" && i + 1 < args.length) {
|
||||
result.provider = args[++i];
|
||||
} else if (arg === "--model" && i + 1 < args.length) {
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
import type { ImageContent } from "@oh-my-pi/pi-ai";
|
||||
import type { Args } from "./args";
|
||||
|
||||
export interface InitialMessageInput {
|
||||
parsed: Args;
|
||||
fileText?: string;
|
||||
fileImages?: ImageContent[];
|
||||
stdinContent?: string;
|
||||
}
|
||||
|
||||
export interface InitialMessageResult {
|
||||
initialMessage?: string;
|
||||
initialImages?: ImageContent[];
|
||||
}
|
||||
|
||||
/**
|
||||
* Combine stdin content, @file text, and the first CLI message into a single
|
||||
* initial prompt for non-interactive mode.
|
||||
*/
|
||||
export function buildInitialMessage({
|
||||
parsed,
|
||||
fileText,
|
||||
fileImages,
|
||||
stdinContent,
|
||||
}: InitialMessageInput): InitialMessageResult {
|
||||
const hasInitialContext = stdinContent !== undefined || fileText !== undefined || (fileImages?.length ?? 0) > 0;
|
||||
if (!hasInitialContext) {
|
||||
return {
|
||||
initialImages: undefined,
|
||||
};
|
||||
}
|
||||
|
||||
let body = "";
|
||||
if (fileText !== undefined) {
|
||||
body += fileText;
|
||||
}
|
||||
|
||||
if (parsed.messages.length > 0) {
|
||||
body += parsed.messages[0];
|
||||
parsed.messages.shift();
|
||||
}
|
||||
|
||||
const initialMessage =
|
||||
stdinContent !== undefined
|
||||
? body.length > 0
|
||||
? `${stdinContent}\n${body}`
|
||||
: stdinContent
|
||||
: body.length > 0
|
||||
? body
|
||||
: fileImages && fileImages.length > 0
|
||||
? ""
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
initialMessage,
|
||||
initialImages: fileImages && fileImages.length > 0 ? fileImages : undefined,
|
||||
};
|
||||
}
|
||||
@@ -1,120 +1,438 @@
|
||||
import { existsSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import * as path from "node:path";
|
||||
import {
|
||||
DEFAULT_EDITOR_KEYBINDINGS,
|
||||
type EditorAction,
|
||||
type EditorKeybindingsConfig,
|
||||
EditorKeybindingsManager,
|
||||
type Keybinding,
|
||||
type KeybindingDefinitions,
|
||||
type KeybindingsConfig,
|
||||
type KeyId,
|
||||
matchesKey,
|
||||
setEditorKeybindings,
|
||||
setKeybindings,
|
||||
TUI_KEYBINDINGS,
|
||||
KeybindingsManager as TuiKeybindingsManager,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { getAgentDir, isEnoent, logger } from "@oh-my-pi/pi-utils";
|
||||
|
||||
/**
|
||||
* Application-level actions (coding agent specific).
|
||||
* Application-level keybindings (coding agent specific).
|
||||
* Values are always `true` — used for declaration merging.
|
||||
*/
|
||||
export type AppAction =
|
||||
| "interrupt"
|
||||
| "clear"
|
||||
| "exit"
|
||||
| "suspend"
|
||||
| "cycleThinkingLevel"
|
||||
| "cycleModelForward"
|
||||
| "cycleModelBackward"
|
||||
| "selectModel"
|
||||
| "togglePlanMode"
|
||||
| "expandTools"
|
||||
| "toggleThinking"
|
||||
| "externalEditor"
|
||||
| "historySearch"
|
||||
| "followUp"
|
||||
| "dequeue"
|
||||
| "pasteImage"
|
||||
| "copyLine"
|
||||
| "copyPrompt"
|
||||
| "newSession"
|
||||
| "tree"
|
||||
| "fork"
|
||||
| "resume"
|
||||
| "toggleSTT";
|
||||
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.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.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 configurable actions.
|
||||
* All keybindings definitions: TUI + app-specific.
|
||||
*/
|
||||
export type KeyAction = AppAction | EditorAction;
|
||||
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.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.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;
|
||||
|
||||
/**
|
||||
* Full keybindings configuration (app + editor actions).
|
||||
* Migration map from old keybinding names to new namespaced IDs.
|
||||
*/
|
||||
export type KeybindingsConfig = {
|
||||
[K in KeyAction]?: KeyId | KeyId[];
|
||||
};
|
||||
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",
|
||||
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",
|
||||
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>;
|
||||
|
||||
/**
|
||||
* Default application keybindings.
|
||||
* Check if a key is a legacy keybinding name.
|
||||
*/
|
||||
export const DEFAULT_APP_KEYBINDINGS: Record<AppAction, KeyId | KeyId[]> = {
|
||||
interrupt: "escape",
|
||||
clear: "ctrl+c",
|
||||
exit: "ctrl+d",
|
||||
suspend: "ctrl+z",
|
||||
cycleThinkingLevel: "shift+tab",
|
||||
cycleModelForward: "ctrl+p",
|
||||
cycleModelBackward: "shift+ctrl+p",
|
||||
selectModel: "ctrl+l",
|
||||
togglePlanMode: "alt+shift+p",
|
||||
historySearch: "ctrl+r",
|
||||
expandTools: "ctrl+o",
|
||||
toggleThinking: "ctrl+t",
|
||||
externalEditor: "ctrl+g",
|
||||
followUp: "ctrl+enter",
|
||||
dequeue: "alt+up",
|
||||
pasteImage: "ctrl+v",
|
||||
copyLine: "alt+shift+l",
|
||||
copyPrompt: "alt+shift+c",
|
||||
newSession: [],
|
||||
tree: [],
|
||||
fork: [],
|
||||
resume: [],
|
||||
toggleSTT: "alt+h",
|
||||
};
|
||||
function isLegacyKeybindingName(key: string): key is keyof typeof KEYBINDING_NAME_MIGRATIONS {
|
||||
return key in KEYBINDING_NAME_MIGRATIONS;
|
||||
}
|
||||
|
||||
/**
|
||||
* All default keybindings (app + editor).
|
||||
* Normalize input to KeybindingsConfig, validating types.
|
||||
*/
|
||||
export const DEFAULT_KEYBINDINGS: Required<KeybindingsConfig> = {
|
||||
...DEFAULT_EDITOR_KEYBINDINGS,
|
||||
...DEFAULT_APP_KEYBINDINGS,
|
||||
};
|
||||
function toKeybindingsConfig(value: unknown): KeybindingsConfig {
|
||||
if (typeof value !== "object" || value === null) {
|
||||
return {};
|
||||
}
|
||||
|
||||
// App actions list for type checking
|
||||
const APP_ACTIONS: AppAction[] = [
|
||||
"interrupt",
|
||||
"clear",
|
||||
"exit",
|
||||
"suspend",
|
||||
"cycleThinkingLevel",
|
||||
"cycleModelForward",
|
||||
"cycleModelBackward",
|
||||
"selectModel",
|
||||
"togglePlanMode",
|
||||
"historySearch",
|
||||
"expandTools",
|
||||
"toggleThinking",
|
||||
"externalEditor",
|
||||
"followUp",
|
||||
"dequeue",
|
||||
"pasteImage",
|
||||
"copyLine",
|
||||
"copyPrompt",
|
||||
"newSession",
|
||||
"tree",
|
||||
"fork",
|
||||
"resume",
|
||||
"toggleSTT",
|
||||
];
|
||||
const config: KeybindingsConfig = {};
|
||||
for (const [key, val] of Object.entries(value)) {
|
||||
// Allow undefined, string (KeyId), or array of strings
|
||||
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 string[] as KeyId[];
|
||||
}
|
||||
// Silently skip invalid entries
|
||||
}
|
||||
return config;
|
||||
}
|
||||
|
||||
function isAppAction(action: string): action is AppAction {
|
||||
return APP_ACTIONS.includes(action as AppAction);
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -145,8 +463,6 @@ const KEY_LABELS: Record<string, string> = {
|
||||
right: "Right",
|
||||
};
|
||||
|
||||
const normalizeKeyId = (key: KeyId): KeyId => key.toLowerCase() as KeyId;
|
||||
|
||||
function formatKeyPart(part: string): string {
|
||||
const lower = part.toLowerCase();
|
||||
const modifier = MODIFIER_LABELS[lower];
|
||||
@@ -166,116 +482,5 @@ export function formatKeyHints(keys: KeyId | KeyId[]): string {
|
||||
return list.map(formatKeyHint).join("/");
|
||||
}
|
||||
|
||||
/**
|
||||
* Manages all keybindings (app + editor).
|
||||
*/
|
||||
export class KeybindingsManager {
|
||||
#appActionToKeys: Map<AppAction, KeyId[]>;
|
||||
|
||||
private constructor(private readonly config: KeybindingsConfig) {
|
||||
this.#appActionToKeys = new Map();
|
||||
this.#buildMaps();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create from config file and set up editor keybindings.
|
||||
*/
|
||||
static async create(agentDir: string = getAgentDir()): Promise<KeybindingsManager> {
|
||||
const configPath = path.join(agentDir, "keybindings.json");
|
||||
const config = await KeybindingsManager.#loadFromFile(configPath);
|
||||
const manager = new KeybindingsManager(config);
|
||||
|
||||
// Set up editor keybindings globally
|
||||
const editorConfig: EditorKeybindingsConfig = {};
|
||||
for (const [action, keys] of Object.entries(config)) {
|
||||
if (!isAppAction(action)) {
|
||||
editorConfig[action as EditorAction] = keys;
|
||||
}
|
||||
}
|
||||
setEditorKeybindings(new EditorKeybindingsManager(editorConfig));
|
||||
|
||||
return manager;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create in-memory.
|
||||
*/
|
||||
static inMemory(config: KeybindingsConfig = {}): KeybindingsManager {
|
||||
return new KeybindingsManager(config);
|
||||
}
|
||||
|
||||
static async #loadFromFile(path: string): Promise<KeybindingsConfig> {
|
||||
try {
|
||||
return await Bun.file(path).json();
|
||||
} catch (error) {
|
||||
if (isEnoent(error)) return {};
|
||||
logger.warn("Failed to parse keybindings config", { path, error: String(error) });
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
#buildMaps(): void {
|
||||
this.#appActionToKeys.clear();
|
||||
|
||||
// Set defaults for app actions
|
||||
for (const [action, keys] of Object.entries(DEFAULT_APP_KEYBINDINGS)) {
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
this.#appActionToKeys.set(
|
||||
action as AppAction,
|
||||
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
||||
);
|
||||
}
|
||||
|
||||
// Override with user config (app actions only)
|
||||
for (const [action, keys] of Object.entries(this.config)) {
|
||||
if (keys === undefined || !isAppAction(action)) continue;
|
||||
const keyArray = Array.isArray(keys) ? keys : [keys];
|
||||
this.#appActionToKeys.set(
|
||||
action,
|
||||
keyArray.map(key => normalizeKeyId(key as KeyId)),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if input matches an app action.
|
||||
*/
|
||||
matches(data: string, action: AppAction): boolean {
|
||||
const keys = this.#appActionToKeys.get(action);
|
||||
if (!keys) return false;
|
||||
for (const key of keys) {
|
||||
if (matchesKey(data, key)) return true;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get keys bound to an app action.
|
||||
*/
|
||||
getKeys(action: AppAction): KeyId[] {
|
||||
return this.#appActionToKeys.get(action) ?? [];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for an action.
|
||||
*/
|
||||
getDisplayString(action: AppAction): string {
|
||||
return formatKeyHints(this.getKeys(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the full effective config.
|
||||
*/
|
||||
getEffectiveConfig(): Required<KeybindingsConfig> {
|
||||
const result = { ...DEFAULT_KEYBINDINGS };
|
||||
for (const [action, keys] of Object.entries(this.config)) {
|
||||
if (keys !== undefined) {
|
||||
(result as KeybindingsConfig)[action as KeyAction] = keys;
|
||||
}
|
||||
}
|
||||
return result;
|
||||
}
|
||||
}
|
||||
|
||||
// Re-export for convenience
|
||||
export type { EditorAction, KeyId };
|
||||
export type { Keybinding, KeybindingsConfig, KeyId };
|
||||
export { migrateKeybindingsConfigFile };
|
||||
|
||||
@@ -90,6 +90,7 @@ const OpenAICompatSchema = Type.Object({
|
||||
thinkingFormat: Type.Optional(
|
||||
Type.Union([
|
||||
Type.Literal("openai"),
|
||||
Type.Literal("openrouter"),
|
||||
Type.Literal("zai"),
|
||||
Type.Literal("qwen"),
|
||||
Type.Literal("qwen-chat-template"),
|
||||
|
||||
@@ -141,6 +141,55 @@ function isAlias(id: string): boolean {
|
||||
return !datePattern.test(id);
|
||||
}
|
||||
|
||||
/**
|
||||
* Find an exact model reference match.
|
||||
* Supports either a bare model id or a canonical provider/modelId reference.
|
||||
* When matching by bare id, ambiguous matches across providers are rejected.
|
||||
*/
|
||||
export function findExactModelReferenceMatch(
|
||||
modelReference: string,
|
||||
availableModels: Model<Api>[],
|
||||
): Model<Api> | undefined {
|
||||
const trimmedReference = modelReference.trim();
|
||||
if (!trimmedReference) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const normalizedReference = trimmedReference.toLowerCase();
|
||||
|
||||
const canonicalMatches = availableModels.filter(
|
||||
model => `${model.provider}/${model.id}`.toLowerCase() === normalizedReference,
|
||||
);
|
||||
if (canonicalMatches.length === 1) {
|
||||
return canonicalMatches[0];
|
||||
}
|
||||
if (canonicalMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const slashIndex = trimmedReference.indexOf("/");
|
||||
if (slashIndex !== -1) {
|
||||
const provider = trimmedReference.substring(0, slashIndex).trim();
|
||||
const modelId = trimmedReference.substring(slashIndex + 1).trim();
|
||||
if (provider && modelId) {
|
||||
const providerMatches = availableModels.filter(
|
||||
model =>
|
||||
model.provider.toLowerCase() === provider.toLowerCase() &&
|
||||
model.id.toLowerCase() === modelId.toLowerCase(),
|
||||
);
|
||||
if (providerMatches.length === 1) {
|
||||
return providerMatches[0];
|
||||
}
|
||||
if (providerMatches.length > 1) {
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const idMatches = availableModels.filter(model => model.id.toLowerCase() === normalizedReference);
|
||||
return idMatches.length === 1 ? idMatches[0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Try to match a pattern to a model from the available models list.
|
||||
* Returns the matched model or undefined if no match found.
|
||||
@@ -150,17 +199,17 @@ function tryMatchModel(
|
||||
availableModels: Model<Api>[],
|
||||
context: ModelPreferenceContext,
|
||||
): Model<Api> | undefined {
|
||||
// Check for provider/modelId format (provider is everything before the first /)
|
||||
// Try exact reference match first (handles provider/modelId and bare id with ambiguity rejection)
|
||||
const exactRefMatch = findExactModelReferenceMatch(modelPattern, availableModels);
|
||||
if (exactRefMatch) {
|
||||
return exactRefMatch;
|
||||
}
|
||||
|
||||
// Check for provider/modelId format — fuzzy match within provider
|
||||
const slashIndex = modelPattern.indexOf("/");
|
||||
if (slashIndex !== -1) {
|
||||
const provider = modelPattern.substring(0, slashIndex);
|
||||
const modelId = modelPattern.substring(slashIndex + 1);
|
||||
const providerMatch = availableModels.find(
|
||||
m => m.provider.toLowerCase() === provider.toLowerCase() && m.id.toLowerCase() === modelId.toLowerCase(),
|
||||
);
|
||||
if (providerMatch) {
|
||||
return providerMatch;
|
||||
}
|
||||
|
||||
const providerModels = availableModels.filter(m => m.provider.toLowerCase() === provider.toLowerCase());
|
||||
if (providerModels.length > 0) {
|
||||
@@ -187,10 +236,9 @@ function tryMatchModel(
|
||||
return scored[0]?.model;
|
||||
}
|
||||
}
|
||||
// No exact provider/model match - fall through to other matching
|
||||
}
|
||||
|
||||
// Check for exact ID match (case-insensitive)
|
||||
// Exact ID match (case-insensitive) — with ambiguity across providers handled by preference
|
||||
const exactMatches = availableModels.filter(m => m.id.toLowerCase() === modelPattern.toLowerCase());
|
||||
if (exactMatches.length > 0) {
|
||||
return pickPreferredModel(exactMatches, context);
|
||||
|
||||
@@ -2,6 +2,10 @@
|
||||
|
||||
:root {
|
||||
--line-height: 18px; /* 12px font * 1.5 */
|
||||
--sidebar-width: 400px;
|
||||
--sidebar-min-width: 240px;
|
||||
--sidebar-max-width: 840px;
|
||||
--sidebar-resizer-width: 6px;
|
||||
}
|
||||
|
||||
body {
|
||||
@@ -12,6 +16,11 @@
|
||||
background: var(--body-bg);
|
||||
}
|
||||
|
||||
body.sidebar-resizing {
|
||||
cursor: col-resize;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
#app {
|
||||
display: flex;
|
||||
min-height: 100vh;
|
||||
@@ -19,7 +28,9 @@
|
||||
|
||||
/* Sidebar */
|
||||
#sidebar {
|
||||
width: 400px;
|
||||
width: var(--sidebar-width);
|
||||
min-width: var(--sidebar-width);
|
||||
max-width: var(--sidebar-width);
|
||||
background: var(--container-bg);
|
||||
flex-shrink: 0;
|
||||
display: flex;
|
||||
@@ -203,8 +214,28 @@
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
#sidebar-resizer {
|
||||
width: var(--sidebar-resizer-width);
|
||||
flex-shrink: 0;
|
||||
position: sticky;
|
||||
top: 0;
|
||||
height: 100vh;
|
||||
cursor: col-resize;
|
||||
touch-action: none;
|
||||
background: transparent;
|
||||
border-right: 1px solid transparent;
|
||||
}
|
||||
|
||||
#sidebar-resizer:hover,
|
||||
body.sidebar-resizing #sidebar-resizer {
|
||||
background: var(--selectedBg);
|
||||
border-right-color: var(--dim);
|
||||
}
|
||||
|
||||
/* Main content */
|
||||
#content {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: var(--line-height) calc(var(--line-height) * 2);
|
||||
@@ -841,17 +872,19 @@
|
||||
@media (max-width: 900px) {
|
||||
#sidebar {
|
||||
position: fixed;
|
||||
left: -400px;
|
||||
width: 400px;
|
||||
transform: translateX(-100%);
|
||||
width: min(var(--sidebar-width), 100vw);
|
||||
min-width: 0;
|
||||
max-width: 100vw;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
height: 100vh;
|
||||
z-index: 99;
|
||||
transition: left 0.3s;
|
||||
transition: transform 0.3s;
|
||||
}
|
||||
|
||||
#sidebar.open {
|
||||
left: 0;
|
||||
transform: translateX(0);
|
||||
}
|
||||
|
||||
#sidebar-overlay.open {
|
||||
@@ -866,6 +899,10 @@
|
||||
display: block;
|
||||
}
|
||||
|
||||
#sidebar-resizer {
|
||||
display: none;
|
||||
}
|
||||
|
||||
#content {
|
||||
padding: var(--line-height) 16px;
|
||||
}
|
||||
@@ -875,15 +912,8 @@
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 500px) {
|
||||
#sidebar {
|
||||
width: 100vw;
|
||||
left: -100vw;
|
||||
}
|
||||
}
|
||||
|
||||
@media print {
|
||||
#sidebar, #sidebar-toggle { display: none !important; }
|
||||
#sidebar, #sidebar-toggle, #sidebar-resizer { display: none !important; }
|
||||
body { background: white; color: black; }
|
||||
#content { max-width: none; }
|
||||
}
|
||||
|
||||
File diff suppressed because one or more lines are too long
@@ -28,6 +28,7 @@
|
||||
<div class="tree-container" id="tree-container"></div>
|
||||
<div class="tree-status" id="tree-status"></div>
|
||||
</aside>
|
||||
<div id="sidebar-resizer" role="separator" aria-orientation="vertical" aria-label="Resize session tree sidebar"></div>
|
||||
<main id="content">
|
||||
<div id="header-container"></div>
|
||||
<div id="messages"></div>
|
||||
|
||||
@@ -1279,6 +1279,113 @@
|
||||
const sidebar = document.getElementById('sidebar');
|
||||
const overlay = document.getElementById('sidebar-overlay');
|
||||
const hamburger = document.getElementById('hamburger');
|
||||
const sidebarResizer = document.getElementById('sidebar-resizer');
|
||||
const SIDEBAR_WIDTH_STORAGE_KEY = 'pi-share:v1:sidebar-width';
|
||||
const MIN_CONTENT_WIDTH = 320;
|
||||
|
||||
function isMobileLayout() {
|
||||
return window.matchMedia('(max-width: 900px)').matches;
|
||||
}
|
||||
|
||||
function getSidebarBounds() {
|
||||
const rootStyles = getComputedStyle(document.documentElement);
|
||||
const minWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-min-width')) || 240;
|
||||
const maxWidth = parseFloat(rootStyles.getPropertyValue('--sidebar-max-width')) || 720;
|
||||
const viewportMaxWidth = window.innerWidth - MIN_CONTENT_WIDTH;
|
||||
return {
|
||||
minWidth,
|
||||
maxWidth: Math.max(minWidth, Math.min(maxWidth, viewportMaxWidth))
|
||||
};
|
||||
}
|
||||
|
||||
function clampSidebarWidth(width) {
|
||||
const { minWidth, maxWidth } = getSidebarBounds();
|
||||
return Math.max(minWidth, Math.min(maxWidth, width));
|
||||
}
|
||||
|
||||
function applySidebarWidth(width) {
|
||||
document.documentElement.style.setProperty('--sidebar-width', `${Math.round(clampSidebarWidth(width))}px`);
|
||||
}
|
||||
|
||||
function loadSidebarWidth() {
|
||||
try {
|
||||
const raw = localStorage.getItem(SIDEBAR_WIDTH_STORAGE_KEY);
|
||||
if (raw === null) return null;
|
||||
const width = Number(raw);
|
||||
return Number.isFinite(width) ? width : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function saveSidebarWidth(width) {
|
||||
try {
|
||||
localStorage.setItem(SIDEBAR_WIDTH_STORAGE_KEY, String(Math.round(clampSidebarWidth(width))));
|
||||
} catch {
|
||||
// Ignore storage failures (e.g. private browsing restrictions)
|
||||
}
|
||||
}
|
||||
|
||||
function setupSidebarResize() {
|
||||
const savedWidth = loadSidebarWidth();
|
||||
if (savedWidth !== null) {
|
||||
applySidebarWidth(savedWidth);
|
||||
}
|
||||
|
||||
if (!sidebarResizer) return;
|
||||
|
||||
let cleanupDrag = null;
|
||||
|
||||
const stopDrag = (pointerId) => {
|
||||
if (cleanupDrag) {
|
||||
cleanupDrag(pointerId);
|
||||
cleanupDrag = null;
|
||||
}
|
||||
};
|
||||
|
||||
sidebarResizer.addEventListener('pointerdown', (e) => {
|
||||
if (isMobileLayout()) return;
|
||||
|
||||
e.preventDefault();
|
||||
const startX = e.clientX;
|
||||
const startWidth = sidebar.getBoundingClientRect().width;
|
||||
document.body.classList.add('sidebar-resizing');
|
||||
sidebarResizer.setPointerCapture?.(e.pointerId);
|
||||
|
||||
const onPointerMove = (event) => {
|
||||
applySidebarWidth(startWidth + (event.clientX - startX));
|
||||
};
|
||||
|
||||
cleanupDrag = (pointerIdToRelease) => {
|
||||
document.body.classList.remove('sidebar-resizing');
|
||||
sidebarResizer.releasePointerCapture?.(pointerIdToRelease);
|
||||
window.removeEventListener('pointermove', onPointerMove);
|
||||
window.removeEventListener('pointerup', onPointerUp);
|
||||
window.removeEventListener('pointercancel', onPointerCancel);
|
||||
saveSidebarWidth(sidebar.getBoundingClientRect().width);
|
||||
};
|
||||
|
||||
const onPointerUp = (event) => stopDrag(event.pointerId);
|
||||
const onPointerCancel = (event) => stopDrag(event.pointerId);
|
||||
|
||||
window.addEventListener('pointermove', onPointerMove);
|
||||
window.addEventListener('pointerup', onPointerUp);
|
||||
window.addEventListener('pointercancel', onPointerCancel);
|
||||
});
|
||||
|
||||
sidebarResizer.addEventListener('dblclick', () => {
|
||||
if (isMobileLayout()) return;
|
||||
applySidebarWidth(400);
|
||||
saveSidebarWidth(400);
|
||||
});
|
||||
|
||||
window.addEventListener('resize', () => {
|
||||
if (isMobileLayout()) return;
|
||||
applySidebarWidth(sidebar.getBoundingClientRect().width);
|
||||
});
|
||||
}
|
||||
|
||||
setupSidebarResize();
|
||||
|
||||
hamburger.addEventListener('click', () => {
|
||||
sidebar.classList.add('open');
|
||||
|
||||
@@ -56,7 +56,7 @@ import type { TodoItem } from "../../tools/todo-write";
|
||||
import type { EventBus } from "../../utils/event-bus";
|
||||
import type { SlashCommandInfo } from "../slash-commands";
|
||||
|
||||
export type { AppAction, KeybindingsManager } from "../../config/keybindings";
|
||||
export type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||
export type { ExecOptions, ExecResult } from "../../exec/exec";
|
||||
export type { AgentToolResult, AgentToolUpdateCallback };
|
||||
|
||||
|
||||
@@ -15,6 +15,7 @@ import { $env, getProjectDir, logger, postmortem, setProjectDir, VERSION } from
|
||||
import chalk from "chalk";
|
||||
import type { Args } from "./cli/args";
|
||||
import { processFileArguments } from "./cli/file-processor";
|
||||
import { buildInitialMessage } from "./cli/initial-message";
|
||||
import { listModels } from "./cli/list-models";
|
||||
import { selectSession } from "./cli/session-picker";
|
||||
import { findConfigFile } from "./config";
|
||||
@@ -137,7 +138,7 @@ async function runInteractiveMode(
|
||||
}
|
||||
}
|
||||
|
||||
if (initialMessage) {
|
||||
if (initialMessage !== undefined) {
|
||||
try {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
} catch (error: unknown) {
|
||||
@@ -161,33 +162,6 @@ async function runInteractiveMode(
|
||||
}
|
||||
}
|
||||
|
||||
async function prepareInitialMessage(
|
||||
parsed: Args,
|
||||
autoResizeImages: boolean,
|
||||
): Promise<{
|
||||
initialMessage?: string;
|
||||
initialImages?: ImageContent[];
|
||||
}> {
|
||||
if (parsed.fileArgs.length === 0) {
|
||||
return {};
|
||||
}
|
||||
|
||||
const { text, images } = await processFileArguments(parsed.fileArgs, { autoResizeImages });
|
||||
|
||||
let initialMessage: string;
|
||||
if (parsed.messages.length > 0) {
|
||||
initialMessage = text + parsed.messages[0];
|
||||
parsed.messages.shift();
|
||||
} else {
|
||||
initialMessage = text;
|
||||
}
|
||||
|
||||
return {
|
||||
initialMessage,
|
||||
initialImages: images.length > 0 ? images : undefined,
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePathForComparison(value: string): string {
|
||||
const resolved = path.resolve(value);
|
||||
let realPath = resolved;
|
||||
@@ -237,6 +211,21 @@ async function getChangelogForDisplay(parsed: Args): Promise<string | undefined>
|
||||
}
|
||||
|
||||
async function createSessionManager(parsed: Args, cwd: string): Promise<SessionManager | undefined> {
|
||||
if (parsed.fork) {
|
||||
if (parsed.noSession) {
|
||||
throw new Error("--fork requires session persistence");
|
||||
}
|
||||
const forkSource = parsed.fork;
|
||||
if (forkSource.includes("/") || forkSource.includes("\\") || forkSource.endsWith(".jsonl")) {
|
||||
return await SessionManager.forkFrom(forkSource, cwd, parsed.sessionDir);
|
||||
}
|
||||
const match = await resolveResumableSession(forkSource, cwd, parsed.sessionDir);
|
||||
if (!match) {
|
||||
throw new Error(`Session "${forkSource}" not found.`);
|
||||
}
|
||||
return await SessionManager.forkFrom(match.session.path, cwd, parsed.sessionDir);
|
||||
}
|
||||
|
||||
if (parsed.noSession) {
|
||||
return SessionManager.inMemory();
|
||||
}
|
||||
@@ -565,22 +554,27 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
||||
if (parsedArgs.noPty) {
|
||||
Bun.env.PI_NO_PTY = "1";
|
||||
}
|
||||
const {
|
||||
pipedInput,
|
||||
initialMessage: initMsg,
|
||||
initialImages,
|
||||
} = await logger.timeAsync("prepareInitialMessage", async () => {
|
||||
const { pipedInput, fileText, fileImages } = await logger.timeAsync("prepareInitialMessage", async () => {
|
||||
const pipedInput = await readPipedInput();
|
||||
let { initialMessage, initialImages } = await prepareInitialMessage(
|
||||
parsedArgs,
|
||||
settings.get("images.autoResize"),
|
||||
);
|
||||
if (pipedInput) {
|
||||
initialMessage = initialMessage ? `${initialMessage}\n${pipedInput}` : pipedInput;
|
||||
if (parsedArgs.fileArgs.length === 0) {
|
||||
return { pipedInput };
|
||||
}
|
||||
return { pipedInput, initialMessage, initialImages };
|
||||
|
||||
const { text, images } = await processFileArguments(parsedArgs.fileArgs, {
|
||||
autoResizeImages: settings.get("images.autoResize"),
|
||||
});
|
||||
return {
|
||||
pipedInput,
|
||||
fileText: text,
|
||||
fileImages: images,
|
||||
};
|
||||
});
|
||||
const { initialMessage, initialImages } = buildInitialMessage({
|
||||
parsed: parsedArgs,
|
||||
fileText,
|
||||
fileImages,
|
||||
stdinContent: pipedInput,
|
||||
});
|
||||
const initialMessage = initMsg;
|
||||
const autoPrint = pipedInput !== undefined && !parsedArgs.print && parsedArgs.mode === undefined;
|
||||
const isInteractive = !parsedArgs.print && !autoPrint && parsedArgs.mode === undefined;
|
||||
const mode = parsedArgs.mode || "text";
|
||||
@@ -626,7 +620,7 @@ export async function runRootCommand(parsed: Args, rawArgs: string[]): Promise<v
|
||||
let sessionManager = await logger.timeAsync("createSessionManager", () => createSessionManager(parsedArgs, cwd));
|
||||
|
||||
// Handle --resume (no value): show session picker
|
||||
if (parsedArgs.resume === true) {
|
||||
if (parsedArgs.resume === true && !parsedArgs.fork) {
|
||||
const sessions = await logger.timeAsync("SessionManager.list", () =>
|
||||
SessionManager.list(cwd, parsedArgs.sessionDir),
|
||||
);
|
||||
|
||||
@@ -1,41 +1,41 @@
|
||||
import { Editor, type KeyId, matchesKey, parseKittySequence } from "@oh-my-pi/pi-tui";
|
||||
import type { AppAction } from "../../config/keybindings";
|
||||
import type { AppKeybinding } from "../../config/keybindings";
|
||||
|
||||
type ConfigurableEditorAction = Extract<
|
||||
AppAction,
|
||||
| "interrupt"
|
||||
| "clear"
|
||||
| "exit"
|
||||
| "suspend"
|
||||
| "cycleThinkingLevel"
|
||||
| "cycleModelForward"
|
||||
| "cycleModelBackward"
|
||||
| "selectModel"
|
||||
| "expandTools"
|
||||
| "toggleThinking"
|
||||
| "externalEditor"
|
||||
| "historySearch"
|
||||
| "dequeue"
|
||||
| "pasteImage"
|
||||
| "copyPrompt"
|
||||
AppKeybinding,
|
||||
| "app.interrupt"
|
||||
| "app.clear"
|
||||
| "app.exit"
|
||||
| "app.suspend"
|
||||
| "app.thinking.cycle"
|
||||
| "app.model.cycleForward"
|
||||
| "app.model.cycleBackward"
|
||||
| "app.model.select"
|
||||
| "app.tools.expand"
|
||||
| "app.thinking.toggle"
|
||||
| "app.editor.external"
|
||||
| "app.history.search"
|
||||
| "app.message.dequeue"
|
||||
| "app.clipboard.pasteImage"
|
||||
| "app.clipboard.copyPrompt"
|
||||
>;
|
||||
|
||||
const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
|
||||
interrupt: ["escape"],
|
||||
clear: ["ctrl+c"],
|
||||
exit: ["ctrl+d"],
|
||||
suspend: ["ctrl+z"],
|
||||
cycleThinkingLevel: ["shift+tab"],
|
||||
cycleModelForward: ["ctrl+p"],
|
||||
cycleModelBackward: ["shift+ctrl+p"],
|
||||
selectModel: ["ctrl+l"],
|
||||
expandTools: ["ctrl+o"],
|
||||
toggleThinking: ["ctrl+t"],
|
||||
externalEditor: ["ctrl+g"],
|
||||
historySearch: ["ctrl+r"],
|
||||
dequeue: ["alt+up"],
|
||||
pasteImage: ["ctrl+v"],
|
||||
copyPrompt: ["alt+shift+c"],
|
||||
"app.interrupt": ["escape"],
|
||||
"app.clear": ["ctrl+c"],
|
||||
"app.exit": ["ctrl+d"],
|
||||
"app.suspend": ["ctrl+z"],
|
||||
"app.thinking.cycle": ["shift+tab"],
|
||||
"app.model.cycleForward": ["ctrl+p"],
|
||||
"app.model.cycleBackward": ["shift+ctrl+p"],
|
||||
"app.model.select": ["ctrl+l"],
|
||||
"app.tools.expand": ["ctrl+o"],
|
||||
"app.thinking.toggle": ["ctrl+t"],
|
||||
"app.editor.external": ["ctrl+g"],
|
||||
"app.history.search": ["ctrl+r"],
|
||||
"app.message.dequeue": ["alt+up"],
|
||||
"app.clipboard.pasteImage": ["ctrl+v"],
|
||||
"app.clipboard.copyPrompt": ["alt+shift+c"],
|
||||
};
|
||||
|
||||
/**
|
||||
@@ -115,13 +115,13 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured image paste (async - fires and handles result)
|
||||
if (this.#matchesAction(data, "pasteImage") && this.onPasteImage) {
|
||||
if (this.#matchesAction(data, "app.clipboard.pasteImage") && this.onPasteImage) {
|
||||
void this.onPasteImage();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured external editor shortcut
|
||||
if (this.#matchesAction(data, "externalEditor") && this.onExternalEditor) {
|
||||
if (this.#matchesAction(data, "app.editor.external") && this.onExternalEditor) {
|
||||
this.onExternalEditor();
|
||||
return;
|
||||
}
|
||||
@@ -133,56 +133,56 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured suspend shortcut
|
||||
if (this.#matchesAction(data, "suspend") && this.onSuspend) {
|
||||
if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
|
||||
this.onSuspend();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured thinking block visibility toggle
|
||||
if (this.#matchesAction(data, "toggleThinking") && this.onToggleThinking) {
|
||||
if (this.#matchesAction(data, "app.thinking.toggle") && this.onToggleThinking) {
|
||||
this.onToggleThinking();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured model selector shortcut
|
||||
if (this.#matchesAction(data, "selectModel") && this.onSelectModel) {
|
||||
if (this.#matchesAction(data, "app.model.select") && this.onSelectModel) {
|
||||
this.onSelectModel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured history search shortcut
|
||||
if (this.#matchesAction(data, "historySearch") && this.onHistorySearch) {
|
||||
if (this.#matchesAction(data, "app.history.search") && this.onHistorySearch) {
|
||||
this.onHistorySearch();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured tool output expansion shortcut
|
||||
if (this.#matchesAction(data, "expandTools") && this.onExpandTools) {
|
||||
if (this.#matchesAction(data, "app.tools.expand") && this.onExpandTools) {
|
||||
this.onExpandTools();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured backward model cycling (check before forward cycling)
|
||||
if (this.#matchesAction(data, "cycleModelBackward") && this.onCycleModelBackward) {
|
||||
if (this.#matchesAction(data, "app.model.cycleBackward") && this.onCycleModelBackward) {
|
||||
this.onCycleModelBackward();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured forward model cycling
|
||||
if (this.#matchesAction(data, "cycleModelForward") && this.onCycleModelForward) {
|
||||
if (this.#matchesAction(data, "app.model.cycleForward") && this.onCycleModelForward) {
|
||||
this.onCycleModelForward();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured thinking level cycling
|
||||
if (this.#matchesAction(data, "cycleThinkingLevel") && this.onCycleThinkingLevel) {
|
||||
if (this.#matchesAction(data, "app.thinking.cycle") && this.onCycleThinkingLevel) {
|
||||
this.onCycleThinkingLevel();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured interrupt shortcut.
|
||||
// Default behavior keeps autocomplete dismissal, but parent can prioritize global interrupt handling.
|
||||
if (this.#matchesAction(data, "interrupt") && this.onEscape) {
|
||||
if (this.#matchesAction(data, "app.interrupt") && this.onEscape) {
|
||||
if (!this.isShowingAutocomplete() || this.shouldBypassAutocompleteOnEscape?.()) {
|
||||
this.onEscape();
|
||||
return;
|
||||
@@ -190,13 +190,13 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured clear shortcut
|
||||
if (this.#matchesAction(data, "clear") && this.onClear) {
|
||||
if (this.#matchesAction(data, "app.clear") && this.onClear) {
|
||||
this.onClear();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured exit shortcut (only when editor is empty)
|
||||
if (this.#matchesAction(data, "exit")) {
|
||||
if (this.#matchesAction(data, "app.exit")) {
|
||||
if (this.getText().length === 0 && this.onExit) {
|
||||
this.onExit();
|
||||
}
|
||||
@@ -205,13 +205,13 @@ export class CustomEditor extends Editor {
|
||||
}
|
||||
|
||||
// Intercept configured dequeue shortcut (restore queued message to editor)
|
||||
if (this.#matchesAction(data, "dequeue") && this.onDequeue) {
|
||||
if (this.#matchesAction(data, "app.message.dequeue") && this.onDequeue) {
|
||||
this.onDequeue();
|
||||
return;
|
||||
}
|
||||
|
||||
// Intercept configured copy-prompt shortcut
|
||||
if (this.#matchesAction(data, "copyPrompt") && this.onCopyPrompt) {
|
||||
if (this.#matchesAction(data, "app.clipboard.copyPrompt") && this.onCopyPrompt) {
|
||||
this.onCopyPrompt();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
/**
|
||||
* Utilities for formatting keybinding hints in the UI.
|
||||
*/
|
||||
import { type EditorAction, getEditorKeybindings, type KeyId } from "@oh-my-pi/pi-tui";
|
||||
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
|
||||
import { getKeybindings, type Keybinding, type KeyId } from "@oh-my-pi/pi-tui";
|
||||
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
|
||||
/**
|
||||
@@ -17,14 +17,14 @@ function formatKeys(keys: KeyId[]): string {
|
||||
/**
|
||||
* Get display string for an editor action.
|
||||
*/
|
||||
export function editorKey(action: EditorAction): string {
|
||||
return formatKeys(getEditorKeybindings().getKeys(action));
|
||||
export function editorKey(action: Keybinding): string {
|
||||
return formatKeys(getKeybindings().getKeys(action));
|
||||
}
|
||||
|
||||
/**
|
||||
* Get display string for an app action.
|
||||
*/
|
||||
export function appKey(keybindings: KeybindingsManager, action: AppAction): string {
|
||||
export function appKey(keybindings: KeybindingsManager, action: AppKeybinding): string {
|
||||
return formatKeys(keybindings.getKeys(action));
|
||||
}
|
||||
|
||||
@@ -32,11 +32,11 @@ export function appKey(keybindings: KeybindingsManager, action: AppAction): stri
|
||||
* Format a keybinding hint with consistent styling: dim key, muted description.
|
||||
* Looks up the key from editor keybindings automatically.
|
||||
*
|
||||
* @param action - Editor action name (e.g., "selectConfirm", "expandTools")
|
||||
* @param action - Keybinding action name (e.g., "tui.select.confirm", "app.tools.expand")
|
||||
* @param description - Description text (e.g., "to expand", "cancel")
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function keyHint(action: EditorAction, description: string): string {
|
||||
export function keyHint(action: Keybinding, description: string): string {
|
||||
return theme.fg("dim", editorKey(action)) + theme.fg("muted", ` ${description}`);
|
||||
}
|
||||
|
||||
@@ -45,11 +45,11 @@ export function keyHint(action: EditorAction, description: string): string {
|
||||
* Requires the KeybindingsManager instance.
|
||||
*
|
||||
* @param keybindings - KeybindingsManager instance
|
||||
* @param action - App action name (e.g., "interrupt", "externalEditor")
|
||||
* @param action - App keybinding name (e.g., "app.interrupt", "app.editor.external")
|
||||
* @param description - Description text
|
||||
* @returns Formatted string with dim key and muted description
|
||||
*/
|
||||
export function appKeyHint(keybindings: KeybindingsManager, action: AppAction, description: string): string {
|
||||
export function appKeyHint(keybindings: KeybindingsManager, action: AppKeybinding, description: string): string {
|
||||
return theme.fg("dim", appKey(keybindings, action)) + theme.fg("muted", ` ${description}`);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getOAuthProviders } from "@oh-my-pi/pi-ai";
|
||||
import { Container, getEditorKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { Container, getKeybindings, Input, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
|
||||
import { theme } from "../../modes/theme/theme";
|
||||
import { openPath } from "../../utils/open";
|
||||
import { DynamicBorder } from "./dynamic-border";
|
||||
@@ -151,9 +151,9 @@ export class LoginDialogComponent extends Container {
|
||||
}
|
||||
|
||||
handleInput(data: string): void {
|
||||
const kb = getEditorKeybindings();
|
||||
const kb = getKeybindings();
|
||||
|
||||
if (kb.matches(data, "selectCancel")) {
|
||||
if (kb.matches(data, "tui.select.cancel")) {
|
||||
this.#cancel();
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1,6 +1,11 @@
|
||||
import { Container, Markdown, Spacer } from "@oh-my-pi/pi-tui";
|
||||
import { getMarkdownTheme, theme } from "../../modes/theme/theme";
|
||||
|
||||
// OSC 133 shell integration: marks prompt zones for terminal multiplexers
|
||||
const OSC133_ZONE_START = "\x1b]133;A\x07";
|
||||
const OSC133_ZONE_END = "\x1b]133;B\x07";
|
||||
const OSC133_ZONE_FINAL = "\x1b]133;C\x07";
|
||||
|
||||
/**
|
||||
* Component that renders a user message
|
||||
*/
|
||||
@@ -19,4 +24,15 @@ export class UserMessageComponent extends Container {
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
override render(width: number): string[] {
|
||||
const lines = super.render(width);
|
||||
if (lines.length === 0) {
|
||||
return lines;
|
||||
}
|
||||
|
||||
lines[0] = OSC133_ZONE_START + lines[0];
|
||||
lines[lines.length - 1] = lines[lines.length - 1] + OSC133_ZONE_END + OSC133_ZONE_FINAL;
|
||||
return lines;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -26,7 +26,7 @@ export class InputController {
|
||||
constructor(private ctx: InteractiveModeContext) {}
|
||||
|
||||
setupKeyHandlers(): void {
|
||||
this.ctx.editor.setActionKeys("interrupt", this.ctx.keybindings.getKeys("interrupt"));
|
||||
this.ctx.editor.setActionKeys("app.interrupt", this.ctx.keybindings.getKeys("app.interrupt"));
|
||||
this.ctx.editor.shouldBypassAutocompleteOnEscape = () =>
|
||||
Boolean(
|
||||
this.ctx.loadingAnimation ||
|
||||
@@ -83,68 +83,74 @@ export class InputController {
|
||||
}
|
||||
};
|
||||
|
||||
this.ctx.editor.setActionKeys("clear", this.ctx.keybindings.getKeys("clear"));
|
||||
this.ctx.editor.setActionKeys("app.clear", this.ctx.keybindings.getKeys("app.clear"));
|
||||
this.ctx.editor.onClear = () => this.handleCtrlC();
|
||||
this.ctx.editor.setActionKeys("exit", this.ctx.keybindings.getKeys("exit"));
|
||||
this.ctx.editor.setActionKeys("app.exit", this.ctx.keybindings.getKeys("app.exit"));
|
||||
this.ctx.editor.onExit = () => this.handleCtrlD();
|
||||
this.ctx.editor.setActionKeys("suspend", this.ctx.keybindings.getKeys("suspend"));
|
||||
this.ctx.editor.setActionKeys("app.suspend", this.ctx.keybindings.getKeys("app.suspend"));
|
||||
this.ctx.editor.onSuspend = () => this.handleCtrlZ();
|
||||
this.ctx.editor.setActionKeys("cycleThinkingLevel", this.ctx.keybindings.getKeys("cycleThinkingLevel"));
|
||||
this.ctx.editor.setActionKeys("app.thinking.cycle", this.ctx.keybindings.getKeys("app.thinking.cycle"));
|
||||
this.ctx.editor.onCycleThinkingLevel = () => this.cycleThinkingLevel();
|
||||
this.ctx.editor.setActionKeys("cycleModelForward", this.ctx.keybindings.getKeys("cycleModelForward"));
|
||||
this.ctx.editor.setActionKeys("app.model.cycleForward", this.ctx.keybindings.getKeys("app.model.cycleForward"));
|
||||
this.ctx.editor.onCycleModelForward = () => this.cycleRoleModel();
|
||||
this.ctx.editor.setActionKeys("cycleModelBackward", this.ctx.keybindings.getKeys("cycleModelBackward"));
|
||||
this.ctx.editor.setActionKeys("app.model.cycleBackward", this.ctx.keybindings.getKeys("app.model.cycleBackward"));
|
||||
this.ctx.editor.onCycleModelBackward = () => this.cycleRoleModel({ temporary: true });
|
||||
this.ctx.editor.onQuickSelectModel = () => this.ctx.showModelSelector({ temporaryOnly: true });
|
||||
|
||||
// Global debug handler on TUI (works regardless of focus)
|
||||
this.ctx.ui.onDebug = () => this.ctx.showDebugSelector();
|
||||
this.ctx.editor.setActionKeys("selectModel", this.ctx.keybindings.getKeys("selectModel"));
|
||||
this.ctx.editor.setActionKeys("app.model.select", this.ctx.keybindings.getKeys("app.model.select"));
|
||||
this.ctx.editor.onSelectModel = () => this.ctx.showModelSelector();
|
||||
this.ctx.editor.setActionKeys("historySearch", this.ctx.keybindings.getKeys("historySearch"));
|
||||
this.ctx.editor.setActionKeys("app.history.search", this.ctx.keybindings.getKeys("app.history.search"));
|
||||
this.ctx.editor.onHistorySearch = () => this.ctx.showHistorySearch();
|
||||
this.ctx.editor.setActionKeys("toggleThinking", this.ctx.keybindings.getKeys("toggleThinking"));
|
||||
this.ctx.editor.setActionKeys("app.thinking.toggle", this.ctx.keybindings.getKeys("app.thinking.toggle"));
|
||||
this.ctx.editor.onToggleThinking = () => this.ctx.toggleThinkingBlockVisibility();
|
||||
this.ctx.editor.setActionKeys("externalEditor", this.ctx.keybindings.getKeys("externalEditor"));
|
||||
this.ctx.editor.setActionKeys("app.editor.external", this.ctx.keybindings.getKeys("app.editor.external"));
|
||||
this.ctx.editor.onExternalEditor = () => void this.openExternalEditor();
|
||||
this.ctx.editor.onShowHotkeys = () => this.ctx.handleHotkeysCommand();
|
||||
this.ctx.editor.setActionKeys("pasteImage", this.ctx.keybindings.getKeys("pasteImage"));
|
||||
this.ctx.editor.setActionKeys(
|
||||
"app.clipboard.pasteImage",
|
||||
this.ctx.keybindings.getKeys("app.clipboard.pasteImage"),
|
||||
);
|
||||
this.ctx.editor.onPasteImage = () => this.handleImagePaste();
|
||||
this.ctx.editor.setActionKeys("copyPrompt", this.ctx.keybindings.getKeys("copyPrompt"));
|
||||
this.ctx.editor.setActionKeys(
|
||||
"app.clipboard.copyPrompt",
|
||||
this.ctx.keybindings.getKeys("app.clipboard.copyPrompt"),
|
||||
);
|
||||
this.ctx.editor.onCopyPrompt = () => this.handleCopyPrompt();
|
||||
this.ctx.editor.setActionKeys("expandTools", this.ctx.keybindings.getKeys("expandTools"));
|
||||
this.ctx.editor.setActionKeys("app.tools.expand", this.ctx.keybindings.getKeys("app.tools.expand"));
|
||||
this.ctx.editor.onExpandTools = () => this.toggleToolOutputExpansion();
|
||||
this.ctx.editor.setActionKeys("dequeue", this.ctx.keybindings.getKeys("dequeue"));
|
||||
this.ctx.editor.setActionKeys("app.message.dequeue", this.ctx.keybindings.getKeys("app.message.dequeue"));
|
||||
this.ctx.editor.onDequeue = () => this.handleDequeue();
|
||||
|
||||
this.ctx.editor.clearCustomKeyHandlers();
|
||||
// Wire up extension shortcuts
|
||||
this.registerExtensionShortcuts();
|
||||
|
||||
const planModeKeys = this.ctx.keybindings.getKeys("togglePlanMode");
|
||||
const planModeKeys = this.ctx.keybindings.getKeys("app.plan.toggle");
|
||||
for (const key of planModeKeys) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handlePlanModeCommand());
|
||||
}
|
||||
|
||||
for (const key of this.ctx.keybindings.getKeys("newSession")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.new")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.handleClearCommand());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("tree")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.tree")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showTreeSelector());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("fork")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.fork")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showUserMessageSelector());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("resume")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.session.resume")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.ctx.showSessionSelector());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("followUp")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.message.followUp")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => void this.handleFollowUp());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("toggleSTT")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.stt.toggle")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => void this.ctx.handleSTTToggle());
|
||||
}
|
||||
for (const key of this.ctx.keybindings.getKeys("copyLine")) {
|
||||
for (const key of this.ctx.keybindings.getKeys("app.clipboard.copyLine")) {
|
||||
this.ctx.editor.setCustomKeyHandler(key, () => this.handleCopyCurrentLine());
|
||||
}
|
||||
|
||||
|
||||
@@ -273,7 +273,7 @@ export class InteractiveMode implements InteractiveModeContext {
|
||||
async init(): Promise<void> {
|
||||
if (this.isInitialized) return;
|
||||
|
||||
this.keybindings = await logger.timeAsync("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
|
||||
this.keybindings = logger.time("InteractiveMode.init:keybindings", () => KeybindingsManager.create());
|
||||
|
||||
// Register session manager flush for signal handlers (SIGINT, SIGTERM, SIGHUP)
|
||||
this.#cleanupUnsubscribe = postmortem.register("session-manager-flush", () => this.sessionManager.flush());
|
||||
|
||||
@@ -146,7 +146,7 @@ export async function runPrintMode(session: AgentSession, options: PrintModeOpti
|
||||
});
|
||||
|
||||
// Send initial message with attachments
|
||||
if (initialMessage) {
|
||||
if (initialMessage !== undefined) {
|
||||
await session.prompt(initialMessage, { images: initialImages });
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,7 @@ import {
|
||||
type AutocompleteItem,
|
||||
type AutocompleteProvider,
|
||||
CombinedAutocompleteProvider,
|
||||
getEditorKeybindings,
|
||||
getKeybindings,
|
||||
type SlashCommand,
|
||||
} from "@oh-my-pi/pi-tui";
|
||||
import { formatKeyHints, type KeybindingsManager } from "../config/keybindings";
|
||||
@@ -174,26 +174,26 @@ export class PromptActionAutocompleteProvider implements AutocompleteProvider {
|
||||
export function createPromptActionAutocompleteProvider(
|
||||
options: PromptActionAutocompleteOptions,
|
||||
): PromptActionAutocompleteProvider {
|
||||
const editorKeybindings = getEditorKeybindings();
|
||||
const editorKeybindings = getKeybindings();
|
||||
const actions: PromptActionDefinition[] = [
|
||||
{
|
||||
id: "copy-line",
|
||||
label: "Copy current line",
|
||||
description: formatKeyHints(options.keybindings.getKeys("copyLine")),
|
||||
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyLine")),
|
||||
keywords: ["copy", "line", "clipboard", "current"],
|
||||
execute: options.copyCurrentLine,
|
||||
},
|
||||
{
|
||||
id: "copy-prompt",
|
||||
label: "Copy whole prompt",
|
||||
description: formatKeyHints(options.keybindings.getKeys("copyPrompt")),
|
||||
description: formatKeyHints(options.keybindings.getKeys("app.clipboard.copyPrompt")),
|
||||
keywords: ["copy", "prompt", "clipboard", "message"],
|
||||
execute: options.copyPrompt,
|
||||
},
|
||||
{
|
||||
id: "undo",
|
||||
label: "Undo",
|
||||
description: formatKeyHints(editorKeybindings.getKeys("undo")),
|
||||
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.undo")),
|
||||
keywords: ["undo", "revert", "edit", "history"],
|
||||
execute: options.undo,
|
||||
},
|
||||
@@ -214,14 +214,14 @@ export function createPromptActionAutocompleteProvider(
|
||||
{
|
||||
id: "cursor-line-start",
|
||||
label: "Move cursor to beginning of line",
|
||||
description: formatKeyHints(editorKeybindings.getKeys("cursorLineStart")),
|
||||
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineStart")),
|
||||
keywords: ["move", "cursor", "line", "start", "beginning", "home"],
|
||||
execute: options.moveCursorToLineStart,
|
||||
},
|
||||
{
|
||||
id: "cursor-line-end",
|
||||
label: "Move cursor to end of line",
|
||||
description: formatKeyHints(editorKeybindings.getKeys("cursorLineEnd")),
|
||||
description: formatKeyHints(editorKeybindings.getKeys("tui.editor.cursorLineEnd")),
|
||||
keywords: ["move", "cursor", "line", "end"],
|
||||
execute: options.moveCursorToLineEnd,
|
||||
},
|
||||
|
||||
@@ -1679,6 +1679,7 @@ export function getCurrentThemeName(): string | undefined {
|
||||
var currentSymbolPresetOverride: SymbolPreset | undefined;
|
||||
var currentColorBlindMode: boolean = false;
|
||||
var themeWatcher: fs.FSWatcher | undefined;
|
||||
var themeReloadTimer: NodeJS.Timeout | undefined;
|
||||
var sigwinchHandler: (() => void) | undefined;
|
||||
var autoDetectedTheme: boolean = false;
|
||||
var autoDarkTheme: string = "dark";
|
||||
@@ -1888,11 +1889,7 @@ export function isValidSymbolPreset(preset: string): preset is SymbolPreset {
|
||||
}
|
||||
|
||||
async function startThemeWatcher(): Promise<void> {
|
||||
// Stop existing watcher if any
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
stopThemeWatcher();
|
||||
|
||||
// Only watch if it's a custom theme (not built-in)
|
||||
if (!currentThemeName || currentThemeName === "dark" || currentThemeName === "light") {
|
||||
@@ -1900,54 +1897,62 @@ async function startThemeWatcher(): Promise<void> {
|
||||
}
|
||||
|
||||
const customThemesDir = getCustomThemesDir();
|
||||
const themeFile = path.join(customThemesDir, `${currentThemeName}.json`);
|
||||
const watchedThemeName = currentThemeName;
|
||||
const watchedFileName = `${watchedThemeName}.json`;
|
||||
const themeFile = path.join(customThemesDir, watchedFileName);
|
||||
|
||||
// Only watch if the file exists
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
themeWatcher = fs.watch(themeFile, eventType => {
|
||||
if (eventType === "change") {
|
||||
// Debounce rapid changes
|
||||
setTimeout(() => {
|
||||
loadTheme(currentThemeName!, getCurrentThemeOptions())
|
||||
.then(loadedTheme => {
|
||||
theme = loadedTheme;
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.debug("Theme reload error during file change", { error: String(err) });
|
||||
});
|
||||
}, 100);
|
||||
} else if (eventType === "rename") {
|
||||
// File was deleted or renamed - fall back to default theme
|
||||
setTimeout(() => {
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
currentThemeName = "dark";
|
||||
loadTheme("dark", getCurrentThemeOptions())
|
||||
.then(loadedTheme => {
|
||||
theme = loadedTheme;
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
})
|
||||
.catch(err => {
|
||||
logger.debug("Theme reload error during rename fallback", { error: String(err) });
|
||||
});
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
}
|
||||
}
|
||||
}, 100);
|
||||
const scheduleReload = () => {
|
||||
if (themeReloadTimer) {
|
||||
clearTimeout(themeReloadTimer);
|
||||
}
|
||||
themeReloadTimer = setTimeout(() => {
|
||||
themeReloadTimer = undefined;
|
||||
|
||||
// Ignore stale timers after switching themes or stopping the watcher
|
||||
if (currentThemeName !== watchedThemeName) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Keep the last successfully loaded theme active if the file is temporarily missing
|
||||
if (!fs.existsSync(themeFile)) {
|
||||
return;
|
||||
}
|
||||
|
||||
loadTheme(watchedThemeName, getCurrentThemeOptions())
|
||||
.then(loadedTheme => {
|
||||
theme = loadedTheme;
|
||||
if (onThemeChangeCallback) {
|
||||
onThemeChangeCallback();
|
||||
}
|
||||
})
|
||||
.catch(() => {
|
||||
// Ignore errors (file might be in invalid state while being edited)
|
||||
});
|
||||
}, 100);
|
||||
};
|
||||
|
||||
try {
|
||||
themeWatcher = fs.watch(customThemesDir, (_eventType, filename) => {
|
||||
if (currentThemeName !== watchedThemeName) {
|
||||
return;
|
||||
}
|
||||
if (!filename) {
|
||||
scheduleReload();
|
||||
return;
|
||||
}
|
||||
const changedFile = String(filename);
|
||||
if (changedFile !== watchedFileName) {
|
||||
return;
|
||||
}
|
||||
scheduleReload();
|
||||
});
|
||||
} catch (err) {
|
||||
logger.debug("Failed to start theme watcher", { error: String(err) });
|
||||
} catch {
|
||||
// Ignore errors starting watcher
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2023,6 +2028,10 @@ function stopSigwinchListener(): void {
|
||||
}
|
||||
|
||||
export function stopThemeWatcher(): void {
|
||||
if (themeReloadTimer) {
|
||||
clearTimeout(themeReloadTimer);
|
||||
themeReloadTimer = undefined;
|
||||
}
|
||||
if (themeWatcher) {
|
||||
themeWatcher.close();
|
||||
themeWatcher = undefined;
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import type { AppAction, KeybindingsManager } from "../../config/keybindings";
|
||||
import type { AppKeybinding, KeybindingsManager } from "../../config/keybindings";
|
||||
|
||||
export interface HotkeysMarkdownBindings {
|
||||
keybindings: Pick<KeybindingsManager, "getDisplayString">;
|
||||
}
|
||||
|
||||
function appKey(bindings: HotkeysMarkdownBindings, action: AppAction): string {
|
||||
function appKey(bindings: HotkeysMarkdownBindings, action: AppKeybinding): string {
|
||||
return bindings.keybindings.getDisplayString(action) || "Disabled";
|
||||
}
|
||||
|
||||
@@ -26,29 +26,29 @@ export function buildHotkeysMarkdown(bindings: HotkeysMarkdownBindings): string
|
||||
"| `Ctrl+W` / `Option+Backspace` | Delete word backwards |",
|
||||
"| `Ctrl+U` | Delete to start of line |",
|
||||
"| `Ctrl+K` | Delete to end of line |",
|
||||
`| \`${appKey(bindings, "copyLine")}\` | Copy current line |`,
|
||||
`| \`${appKey(bindings, "copyPrompt")}\` | Copy whole prompt |`,
|
||||
`| \`${appKey(bindings, "app.clipboard.copyLine")}\` | Copy current line |`,
|
||||
`| \`${appKey(bindings, "app.clipboard.copyPrompt")}\` | Copy whole prompt |`,
|
||||
"",
|
||||
"**Other**",
|
||||
"| Key | Action |",
|
||||
"|-----|--------|",
|
||||
"| `Tab` | Path completion / accept autocomplete |",
|
||||
`| \`${appKey(bindings, "interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
||||
`| \`${appKey(bindings, "clear")}\` | Clear editor (first) / exit (second) |`,
|
||||
`| \`${appKey(bindings, "exit")}\` | Exit (when editor is empty) |`,
|
||||
`| \`${appKey(bindings, "suspend")}\` | Suspend to background |`,
|
||||
`| \`${appKey(bindings, "cycleThinkingLevel")}\` | Cycle thinking level |`,
|
||||
`| \`${appKey(bindings, "cycleModelForward")}\` | Cycle role models (slow/default/smol) |`,
|
||||
`| \`${appKey(bindings, "cycleModelBackward")}\` | Cycle role models (temporary) |`,
|
||||
`| \`${appKey(bindings, "app.interrupt")}\` | Cancel autocomplete / interrupt active work |`,
|
||||
`| \`${appKey(bindings, "app.clear")}\` | Clear editor (first) / exit (second) |`,
|
||||
`| \`${appKey(bindings, "app.exit")}\` | Exit (when editor is empty) |`,
|
||||
`| \`${appKey(bindings, "app.suspend")}\` | Suspend to background |`,
|
||||
`| \`${appKey(bindings, "app.thinking.cycle")}\` | Cycle thinking level |`,
|
||||
`| \`${appKey(bindings, "app.model.cycleForward")}\` | Cycle role models (slow/default/smol) |`,
|
||||
`| \`${appKey(bindings, "app.model.cycleBackward")}\` | Cycle role models (temporary) |`,
|
||||
"| `Alt+P` | Select model (temporary) |",
|
||||
`| \`${appKey(bindings, "selectModel")}\` | Select model (set roles) |`,
|
||||
`| \`${appKey(bindings, "togglePlanMode")}\` | Toggle plan mode |`,
|
||||
`| \`${appKey(bindings, "historySearch")}\` | Search prompt history |`,
|
||||
`| \`${appKey(bindings, "expandTools")}\` | Toggle tool output expansion |`,
|
||||
`| \`${appKey(bindings, "toggleThinking")}\` | Toggle thinking block visibility |`,
|
||||
`| \`${appKey(bindings, "externalEditor")}\` | Edit message in external editor |`,
|
||||
`| \`${appKey(bindings, "pasteImage")}\` | Paste image from clipboard |`,
|
||||
`| \`${appKey(bindings, "toggleSTT")}\` | Toggle speech-to-text recording |`,
|
||||
`| \`${appKey(bindings, "app.model.select")}\` | Select model (set roles) |`,
|
||||
`| \`${appKey(bindings, "app.plan.toggle")}\` | Toggle plan mode |`,
|
||||
`| \`${appKey(bindings, "app.history.search")}\` | Search prompt history |`,
|
||||
`| \`${appKey(bindings, "app.tools.expand")}\` | Toggle tool output expansion |`,
|
||||
`| \`${appKey(bindings, "app.thinking.toggle")}\` | Toggle thinking block visibility |`,
|
||||
`| \`${appKey(bindings, "app.editor.external")}\` | Edit message in external editor |`,
|
||||
`| \`${appKey(bindings, "app.clipboard.pasteImage")}\` | Paste image from clipboard |`,
|
||||
`| \`${appKey(bindings, "app.stt.toggle")}\` | Toggle speech-to-text recording |`,
|
||||
"| `#` | Open prompt actions |",
|
||||
"| `/` | Slash commands |",
|
||||
"| `!` | Run bash command |",
|
||||
|
||||
@@ -474,7 +474,7 @@ export class UiHelpers {
|
||||
const queuedText = theme.fg("dim", `${entry.label}: ${entry.message}`);
|
||||
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(queuedText, 1, 0));
|
||||
}
|
||||
const dequeueKey = this.ctx.keybindings.getDisplayString("dequeue") || "Alt+Up";
|
||||
const dequeueKey = this.ctx.keybindings.getDisplayString("app.message.dequeue") || "Alt+Up";
|
||||
const hintText = theme.fg("dim", `${theme.tree.hook} ${dequeueKey} to edit`);
|
||||
this.ctx.pendingMessagesContainer.addChild(new TruncatedText(hintText, 1, 0));
|
||||
}
|
||||
|
||||
@@ -782,7 +782,6 @@ export class AgentSession {
|
||||
attempt: this.#retryAttempt,
|
||||
});
|
||||
this.#retryAttempt = 0;
|
||||
this.#resolveRetry();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -858,6 +857,7 @@ export class AgentSession {
|
||||
const didRetry = await this.#handleRetryableError(msg);
|
||||
if (didRetry) return; // Retry was initiated, don't proceed to compaction
|
||||
}
|
||||
this.#resolveRetry();
|
||||
|
||||
if (msg.stopReason === "aborted" && this.#checkpointState) {
|
||||
this.#checkpointState = undefined;
|
||||
@@ -4495,7 +4495,9 @@ export class AgentSession {
|
||||
}
|
||||
|
||||
#isTransientErrorMessage(errorMessage: string): boolean {
|
||||
return /overloaded|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server error|internal error|connection.?error|unable to connect|fetch failed|retry delay|stream stall/i.test(
|
||||
// Match: overloaded_error, provider returned error, rate limit, 429, 500, 502, 503, 504,
|
||||
// service unavailable, network/connection errors, fetch failed, terminated, retry delay exceeded
|
||||
return /overloaded|provider.?returned.?error|rate.?limit|too many requests|429|500|502|503|504|service.?unavailable|server.?error|internal.?error|network.?error|connection.?error|connection.?refused|other side closed|fetch failed|upstream.?connect|reset before headers|socket hang up|timed? out|timeout|terminated|retry delay|stream stall/i.test(
|
||||
errorMessage,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,88 @@
|
||||
import type { ChildProcess } from "node:child_process";
|
||||
|
||||
const EXIT_STDIO_GRACE_MS = 100;
|
||||
|
||||
/**
|
||||
* Wait for a child process to terminate without hanging on inherited stdio handles.
|
||||
*
|
||||
* Daemonized descendants can inherit the child's stdout/stderr pipe handles. In that
|
||||
* case the child emits `exit`, but `close` can hang forever even though the original
|
||||
* process is already gone. We wait briefly for stdio to end, then forcibly stop
|
||||
* tracking the inherited handles.
|
||||
*/
|
||||
export function waitForChildProcess(child: ChildProcess): Promise<number | null> {
|
||||
const { promise, resolve, reject } = Promise.withResolvers<number | null>();
|
||||
|
||||
let settled = false;
|
||||
let exited = false;
|
||||
let exitCode: number | null = null;
|
||||
let postExitTimer: NodeJS.Timeout | undefined;
|
||||
let stdoutEnded = child.stdout === null;
|
||||
let stderrEnded = child.stderr === null;
|
||||
|
||||
const cleanup = () => {
|
||||
if (postExitTimer) {
|
||||
clearTimeout(postExitTimer);
|
||||
postExitTimer = undefined;
|
||||
}
|
||||
child.removeListener("error", onError);
|
||||
child.removeListener("exit", onExit);
|
||||
child.removeListener("close", onClose);
|
||||
child.stdout?.removeListener("end", onStdoutEnd);
|
||||
child.stderr?.removeListener("end", onStderrEnd);
|
||||
};
|
||||
|
||||
const finalize = (code: number | null) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
child.stdout?.destroy();
|
||||
child.stderr?.destroy();
|
||||
resolve(code);
|
||||
};
|
||||
|
||||
const maybeFinalizeAfterExit = () => {
|
||||
if (!exited || settled) return;
|
||||
if (stdoutEnded && stderrEnded) {
|
||||
finalize(exitCode);
|
||||
}
|
||||
};
|
||||
|
||||
const onStdoutEnd = () => {
|
||||
stdoutEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onStderrEnd = () => {
|
||||
stderrEnded = true;
|
||||
maybeFinalizeAfterExit();
|
||||
};
|
||||
|
||||
const onError = (err: Error) => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
cleanup();
|
||||
reject(err);
|
||||
};
|
||||
|
||||
const onExit = (code: number | null) => {
|
||||
exited = true;
|
||||
exitCode = code;
|
||||
maybeFinalizeAfterExit();
|
||||
if (!settled) {
|
||||
postExitTimer = setTimeout(() => finalize(code), EXIT_STDIO_GRACE_MS);
|
||||
}
|
||||
};
|
||||
|
||||
const onClose = (code: number | null) => {
|
||||
finalize(code);
|
||||
};
|
||||
|
||||
child.stdout?.once("end", onStdoutEnd);
|
||||
child.stderr?.once("end", onStderrEnd);
|
||||
child.once("error", onError);
|
||||
child.once("exit", onExit);
|
||||
child.once("close", onClose);
|
||||
|
||||
return promise;
|
||||
}
|
||||
Reference in New Issue
Block a user