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:
can1357
2026-03-22 18:28:40 +01:00
parent c9a7bb0c96
commit 7fb18faf4c
60 changed files with 2581 additions and 939 deletions
+3
View File
@@ -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,
};
}
+416 -211
View File
@@ -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 };
+36 -42
View File
@@ -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,
},
+54 -45
View File
@@ -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;
}