@mariozechner/pi-coding-agent 0.33.0 → 0.34.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 (68) hide show
  1. package/CHANGELOG.md +40 -1
  2. package/README.md +0 -1
  3. package/dist/cli/args.d.ts +5 -1
  4. package/dist/cli/args.d.ts.map +1 -1
  5. package/dist/cli/args.js +18 -1
  6. package/dist/cli/args.js.map +1 -1
  7. package/dist/core/agent-session.d.ts +24 -1
  8. package/dist/core/agent-session.d.ts.map +1 -1
  9. package/dist/core/agent-session.js +65 -9
  10. package/dist/core/agent-session.js.map +1 -1
  11. package/dist/core/bash-executor.d.ts.map +1 -1
  12. package/dist/core/bash-executor.js +2 -1
  13. package/dist/core/bash-executor.js.map +1 -1
  14. package/dist/core/custom-tools/loader.d.ts.map +1 -1
  15. package/dist/core/custom-tools/loader.js +1 -0
  16. package/dist/core/custom-tools/loader.js.map +1 -1
  17. package/dist/core/hooks/index.d.ts +1 -1
  18. package/dist/core/hooks/index.d.ts.map +1 -1
  19. package/dist/core/hooks/index.js.map +1 -1
  20. package/dist/core/hooks/loader.d.ts +56 -1
  21. package/dist/core/hooks/loader.d.ts.map +1 -1
  22. package/dist/core/hooks/loader.js +54 -2
  23. package/dist/core/hooks/loader.js.map +1 -1
  24. package/dist/core/hooks/runner.d.ts +33 -5
  25. package/dist/core/hooks/runner.d.ts.map +1 -1
  26. package/dist/core/hooks/runner.js +100 -9
  27. package/dist/core/hooks/runner.js.map +1 -1
  28. package/dist/core/hooks/types.d.ts +135 -3
  29. package/dist/core/hooks/types.d.ts.map +1 -1
  30. package/dist/core/hooks/types.js.map +1 -1
  31. package/dist/core/sdk.d.ts +3 -0
  32. package/dist/core/sdk.d.ts.map +1 -1
  33. package/dist/core/sdk.js +102 -27
  34. package/dist/core/sdk.js.map +1 -1
  35. package/dist/index.d.ts +1 -1
  36. package/dist/index.d.ts.map +1 -1
  37. package/dist/index.js +1 -1
  38. package/dist/index.js.map +1 -1
  39. package/dist/main.d.ts.map +1 -1
  40. package/dist/main.js +32 -7
  41. package/dist/main.js.map +1 -1
  42. package/dist/modes/interactive/components/custom-editor.d.ts +2 -0
  43. package/dist/modes/interactive/components/custom-editor.d.ts.map +1 -1
  44. package/dist/modes/interactive/components/custom-editor.js +6 -0
  45. package/dist/modes/interactive/components/custom-editor.js.map +1 -1
  46. package/dist/modes/interactive/interactive-mode.d.ts +19 -6
  47. package/dist/modes/interactive/interactive-mode.d.ts.map +1 -1
  48. package/dist/modes/interactive/interactive-mode.js +148 -42
  49. package/dist/modes/interactive/interactive-mode.js.map +1 -1
  50. package/dist/modes/interactive/theme/theme.d.ts +1 -0
  51. package/dist/modes/interactive/theme/theme.d.ts.map +1 -1
  52. package/dist/modes/interactive/theme/theme.js +3 -0
  53. package/dist/modes/interactive/theme/theme.js.map +1 -1
  54. package/dist/modes/print-mode.d.ts.map +1 -1
  55. package/dist/modes/print-mode.js +3 -0
  56. package/dist/modes/print-mode.js.map +1 -1
  57. package/dist/modes/rpc/rpc-mode.d.ts.map +1 -1
  58. package/dist/modes/rpc/rpc-mode.js +16 -0
  59. package/dist/modes/rpc/rpc-mode.js.map +1 -1
  60. package/dist/modes/rpc/rpc-types.d.ts +6 -0
  61. package/dist/modes/rpc/rpc-types.d.ts.map +1 -1
  62. package/dist/modes/rpc/rpc-types.js.map +1 -1
  63. package/docs/hooks.md +114 -4
  64. package/examples/hooks/README.md +3 -0
  65. package/examples/hooks/pirate.ts +44 -0
  66. package/examples/hooks/plan-mode.ts +548 -0
  67. package/examples/hooks/tools.ts +145 -0
  68. package/package.json +4 -4
@@ -7,7 +7,7 @@ import * as fs from "node:fs";
7
7
  import * as os from "node:os";
8
8
  import * as path from "node:path";
9
9
  import Clipboard from "@crosscopy/clipboard";
10
- import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Input, Loader, Markdown, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
10
+ import { CombinedAutocompleteProvider, Container, getEditorKeybindings, Input, Loader, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TruncatedText, TUI, visibleWidth, } from "@mariozechner/pi-tui";
11
11
  import { exec, spawn, spawnSync } from "child_process";
12
12
  import { APP_NAME, getAuthPath, getDebugLogPath } from "../../config.js";
13
13
  import { KeybindingsManager } from "../../core/keybindings.js";
@@ -90,11 +90,11 @@ export class InteractiveMode {
90
90
  hookSelector = undefined;
91
91
  hookInput = undefined;
92
92
  hookEditor = undefined;
93
+ // Hook widgets (components rendered above the editor)
94
+ hookWidgets = new Map();
95
+ widgetContainer;
93
96
  // Custom tools for custom rendering
94
97
  customTools;
95
- // Clipboard image tracking: imageId -> temp file path
96
- clipboardImages = new Map();
97
- clipboardImageCounter = 0;
98
98
  // Convenience accessors
99
99
  get agent() {
100
100
  return this.session.agent;
@@ -115,6 +115,7 @@ export class InteractiveMode {
115
115
  this.chatContainer = new Container();
116
116
  this.pendingMessagesContainer = new Container();
117
117
  this.statusContainer = new Container();
118
+ this.widgetContainer = new Container();
118
119
  this.keybindings = KeybindingsManager.create();
119
120
  this.editor = new CustomEditor(getEditorTheme(), this.keybindings);
120
121
  this.editorContainer = new Container();
@@ -254,6 +255,7 @@ export class InteractiveMode {
254
255
  this.ui.addChild(this.chatContainer);
255
256
  this.ui.addChild(this.pendingMessagesContainer);
256
257
  this.ui.addChild(this.statusContainer);
258
+ this.ui.addChild(this.widgetContainer);
257
259
  this.ui.addChild(new Spacer(1));
258
260
  this.ui.addChild(this.editorContainer);
259
261
  this.ui.addChild(this.footer);
@@ -322,20 +324,7 @@ export class InteractiveMode {
322
324
  this.chatContainer.addChild(new Spacer(1));
323
325
  }
324
326
  // Create and set hook & tool UI context
325
- const uiContext = {
326
- select: (title, options) => this.showHookSelector(title, options),
327
- confirm: (title, message) => this.showHookConfirm(title, message),
328
- input: (title, placeholder) => this.showHookInput(title, placeholder),
329
- notify: (message, type) => this.showHookNotify(message, type),
330
- setStatus: (key, text) => this.setHookStatus(key, text),
331
- custom: (factory) => this.showHookCustom(factory),
332
- setEditorText: (text) => this.editor.setText(text),
333
- getEditorText: () => this.editor.getText(),
334
- editor: (title, prefill) => this.showHookEditor(title, prefill),
335
- get theme() {
336
- return theme;
337
- },
338
- };
327
+ const uiContext = this.createHookUIContext();
339
328
  this.setToolUIContext(uiContext, true);
340
329
  // Notify custom tools of session start
341
330
  await this.emitCustomToolSessionEvent({
@@ -366,6 +355,9 @@ export class InteractiveMode {
366
355
  appendEntryHandler: (customType, data) => {
367
356
  this.sessionManager.appendCustomEntry(customType, data);
368
357
  },
358
+ getActiveToolsHandler: () => this.session.getActiveToolNames(),
359
+ getAllToolsHandler: () => this.session.getAllToolNames(),
360
+ setActiveToolsHandler: (toolNames) => this.session.setActiveToolsByName(toolNames),
369
361
  newSessionHandler: async (options) => {
370
362
  // Stop any loading animation
371
363
  if (this.loadingAnimation) {
@@ -430,8 +422,10 @@ export class InteractiveMode {
430
422
  });
431
423
  // Subscribe to hook errors
432
424
  hookRunner.onError((error) => {
433
- this.showHookError(error.hookPath, error.error);
425
+ this.showHookError(error.hookPath, error.error, error.stack);
434
426
  });
427
+ // Set up hook-registered shortcuts
428
+ this.setupHookShortcuts(hookRunner);
435
429
  // Show loaded hooks
436
430
  const hookPaths = hookRunner.getHookPaths();
437
431
  if (hookPaths.length > 0) {
@@ -476,6 +470,40 @@ export class InteractiveMode {
476
470
  this.chatContainer.addChild(errorText);
477
471
  this.ui.requestRender();
478
472
  }
473
+ /**
474
+ * Set up keyboard shortcuts registered by hooks.
475
+ */
476
+ setupHookShortcuts(hookRunner) {
477
+ const shortcuts = hookRunner.getShortcuts();
478
+ if (shortcuts.size === 0)
479
+ return;
480
+ // Create a context for shortcut handlers
481
+ const createContext = () => ({
482
+ ui: this.createHookUIContext(),
483
+ hasUI: true,
484
+ cwd: process.cwd(),
485
+ sessionManager: this.sessionManager,
486
+ modelRegistry: this.session.modelRegistry,
487
+ model: this.session.model,
488
+ isIdle: () => !this.session.isStreaming,
489
+ abort: () => this.session.abort(),
490
+ hasPendingMessages: () => this.session.pendingMessageCount > 0,
491
+ });
492
+ // Set up the hook shortcut handler on the editor
493
+ this.editor.onHookShortcut = (data) => {
494
+ for (const [shortcutStr, shortcut] of shortcuts) {
495
+ // Cast to KeyId - hook shortcuts use the same format
496
+ if (matchesKey(data, shortcutStr)) {
497
+ // Run handler async, don't block input
498
+ Promise.resolve(shortcut.handler(createContext())).catch((err) => {
499
+ this.showError(`Shortcut handler error: ${err instanceof Error ? err.message : String(err)}`);
500
+ });
501
+ return true;
502
+ }
503
+ }
504
+ return false;
505
+ };
506
+ }
479
507
  /**
480
508
  * Set hook status text in the footer.
481
509
  */
@@ -483,6 +511,73 @@ export class InteractiveMode {
483
511
  this.footer.setHookStatus(key, text);
484
512
  this.ui.requestRender();
485
513
  }
514
+ /**
515
+ * Set a hook widget (string array or custom component).
516
+ */
517
+ setHookWidget(key, content) {
518
+ // Dispose and remove existing widget
519
+ const existing = this.hookWidgets.get(key);
520
+ if (existing?.dispose)
521
+ existing.dispose();
522
+ if (content === undefined) {
523
+ this.hookWidgets.delete(key);
524
+ }
525
+ else if (Array.isArray(content)) {
526
+ // Wrap string array in a Container with Text components
527
+ const container = new Container();
528
+ for (const line of content.slice(0, InteractiveMode.MAX_WIDGET_LINES)) {
529
+ container.addChild(new Text(line, 1, 0));
530
+ }
531
+ if (content.length > InteractiveMode.MAX_WIDGET_LINES) {
532
+ container.addChild(new Text(theme.fg("muted", "... (widget truncated)"), 1, 0));
533
+ }
534
+ this.hookWidgets.set(key, container);
535
+ }
536
+ else {
537
+ // Factory function - create component
538
+ const component = content(this.ui, theme);
539
+ this.hookWidgets.set(key, component);
540
+ }
541
+ this.renderWidgets();
542
+ }
543
+ // Maximum total widget lines to prevent viewport overflow
544
+ static MAX_WIDGET_LINES = 10;
545
+ /**
546
+ * Render all hook widgets to the widget container.
547
+ */
548
+ renderWidgets() {
549
+ if (!this.widgetContainer)
550
+ return;
551
+ this.widgetContainer.clear();
552
+ if (this.hookWidgets.size === 0) {
553
+ this.ui.requestRender();
554
+ return;
555
+ }
556
+ for (const [_key, component] of this.hookWidgets) {
557
+ this.widgetContainer.addChild(component);
558
+ }
559
+ this.ui.requestRender();
560
+ }
561
+ /**
562
+ * Create the HookUIContext for hooks and tools.
563
+ */
564
+ createHookUIContext() {
565
+ return {
566
+ select: (title, options) => this.showHookSelector(title, options),
567
+ confirm: (title, message) => this.showHookConfirm(title, message),
568
+ input: (title, placeholder) => this.showHookInput(title, placeholder),
569
+ notify: (message, type) => this.showHookNotify(message, type),
570
+ setStatus: (key, text) => this.setHookStatus(key, text),
571
+ setWidget: (key, content) => this.setHookWidget(key, content),
572
+ custom: (factory) => this.showHookCustom(factory),
573
+ setEditorText: (text) => this.editor.setText(text),
574
+ getEditorText: () => this.editor.getText(),
575
+ editor: (title, prefill) => this.showHookEditor(title, prefill),
576
+ get theme() {
577
+ return theme;
578
+ },
579
+ };
580
+ }
486
581
  /**
487
582
  * Show a selector for hooks.
488
583
  */
@@ -616,9 +711,21 @@ export class InteractiveMode {
616
711
  /**
617
712
  * Show a hook error in the UI.
618
713
  */
619
- showHookError(hookPath, error) {
620
- const errorText = new Text(theme.fg("error", `Hook "${hookPath}" error: ${error}`), 1, 0);
714
+ showHookError(hookPath, error, stack) {
715
+ const errorMsg = `Hook "${hookPath}" error: ${error}`;
716
+ const errorText = new Text(theme.fg("error", errorMsg), 1, 0);
621
717
  this.chatContainer.addChild(errorText);
718
+ if (stack) {
719
+ // Show stack trace in dim color, indented
720
+ const stackLines = stack
721
+ .split("\n")
722
+ .slice(1) // Skip first line (duplicates error message)
723
+ .map((line) => theme.fg("dim", ` ${line.trim()}`))
724
+ .join("\n");
725
+ if (stackLines) {
726
+ this.chatContainer.addChild(new Text(stackLines, 1, 0));
727
+ }
728
+ }
622
729
  this.ui.requestRender();
623
730
  }
624
731
  /**
@@ -702,33 +809,18 @@ export class InteractiveMode {
702
809
  return;
703
810
  }
704
811
  // Write to temp file
705
- const imageId = ++this.clipboardImageCounter;
706
812
  const tmpDir = os.tmpdir();
707
813
  const fileName = `pi-clipboard-${crypto.randomUUID()}.png`;
708
814
  const filePath = path.join(tmpDir, fileName);
709
815
  fs.writeFileSync(filePath, Buffer.from(imageData));
710
- // Store mapping and insert marker
711
- this.clipboardImages.set(imageId, filePath);
712
- this.editor.insertTextAtCursor(`[image #${imageId}]`);
816
+ // Insert file path directly
817
+ this.editor.insertTextAtCursor(filePath);
713
818
  this.ui.requestRender();
714
819
  }
715
820
  catch {
716
821
  // Silently ignore clipboard errors (may not have permission, etc.)
717
822
  }
718
823
  }
719
- /**
720
- * Replace [image #N] markers with actual file paths and clear the image map.
721
- */
722
- replaceImageMarkers(text) {
723
- let result = text;
724
- for (const [imageId, filePath] of this.clipboardImages) {
725
- const marker = `[image #${imageId}]`;
726
- result = result.replace(marker, filePath);
727
- }
728
- this.clipboardImages.clear();
729
- this.clipboardImageCounter = 0;
730
- return result;
731
- }
732
824
  setupEditorSubmitHandler() {
733
825
  this.editor.onSubmit = async (text) => {
734
826
  text = text.trim();
@@ -854,8 +946,6 @@ export class InteractiveMode {
854
946
  return;
855
947
  }
856
948
  // If streaming, use prompt() with steer behavior
857
- // Replace image markers with actual file paths
858
- text = this.replaceImageMarkers(text);
859
949
  // This handles hook commands (execute immediately), slash command expansion, and queueing
860
950
  if (this.session.isStreaming) {
861
951
  this.editor.addToHistory(text);
@@ -1394,7 +1484,7 @@ export class InteractiveMode {
1394
1484
  this.showWarning("No editor configured. Set $VISUAL or $EDITOR environment variable.");
1395
1485
  return;
1396
1486
  }
1397
- const currentText = this.editor.getText();
1487
+ const currentText = this.editor.getExpandedText();
1398
1488
  const tmpFile = path.join(os.tmpdir(), `pi-editor-${Date.now()}.pi.md`);
1399
1489
  try {
1400
1490
  // Write current content to temp file
@@ -2049,7 +2139,7 @@ export class InteractiveMode {
2049
2139
  const toggleThinking = this.getAppKeyDisplay("toggleThinking");
2050
2140
  const externalEditor = this.getAppKeyDisplay("externalEditor");
2051
2141
  const followUp = this.getAppKeyDisplay("followUp");
2052
- const hotkeys = `
2142
+ let hotkeys = `
2053
2143
  **Navigation**
2054
2144
  | Key | Action |
2055
2145
  |-----|--------|
@@ -2085,6 +2175,22 @@ export class InteractiveMode {
2085
2175
  | \`/\` | Slash commands |
2086
2176
  | \`!\` | Run bash command |
2087
2177
  `;
2178
+ // Add hook-registered shortcuts
2179
+ const hookRunner = this.session.hookRunner;
2180
+ if (hookRunner) {
2181
+ const shortcuts = hookRunner.getShortcuts();
2182
+ if (shortcuts.size > 0) {
2183
+ hotkeys += `
2184
+ **Hooks**
2185
+ | Key | Action |
2186
+ |-----|--------|
2187
+ `;
2188
+ for (const [key, shortcut] of shortcuts) {
2189
+ const description = shortcut.description ?? shortcut.hookPath;
2190
+ hotkeys += `| \`${key}\` | ${description} |\n`;
2191
+ }
2192
+ }
2193
+ }
2088
2194
  this.chatContainer.addChild(new Spacer(1));
2089
2195
  this.chatContainer.addChild(new DynamicBorder());
2090
2196
  this.chatContainer.addChild(new Text(theme.bold(theme.fg("accent", "Keyboard Shortcuts")), 1, 0));