@oh-my-pi/pi-coding-agent 3.37.0 → 4.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (70) hide show
  1. package/CHANGELOG.md +89 -0
  2. package/README.md +44 -3
  3. package/docs/extensions.md +29 -4
  4. package/docs/sdk.md +3 -3
  5. package/package.json +5 -5
  6. package/src/cli/args.ts +8 -0
  7. package/src/config.ts +5 -15
  8. package/src/core/agent-session.ts +193 -47
  9. package/src/core/auth-storage.ts +16 -3
  10. package/src/core/bash-executor.ts +79 -14
  11. package/src/core/custom-commands/types.ts +1 -1
  12. package/src/core/custom-tools/types.ts +1 -1
  13. package/src/core/export-html/index.ts +33 -1
  14. package/src/core/export-html/template.css +99 -0
  15. package/src/core/export-html/template.generated.ts +1 -1
  16. package/src/core/export-html/template.js +133 -8
  17. package/src/core/extensions/index.ts +22 -4
  18. package/src/core/extensions/loader.ts +152 -214
  19. package/src/core/extensions/runner.ts +139 -79
  20. package/src/core/extensions/types.ts +143 -19
  21. package/src/core/extensions/wrapper.ts +5 -8
  22. package/src/core/hooks/types.ts +1 -1
  23. package/src/core/index.ts +2 -1
  24. package/src/core/keybindings.ts +4 -1
  25. package/src/core/model-registry.ts +1 -1
  26. package/src/core/model-resolver.ts +35 -26
  27. package/src/core/sdk.ts +96 -76
  28. package/src/core/settings-manager.ts +45 -14
  29. package/src/core/system-prompt.ts +5 -15
  30. package/src/core/tools/bash.ts +115 -54
  31. package/src/core/tools/find.ts +86 -7
  32. package/src/core/tools/grep.ts +27 -6
  33. package/src/core/tools/index.ts +15 -6
  34. package/src/core/tools/ls.ts +49 -18
  35. package/src/core/tools/render-utils.ts +2 -1
  36. package/src/core/tools/task/worker.ts +35 -12
  37. package/src/core/tools/web-search/auth.ts +37 -32
  38. package/src/core/tools/web-search/providers/anthropic.ts +35 -22
  39. package/src/index.ts +101 -9
  40. package/src/main.ts +60 -20
  41. package/src/migrations.ts +47 -2
  42. package/src/modes/index.ts +2 -2
  43. package/src/modes/interactive/components/assistant-message.ts +25 -7
  44. package/src/modes/interactive/components/bash-execution.ts +5 -0
  45. package/src/modes/interactive/components/branch-summary-message.ts +5 -0
  46. package/src/modes/interactive/components/compaction-summary-message.ts +5 -0
  47. package/src/modes/interactive/components/countdown-timer.ts +38 -0
  48. package/src/modes/interactive/components/custom-editor.ts +8 -0
  49. package/src/modes/interactive/components/custom-message.ts +5 -0
  50. package/src/modes/interactive/components/footer.ts +2 -5
  51. package/src/modes/interactive/components/hook-input.ts +29 -20
  52. package/src/modes/interactive/components/hook-selector.ts +52 -38
  53. package/src/modes/interactive/components/index.ts +39 -0
  54. package/src/modes/interactive/components/login-dialog.ts +160 -0
  55. package/src/modes/interactive/components/model-selector.ts +10 -2
  56. package/src/modes/interactive/components/session-selector.ts +5 -1
  57. package/src/modes/interactive/components/settings-defs.ts +9 -0
  58. package/src/modes/interactive/components/status-line/segments.ts +3 -3
  59. package/src/modes/interactive/components/tool-execution.ts +9 -16
  60. package/src/modes/interactive/components/tree-selector.ts +1 -6
  61. package/src/modes/interactive/interactive-mode.ts +466 -215
  62. package/src/modes/interactive/theme/theme.ts +50 -2
  63. package/src/modes/print-mode.ts +78 -31
  64. package/src/modes/rpc/rpc-mode.ts +186 -78
  65. package/src/modes/rpc/rpc-types.ts +10 -3
  66. package/src/prompts/system-prompt.md +36 -28
  67. package/src/utils/clipboard.ts +90 -50
  68. package/src/utils/image-convert.ts +1 -1
  69. package/src/utils/image-resize.ts +1 -1
  70. package/src/utils/tools-manager.ts +2 -2
package/src/index.ts CHANGED
@@ -60,11 +60,40 @@ export type {
60
60
  RenderResultOptions,
61
61
  } from "./core/custom-tools/index";
62
62
  export { discoverAndLoadCustomTools, loadCustomTools } from "./core/custom-tools/index";
63
- // Extension types
64
- export type { ExtensionAPI, ExtensionContext, ExtensionFactory } from "./core/extensions/types";
65
- export type * from "./core/hooks/index";
66
- // Hook system types and type guards
63
+ // Extension types and utilities
64
+ export type {
65
+ AppAction,
66
+ Extension,
67
+ ExtensionActions,
68
+ ExtensionAPI,
69
+ ExtensionCommandContext,
70
+ ExtensionCommandContextActions,
71
+ ExtensionContext,
72
+ ExtensionContextActions,
73
+ ExtensionError,
74
+ ExtensionEvent,
75
+ ExtensionFactory,
76
+ ExtensionFlag,
77
+ ExtensionHandler,
78
+ ExtensionRuntime,
79
+ ExtensionShortcut,
80
+ ExtensionUIContext,
81
+ ExtensionUIDialogOptions,
82
+ KeybindingsManager,
83
+ LoadExtensionsResult,
84
+ MessageRenderer,
85
+ MessageRenderOptions,
86
+ RegisteredCommand,
87
+ ToolResultEvent,
88
+ TurnEndEvent,
89
+ TurnStartEvent,
90
+ UserBashEvent,
91
+ UserBashEventResult,
92
+ } from "./core/extensions/index";
67
93
  export {
94
+ createExtensionRuntime,
95
+ discoverAndLoadExtensions,
96
+ ExtensionRunner,
68
97
  isBashToolResult,
69
98
  isEditToolResult,
70
99
  isFindToolResult,
@@ -72,7 +101,9 @@ export {
72
101
  isLsToolResult,
73
102
  isReadToolResult,
74
103
  isWriteToolResult,
75
- } from "./core/hooks/index";
104
+ } from "./core/extensions/index";
105
+ // Hook system types (legacy re-export)
106
+ export type * from "./core/hooks/index";
76
107
  // Logging
77
108
  export { type Logger, logger } from "./core/logger";
78
109
  export { convertToLlm } from "./core/messages";
@@ -153,25 +184,86 @@ export {
153
184
  } from "./core/skills";
154
185
  // Slash commands
155
186
  export { type FileSlashCommand, loadSlashCommands as discoverSlashCommands } from "./core/slash-commands";
156
- // Tools (detail types only - factories exported from sdk)
187
+ // Tools (detail types and utilities)
157
188
  export {
189
+ type BashOperations,
158
190
  type BashToolDetails,
191
+ DEFAULT_MAX_BYTES,
192
+ DEFAULT_MAX_LINES,
193
+ type FindOperations,
159
194
  type FindToolDetails,
195
+ type FindToolOptions,
196
+ formatSize,
160
197
  type GitToolDetails,
198
+ type GrepOperations,
161
199
  type GrepToolDetails,
200
+ type GrepToolOptions,
162
201
  gitTool,
202
+ type LsOperations,
163
203
  type LsToolDetails,
204
+ type LsToolOptions,
164
205
  type ReadToolDetails,
206
+ type TruncationOptions,
165
207
  type TruncationResult,
208
+ truncateHead,
209
+ truncateLine,
210
+ truncateTail,
166
211
  type WriteToolDetails,
167
212
  } from "./core/tools/index";
168
213
  export type { FileDiagnosticsResult } from "./core/tools/lsp/index";
169
214
  // Main entry point
170
215
  export { main } from "./main";
171
- // UI components for hooks and custom tools
172
- export { BorderedLoader } from "./modes/interactive/components/bordered-loader";
216
+ // Run modes for programmatic SDK usage
217
+ export {
218
+ InteractiveMode,
219
+ type InteractiveModeOptions,
220
+ type PrintModeOptions,
221
+ runPrintMode,
222
+ runRpcMode,
223
+ } from "./modes/index";
224
+ // UI components for extensions
225
+ export {
226
+ ArminComponent,
227
+ AssistantMessageComponent,
228
+ BashExecutionComponent,
229
+ BorderedLoader,
230
+ BranchSummaryMessageComponent,
231
+ CompactionSummaryMessageComponent,
232
+ CustomEditor,
233
+ CustomMessageComponent,
234
+ DynamicBorder,
235
+ FooterComponent,
236
+ HookEditorComponent as ExtensionEditorComponent,
237
+ HookInputComponent as ExtensionInputComponent,
238
+ HookSelectorComponent as ExtensionSelectorComponent,
239
+ LoginDialogComponent,
240
+ ModelSelectorComponent,
241
+ OAuthSelectorComponent,
242
+ type RenderDiffOptions,
243
+ renderDiff,
244
+ SessionSelectorComponent,
245
+ type SettingsCallbacks,
246
+ SettingsSelectorComponent,
247
+ ShowImagesSelectorComponent,
248
+ ThemeSelectorComponent,
249
+ ThinkingSelectorComponent,
250
+ ToolExecutionComponent,
251
+ type ToolExecutionOptions,
252
+ TreeSelectorComponent,
253
+ truncateToVisualLines,
254
+ UserMessageComponent,
255
+ UserMessageSelectorComponent,
256
+ type VisualTruncateResult,
257
+ } from "./modes/interactive/components/index";
173
258
  // Theme utilities for custom tools
174
- export { getMarkdownTheme, getSettingsListTheme, type Theme } from "./modes/interactive/theme/theme";
259
+ export {
260
+ getMarkdownTheme,
261
+ getSelectListTheme,
262
+ getSettingsListTheme,
263
+ initTheme,
264
+ Theme,
265
+ type ThemeColor,
266
+ } from "./modes/interactive/theme/theme";
175
267
 
176
268
  // TypeBox helper for string enums (convenience for custom tools)
177
269
  import { type TSchema, Type } from "@sinclair/typebox";
package/src/main.ts CHANGED
@@ -30,7 +30,6 @@ import { runMigrations, showDeprecationWarnings } from "./migrations";
30
30
  import { InteractiveMode, installTerminalCrashHandlers, runPrintMode, runRpcMode } from "./modes/index";
31
31
  import { initTheme, stopThemeWatcher } from "./modes/interactive/theme/theme";
32
32
  import { getChangelogPath, getNewEntries, parseChangelog } from "./utils/changelog";
33
- import { ensureTool } from "./utils/tools-manager";
34
33
 
35
34
  async function checkForNewVersion(currentVersion: string): Promise<string | undefined> {
36
35
  try {
@@ -63,9 +62,8 @@ async function runInteractiveMode(
63
62
  lspServers: Array<{ name: string; status: "ready" | "error"; fileTypes: string[] }> | undefined,
64
63
  initialMessage?: string,
65
64
  initialImages?: ImageContent[],
66
- fdPath: string | undefined = undefined,
67
65
  ): Promise<void> {
68
- const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers, fdPath);
66
+ const mode = new InteractiveMode(session, version, changelogMarkdown, setExtensionUIContext, lspServers);
69
67
 
70
68
  await mode.init();
71
69
 
@@ -145,6 +143,28 @@ async function prepareInitialMessage(
145
143
  };
146
144
  }
147
145
 
146
+ /**
147
+ * Resolve a session argument to a file path.
148
+ * If it looks like a path, use as-is. Otherwise try to match as session ID prefix.
149
+ */
150
+ function resolveSessionPath(sessionArg: string, cwd: string, sessionDir?: string): string {
151
+ // If it looks like a file path, use as-is
152
+ if (sessionArg.includes("/") || sessionArg.includes("\\") || sessionArg.endsWith(".jsonl")) {
153
+ return sessionArg;
154
+ }
155
+
156
+ // Try to match as session ID (full or partial UUID)
157
+ const sessions = SessionManager.list(cwd, sessionDir);
158
+ const matches = sessions.filter((session) => session.id.startsWith(sessionArg));
159
+
160
+ if (matches.length >= 1) {
161
+ return matches[0].path; // Already sorted by modified time (most recent first)
162
+ }
163
+
164
+ // No match - return original (will create new session)
165
+ return sessionArg;
166
+ }
167
+
148
168
  function getChangelogForDisplay(parsed: Args, settingsManager: SettingsManager): string | undefined {
149
169
  if (parsed.continue || parsed.resume) {
150
170
  return undefined;
@@ -175,7 +195,8 @@ async function createSessionManager(parsed: Args, cwd: string): Promise<SessionM
175
195
  return SessionManager.inMemory();
176
196
  }
177
197
  if (parsed.session) {
178
- return await SessionManager.open(parsed.session, parsed.sessionDir);
198
+ const resolvedPath = resolveSessionPath(parsed.session, cwd, parsed.sessionDir);
199
+ return await SessionManager.open(resolvedPath, parsed.sessionDir);
179
200
  }
180
201
  if (parsed.continue) {
181
202
  return await SessionManager.continueRecent(cwd, parsed.sessionDir);
@@ -299,13 +320,24 @@ async function buildSessionOptions(
299
320
  // Thinking level
300
321
  if (parsed.thinking) {
301
322
  options.thinkingLevel = parsed.thinking;
302
- } else if (scopedModels.length > 0 && !parsed.continue && !parsed.resume) {
323
+ } else if (
324
+ scopedModels.length > 0 &&
325
+ scopedModels[0].explicitThinkingLevel === true &&
326
+ !parsed.continue &&
327
+ !parsed.resume
328
+ ) {
303
329
  options.thinkingLevel = scopedModels[0].thinkingLevel;
304
330
  }
305
331
 
306
- // Scoped models for Ctrl+P cycling
332
+ // Scoped models for Ctrl+P cycling - fill in default thinking levels when not explicit
307
333
  if (scopedModels.length > 0) {
308
- options.scopedModels = scopedModels;
334
+ const defaultThinkingLevel = settingsManager.getDefaultThinkingLevel() ?? "off";
335
+ options.scopedModels = scopedModels.map((scopedModel) => ({
336
+ model: scopedModel.model,
337
+ thinkingLevel: scopedModel.explicitThinkingLevel
338
+ ? (scopedModel.thinkingLevel ?? defaultThinkingLevel)
339
+ : defaultThinkingLevel,
340
+ }));
309
341
  }
310
342
 
311
343
  // API key from CLI - set in authStorage
@@ -321,7 +353,9 @@ async function buildSessionOptions(
321
353
  }
322
354
 
323
355
  // Tools
324
- if (parsed.tools) {
356
+ if (parsed.noTools) {
357
+ options.toolNames = parsed.tools && parsed.tools.length > 0 ? parsed.tools : [];
358
+ } else if (parsed.tools) {
325
359
  options.toolNames = parsed.tools;
326
360
  }
327
361
 
@@ -344,6 +378,10 @@ async function buildSessionOptions(
344
378
  options.additionalExtensionPaths = cliExtensionPaths;
345
379
  }
346
380
 
381
+ if (parsed.noExtensions) {
382
+ options.disableExtensionDiscovery = true;
383
+ }
384
+
347
385
  return options;
348
386
  }
349
387
 
@@ -504,7 +542,7 @@ export async function main(args: string[]) {
504
542
  }
505
543
 
506
544
  time("buildSessionOptions");
507
- const { session, extensionsResult, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions);
545
+ const { session, setToolUIContext, modelFallbackMessage, lspServers } = await createAgentSession(sessionOptions);
508
546
  time("createAgentSession");
509
547
 
510
548
  // Re-parse CLI args with extension flags and apply values
@@ -550,19 +588,17 @@ export async function main(args: string[]) {
550
588
  const versionCheckPromise = checkForNewVersion(VERSION).catch(() => undefined);
551
589
  const changelogMarkdown = getChangelogForDisplay(parsed, settingsManager);
552
590
 
553
- if (scopedModels.length > 0) {
554
- const modelList = scopedModels
555
- .map((sm) => {
556
- const thinkingStr = sm.thinkingLevel !== "off" ? `:${sm.thinkingLevel}` : "";
557
- return `${sm.model.id}${thinkingStr}`;
591
+ const scopedModelsForDisplay = sessionOptions.scopedModels ?? scopedModels;
592
+ if (scopedModelsForDisplay.length > 0) {
593
+ const modelList = scopedModelsForDisplay
594
+ .map((scopedModel) => {
595
+ const thinkingStr = scopedModel.thinkingLevel !== "off" ? `:${scopedModel.thinkingLevel}` : "";
596
+ return `${scopedModel.model.id}${thinkingStr}`;
558
597
  })
559
598
  .join(", ");
560
599
  console.log(chalk.dim(`Model scope: ${modelList} ${chalk.gray("(Ctrl+P to cycle)")}`));
561
600
  }
562
601
 
563
- const fdPath = await ensureTool("fd");
564
- time("ensureTool(fd)");
565
-
566
602
  installTerminalCrashHandlers();
567
603
  printTimings();
568
604
  await runInteractiveMode(
@@ -574,14 +610,18 @@ export async function main(args: string[]) {
574
610
  migratedProviders,
575
611
  versionCheckPromise,
576
612
  parsed.messages,
577
- extensionsResult.setUIContext,
613
+ setToolUIContext,
578
614
  lspServers,
579
615
  initialMessage,
580
616
  initialImages,
581
- fdPath,
582
617
  );
583
618
  } else {
584
- await runPrintMode(session, mode, parsed.messages, initialMessage, initialImages);
619
+ await runPrintMode(session, {
620
+ mode,
621
+ messages: parsed.messages,
622
+ initialMessage,
623
+ initialImages,
624
+ });
585
625
  stopThemeWatcher();
586
626
  if (process.stdout.writableLength > 0) {
587
627
  await new Promise<void>((resolve) => process.stdout.once("drain", resolve));
package/src/migrations.ts CHANGED
@@ -2,10 +2,10 @@
2
2
  * One-time migrations that run on startup.
3
3
  */
4
4
 
5
- import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
5
+ import { existsSync, mkdirSync, readdirSync, readFileSync, renameSync, rmSync, writeFileSync } from "node:fs";
6
6
  import { dirname, join } from "node:path";
7
7
  import chalk from "chalk";
8
- import { getAgentDir } from "./config";
8
+ import { getAgentDir, getBinDir } from "./config";
9
9
 
10
10
  /**
11
11
  * Migrate PI_* environment variables to OMP_* equivalents.
@@ -143,6 +143,50 @@ export function migrateSessionsFromAgentRoot(): void {
143
143
  }
144
144
  }
145
145
 
146
+ /**
147
+ * Move fd/rg binaries from tools/ to bin/ if they exist.
148
+ */
149
+ function migrateToolsToBin(): void {
150
+ const agentDir = getAgentDir();
151
+ const toolsDir = join(agentDir, "tools");
152
+ const binDir = getBinDir();
153
+
154
+ if (!existsSync(toolsDir)) return;
155
+
156
+ const binaries = ["fd", "rg", "fd.exe", "rg.exe"];
157
+ let movedAny = false;
158
+
159
+ for (const bin of binaries) {
160
+ const oldPath = join(toolsDir, bin);
161
+ const newPath = join(binDir, bin);
162
+
163
+ if (existsSync(oldPath)) {
164
+ if (!existsSync(binDir)) {
165
+ mkdirSync(binDir, { recursive: true });
166
+ }
167
+ if (!existsSync(newPath)) {
168
+ try {
169
+ renameSync(oldPath, newPath);
170
+ movedAny = true;
171
+ } catch {
172
+ // Ignore errors
173
+ }
174
+ } else {
175
+ // Target exists, just delete the old one
176
+ try {
177
+ rmSync(oldPath, { force: true });
178
+ } catch {
179
+ // Ignore
180
+ }
181
+ }
182
+ }
183
+ }
184
+
185
+ if (movedAny) {
186
+ console.log(chalk.green(`Migrated managed binaries tools/ → bin/`));
187
+ }
188
+ }
189
+
146
190
  /**
147
191
  * Run all migrations. Called once on startup.
148
192
  *
@@ -159,6 +203,7 @@ export async function runMigrations(_cwd: string): Promise<{
159
203
  // Then: run data migrations
160
204
  const migratedAuthProviders = migrateAuthToAuthJson();
161
205
  migrateSessionsFromAgentRoot();
206
+ migrateToolsToBin();
162
207
 
163
208
  // Collect deprecation warnings
164
209
  const deprecationWarnings: string[] = [];
@@ -41,8 +41,8 @@ export function installTerminalCrashHandlers(): void {
41
41
  });
42
42
  }
43
43
 
44
- export { InteractiveMode } from "./interactive/interactive-mode";
45
- export { runPrintMode } from "./print-mode";
44
+ export { InteractiveMode, type InteractiveModeOptions } from "./interactive/interactive-mode";
45
+ export { type PrintModeOptions, runPrintMode } from "./print-mode";
46
46
  export { type ModelInfo, RpcClient, type RpcClientOptions, type RpcEventListener } from "./rpc/rpc-client";
47
47
  export { runRpcMode } from "./rpc/rpc-mode";
48
48
  export type { RpcCommand, RpcResponse, RpcSessionState } from "./rpc/rpc-types";
@@ -8,6 +8,7 @@ import { getMarkdownTheme, theme } from "../theme/theme";
8
8
  export class AssistantMessageComponent extends Container {
9
9
  private contentContainer: Container;
10
10
  private hideThinkingBlock: boolean;
11
+ private lastMessage?: AssistantMessage;
11
12
 
12
13
  constructor(message?: AssistantMessage, hideThinkingBlock = false) {
13
14
  super();
@@ -23,20 +24,28 @@ export class AssistantMessageComponent extends Container {
23
24
  }
24
25
  }
25
26
 
27
+ override invalidate(): void {
28
+ super.invalidate();
29
+ if (this.lastMessage) {
30
+ this.updateContent(this.lastMessage);
31
+ }
32
+ }
33
+
26
34
  setHideThinkingBlock(hide: boolean): void {
27
35
  this.hideThinkingBlock = hide;
28
36
  }
29
37
 
30
38
  updateContent(message: AssistantMessage): void {
39
+ this.lastMessage = message;
40
+
31
41
  // Clear content container
32
42
  this.contentContainer.clear();
33
43
 
34
- if (
35
- message.content.length > 0 &&
36
- message.content.some(
37
- (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
38
- )
39
- ) {
44
+ const hasVisibleContent = message.content.some(
45
+ (c) => (c.type === "text" && c.text.trim()) || (c.type === "thinking" && c.thinking.trim()),
46
+ );
47
+
48
+ if (hasVisibleContent) {
40
49
  this.contentContainer.addChild(new Spacer(1));
41
50
  }
42
51
 
@@ -75,7 +84,16 @@ export class AssistantMessageComponent extends Container {
75
84
  const hasToolCalls = message.content.some((c) => c.type === "toolCall");
76
85
  if (!hasToolCalls) {
77
86
  if (message.stopReason === "aborted") {
78
- this.contentContainer.addChild(new Text(theme.fg("error", "\nAborted"), 1, 0));
87
+ const abortMessage =
88
+ message.errorMessage && message.errorMessage !== "Request was aborted"
89
+ ? message.errorMessage
90
+ : "Operation aborted";
91
+ if (hasVisibleContent) {
92
+ this.contentContainer.addChild(new Spacer(1));
93
+ } else {
94
+ this.contentContainer.addChild(new Spacer(1));
95
+ }
96
+ this.contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
79
97
  } else if (message.stopReason === "error") {
80
98
  const errorMsg = message.errorMessage || "Unknown error";
81
99
  this.contentContainer.addChild(new Spacer(1));
@@ -72,6 +72,11 @@ export class BashExecutionComponent extends Container {
72
72
  this.updateDisplay();
73
73
  }
74
74
 
75
+ override invalidate(): void {
76
+ super.invalidate();
77
+ this.updateDisplay();
78
+ }
79
+
75
80
  appendOutput(chunk: string): void {
76
81
  // Strip ANSI codes and normalize line endings
77
82
  // Note: binary data is already sanitized in tui-renderer.ts executeBashCommand
@@ -21,6 +21,11 @@ export class BranchSummaryMessageComponent extends Box {
21
21
  this.updateDisplay();
22
22
  }
23
23
 
24
+ override invalidate(): void {
25
+ super.invalidate();
26
+ this.updateDisplay();
27
+ }
28
+
24
29
  private updateDisplay(): void {
25
30
  this.clear();
26
31
 
@@ -21,6 +21,11 @@ export class CompactionSummaryMessageComponent extends Box {
21
21
  this.updateDisplay();
22
22
  }
23
23
 
24
+ override invalidate(): void {
25
+ super.invalidate();
26
+ this.updateDisplay();
27
+ }
28
+
24
29
  private updateDisplay(): void {
25
30
  this.clear();
26
31
 
@@ -0,0 +1,38 @@
1
+ /**
2
+ * Reusable countdown timer for dialog components.
3
+ */
4
+
5
+ import type { TUI } from "@oh-my-pi/pi-tui";
6
+
7
+ export class CountdownTimer {
8
+ private intervalId: ReturnType<typeof setInterval> | undefined;
9
+ private remainingSeconds: number;
10
+
11
+ constructor(
12
+ timeoutMs: number,
13
+ private tui: TUI | undefined,
14
+ private onTick: (seconds: number) => void,
15
+ private onExpire: () => void,
16
+ ) {
17
+ this.remainingSeconds = Math.ceil(timeoutMs / 1000);
18
+ this.onTick(this.remainingSeconds);
19
+
20
+ this.intervalId = setInterval(() => {
21
+ this.remainingSeconds--;
22
+ this.onTick(this.remainingSeconds);
23
+ this.tui?.requestRender();
24
+
25
+ if (this.remainingSeconds <= 0) {
26
+ this.dispose();
27
+ this.onExpire();
28
+ }
29
+ }, 1000);
30
+ }
31
+
32
+ dispose(): void {
33
+ if (this.intervalId) {
34
+ clearInterval(this.intervalId);
35
+ this.intervalId = undefined;
36
+ }
37
+ }
38
+ }
@@ -38,6 +38,8 @@ export class CustomEditor extends Editor {
38
38
  public onCtrlY?: () => void;
39
39
  /** Called when Ctrl+V is pressed. Returns true if handled (image found), false to fall through to text paste. */
40
40
  public onCtrlV?: () => Promise<boolean>;
41
+ /** Called when Alt+Up is pressed (dequeue keybinding). */
42
+ public onAltUp?: () => void;
41
43
 
42
44
  /** Custom key handlers from extensions */
43
45
  private customKeyHandlers = new Map<KeyId, () => void>();
@@ -157,6 +159,12 @@ export class CustomEditor extends Editor {
157
159
  return;
158
160
  }
159
161
 
162
+ // Intercept Alt+Up for dequeue (restore queued message to editor)
163
+ if (matchesKey(data, "alt+up") && this.onAltUp) {
164
+ this.onAltUp();
165
+ return;
166
+ }
167
+
160
168
  // Intercept ? when editor is empty to show hotkeys
161
169
  if (data === "?" && this.getText().length === 0 && this.onQuestionMark) {
162
170
  this.onQuestionMark();
@@ -36,6 +36,11 @@ export class CustomMessageComponent extends Container {
36
36
  }
37
37
  }
38
38
 
39
+ override invalidate(): void {
40
+ super.invalidate();
41
+ this.rebuild();
42
+ }
43
+
39
44
  private rebuild(): void {
40
45
  // Remove previous content component
41
46
  if (this.customComponent) {
@@ -3,6 +3,7 @@ import type { AssistantMessage } from "@oh-my-pi/pi-ai";
3
3
  import { type Component, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
4
4
  import { dirname, join } from "path";
5
5
  import type { AgentSession } from "../../../core/agent-session";
6
+ import { shortenPath } from "../../../core/tools/render-utils";
6
7
  import { theme } from "../theme/theme";
7
8
 
8
9
  /**
@@ -199,11 +200,7 @@ export class FooterComponent implements Component {
199
200
  };
200
201
 
201
202
  // Replace home directory with ~
202
- let pwd = process.cwd();
203
- const home = process.env.HOME || process.env.USERPROFILE;
204
- if (home && pwd.startsWith(home)) {
205
- pwd = `~${pwd.slice(home.length)}`;
206
- }
203
+ let pwd = shortenPath(process.cwd());
207
204
 
208
205
  // Add git branch if available
209
206
  const branch = this.getCurrentBranch();
@@ -2,63 +2,72 @@
2
2
  * Simple text input component for hooks.
3
3
  */
4
4
 
5
- import { Container, Input, isEnter, isEscape, Spacer, Text } from "@oh-my-pi/pi-tui";
5
+ import { Container, Input, isEnter, isEscape, Spacer, Text, type TUI } from "@oh-my-pi/pi-tui";
6
6
  import { theme } from "../theme/theme";
7
+ import { CountdownTimer } from "./countdown-timer";
7
8
  import { DynamicBorder } from "./dynamic-border";
8
9
 
10
+ export interface HookInputOptions {
11
+ tui?: TUI;
12
+ timeout?: number;
13
+ }
14
+
9
15
  export class HookInputComponent extends Container {
10
16
  private input: Input;
11
17
  private onSubmitCallback: (value: string) => void;
12
18
  private onCancelCallback: () => void;
19
+ private titleText: Text;
20
+ private baseTitle: string;
21
+ private countdown: CountdownTimer | undefined;
13
22
 
14
23
  constructor(
15
24
  title: string,
16
25
  _placeholder: string | undefined,
17
26
  onSubmit: (value: string) => void,
18
27
  onCancel: () => void,
28
+ opts?: HookInputOptions,
19
29
  ) {
20
30
  super();
21
31
 
22
32
  this.onSubmitCallback = onSubmit;
23
33
  this.onCancelCallback = onCancel;
34
+ this.baseTitle = title;
24
35
 
25
- // Add top border
26
36
  this.addChild(new DynamicBorder());
27
37
  this.addChild(new Spacer(1));
28
38
 
29
- // Add title
30
- this.addChild(new Text(theme.fg("accent", title), 1, 0));
39
+ this.titleText = new Text(theme.fg("accent", title), 1, 0);
40
+ this.addChild(this.titleText);
31
41
  this.addChild(new Spacer(1));
32
42
 
33
- // Create input
43
+ if (opts?.timeout && opts.timeout > 0 && opts.tui) {
44
+ this.countdown = new CountdownTimer(
45
+ opts.timeout,
46
+ opts.tui,
47
+ (s) => this.titleText.setText(theme.fg("accent", `${this.baseTitle} (${s}s)`)),
48
+ () => this.onCancelCallback(),
49
+ );
50
+ }
51
+
34
52
  this.input = new Input();
35
53
  this.addChild(this.input);
36
-
37
54
  this.addChild(new Spacer(1));
38
-
39
- // Add hint
40
55
  this.addChild(new Text(theme.fg("dim", "enter submit esc cancel"), 1, 0));
41
-
42
56
  this.addChild(new Spacer(1));
43
-
44
- // Add bottom border
45
57
  this.addChild(new DynamicBorder());
46
58
  }
47
59
 
48
60
  handleInput(keyData: string): void {
49
- // Enter
50
61
  if (isEnter(keyData) || keyData === "\n") {
51
62
  this.onSubmitCallback(this.input.getValue());
52
- return;
53
- }
54
-
55
- // Escape to cancel
56
- if (isEscape(keyData)) {
63
+ } else if (isEscape(keyData)) {
57
64
  this.onCancelCallback();
58
- return;
65
+ } else {
66
+ this.input.handleInput(keyData);
59
67
  }
68
+ }
60
69
 
61
- // Forward to input
62
- this.input.handleInput(keyData);
70
+ dispose(): void {
71
+ this.countdown?.dispose();
63
72
  }
64
73
  }