@oh-my-pi/pi-coding-agent 17.1.0 → 17.1.1

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 (112) hide show
  1. package/CHANGELOG.md +24 -0
  2. package/dist/cli.js +3686 -4013
  3. package/dist/types/config/settings-schema.d.ts +58 -0
  4. package/dist/types/extensibility/legacy-pi-coding-agent-shim.d.ts +3 -0
  5. package/dist/types/live/attestation.d.ts +2 -0
  6. package/dist/types/live/controller.d.ts +10 -2
  7. package/dist/types/live/protocol.d.ts +1 -1
  8. package/dist/types/live/transport.d.ts +6 -19
  9. package/dist/types/live/visualizer.d.ts +8 -11
  10. package/dist/types/modes/components/assistant-message.d.ts +1 -0
  11. package/dist/types/modes/components/custom-message.d.ts +1 -1
  12. package/dist/types/modes/components/message-frame.d.ts +8 -4
  13. package/dist/types/modes/components/session-account-selector.d.ts +11 -0
  14. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  15. package/dist/types/modes/interactive-mode.d.ts +1 -0
  16. package/dist/types/modes/types.d.ts +1 -0
  17. package/dist/types/session/agent-session-types.d.ts +8 -1
  18. package/dist/types/session/agent-session.d.ts +20 -1
  19. package/dist/types/session/auth-storage.d.ts +1 -1
  20. package/dist/types/session/eval-runner.d.ts +2 -0
  21. package/dist/types/session/messages.d.ts +2 -0
  22. package/dist/types/session/session-tools.d.ts +15 -0
  23. package/dist/types/session/streaming-output.d.ts +8 -0
  24. package/dist/types/slash-commands/helpers/session-pin.d.ts +9 -0
  25. package/dist/types/stt/index.d.ts +0 -2
  26. package/dist/types/stt/stt-controller.d.ts +7 -0
  27. package/dist/types/tiny/title-client.d.ts +10 -0
  28. package/dist/types/tools/builtin-names.d.ts +1 -1
  29. package/dist/types/tools/computer/protocol.d.ts +43 -0
  30. package/dist/types/tools/computer/supervisor.d.ts +32 -0
  31. package/dist/types/tools/computer/worker-entry.d.ts +1 -0
  32. package/dist/types/tools/computer/worker.d.ts +15 -0
  33. package/dist/types/tools/computer-renderer.d.ts +22 -0
  34. package/dist/types/tools/computer.d.ts +71 -0
  35. package/dist/types/tools/context.d.ts +2 -0
  36. package/dist/types/tools/default-renderer.d.ts +21 -0
  37. package/dist/types/tools/index.d.ts +2 -0
  38. package/dist/types/tts/streaming-player.d.ts +10 -43
  39. package/dist/types/utils/tools-manager.d.ts +1 -2
  40. package/package.json +12 -12
  41. package/src/cli/args.ts +1 -0
  42. package/src/cli/setup-cli.ts +2 -14
  43. package/src/cli.ts +8 -0
  44. package/src/config/model-registry.ts +6 -0
  45. package/src/config/settings-schema.ts +61 -0
  46. package/src/eval/executor-base.ts +1 -0
  47. package/src/eval/js/executor.ts +2 -0
  48. package/src/exec/bash-executor.ts +1 -0
  49. package/src/extensibility/extensions/wrapper.ts +68 -12
  50. package/src/extensibility/legacy-pi-coding-agent-shim.ts +7 -1
  51. package/src/live/attestation.ts +91 -0
  52. package/src/live/controller.ts +76 -23
  53. package/src/live/protocol.test.ts +3 -3
  54. package/src/live/protocol.ts +1 -1
  55. package/src/live/transport.ts +72 -140
  56. package/src/live/visualizer.ts +114 -134
  57. package/src/modes/components/assistant-message.ts +7 -2
  58. package/src/modes/components/custom-message.ts +4 -1
  59. package/src/modes/components/message-frame.ts +14 -8
  60. package/src/modes/components/session-account-selector.ts +62 -0
  61. package/src/modes/components/tool-execution.ts +17 -110
  62. package/src/modes/controllers/input-controller.ts +47 -39
  63. package/src/modes/controllers/live-command-controller.ts +82 -5
  64. package/src/modes/controllers/selector-controller.ts +62 -0
  65. package/src/modes/interactive-mode.ts +19 -0
  66. package/src/modes/types.ts +1 -0
  67. package/src/prompts/system/computer-safety.md +14 -0
  68. package/src/prompts/tools/computer.md +26 -0
  69. package/src/sdk.ts +5 -0
  70. package/src/session/agent-session-types.ts +9 -0
  71. package/src/session/agent-session.ts +56 -0
  72. package/src/session/auth-storage.ts +1 -0
  73. package/src/session/eval-runner.ts +5 -0
  74. package/src/session/messages.ts +3 -0
  75. package/src/session/session-tools.ts +37 -0
  76. package/src/session/streaming-output.ts +52 -5
  77. package/src/slash-commands/builtin-registry.ts +165 -9
  78. package/src/slash-commands/helpers/session-pin.ts +44 -0
  79. package/src/stt/downloader.ts +0 -2
  80. package/src/stt/index.ts +0 -2
  81. package/src/stt/stt-controller.ts +57 -146
  82. package/src/system-prompt.ts +4 -0
  83. package/src/tiny/title-client.ts +22 -0
  84. package/src/tools/bash-interactive.ts +90 -86
  85. package/src/tools/builtin-names.ts +1 -0
  86. package/src/tools/computer/protocol.ts +28 -0
  87. package/src/tools/computer/supervisor.ts +258 -0
  88. package/src/tools/computer/worker-entry.ts +25 -0
  89. package/src/tools/computer/worker.ts +135 -0
  90. package/src/tools/computer-renderer.ts +108 -0
  91. package/src/tools/computer.ts +433 -0
  92. package/src/tools/context.ts +2 -0
  93. package/src/tools/default-renderer.ts +139 -0
  94. package/src/tools/essential-tools.ts +1 -0
  95. package/src/tools/index.ts +5 -0
  96. package/src/tools/renderers.ts +2 -0
  97. package/src/tools/xdev.ts +54 -26
  98. package/src/tts/streaming-player.ts +81 -340
  99. package/src/utils/clipboard.ts +1 -30
  100. package/src/utils/mac-file-urls.applescript +37 -0
  101. package/src/utils/tool-choice.ts +14 -0
  102. package/src/utils/tools-manager.ts +1 -19
  103. package/dist/types/stt/recorder.d.ts +0 -30
  104. package/dist/types/stt/transcriber.d.ts +0 -14
  105. package/dist/types/stt/wav.d.ts +0 -29
  106. package/dist/types/tts/player.d.ts +0 -32
  107. package/src/live/audio-worklet.txt +0 -59
  108. package/src/live/browser-runtime.txt +0 -221
  109. package/src/stt/recorder.ts +0 -551
  110. package/src/stt/transcriber.ts +0 -60
  111. package/src/stt/wav.ts +0 -173
  112. package/src/tts/player.ts +0 -137
@@ -1,22 +1,34 @@
1
- import { type Component, matchesKey, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
1
+ import {
2
+ type Component,
3
+ matchesKey,
4
+ replaceTabs,
5
+ sliceWithWidth,
6
+ truncateToWidth,
7
+ visibleWidth,
8
+ } from "@oh-my-pi/pi-tui";
2
9
  import { sanitizeText } from "@oh-my-pi/pi-utils";
3
10
  import { type ThemeColor, theme } from "../modes/theme/theme";
4
11
 
5
12
  /** Distinct states of a realtime call connection. */
6
13
  export type LivePhase = "connecting" | "listening" | "working" | "speaking" | "muted" | "error";
7
-
8
- /** A transcribed turn in the realtime call. */
9
- export interface LiveTranscript {
10
- role: "user" | "assistant";
11
- text: string;
12
- }
13
-
14
14
  /** Configuration callbacks for user interactions in the visualizer. */
15
15
  export interface LiveVisualizerOptions {
16
16
  onStop(): void;
17
17
  onToggleMute(): void;
18
18
  }
19
19
 
20
+ function normalizeTranscript(text: string): string {
21
+ return replaceTabs(sanitizeText(text)).replace(/\s+/g, " ").trim();
22
+ }
23
+
24
+ function truncateFromStart(text: string, width: number): string {
25
+ if (width <= 0) return "";
26
+ const textWidth = visibleWidth(text);
27
+ if (textWidth <= width) return text;
28
+ if (width === 1) return "…";
29
+ return `…${sliceWithWidth(text, textWidth - width + 1, width - 1, true).text}`;
30
+ }
31
+
20
32
  /** A compact, fixed-height terminal component for displaying a realtime call. */
21
33
  export class LiveVisualizer implements Component {
22
34
  readonly wantsKeyRelease = false;
@@ -25,19 +37,17 @@ export class LiveVisualizer implements Component {
25
37
 
26
38
  #phase: LivePhase = "connecting";
27
39
  #inputLevel = 0;
28
- #outputLevel = 0;
29
- #transcript: LiveTranscript | undefined;
40
+ #displayLevel = 0;
30
41
  #frame = 0;
42
+ #userTranscript = "";
31
43
 
32
44
  #cache:
33
45
  | {
34
46
  width: number;
35
47
  phase: LivePhase;
36
- inputLevel: number;
37
- outputLevel: number;
38
- transcriptRole: string | undefined;
39
- transcriptText: string | undefined;
48
+ displayLevel: number;
40
49
  frame: number;
50
+ userTranscript: string;
41
51
  lines: readonly string[];
42
52
  }
43
53
  | undefined;
@@ -54,34 +64,43 @@ export class LiveVisualizer implements Component {
54
64
  }
55
65
  }
56
66
 
57
- /** Updates the current input and output volume levels (0..1). */
58
- setLevels(input: number, output: number): void {
59
- if (this.#inputLevel !== input || this.#outputLevel !== output) {
60
- this.#inputLevel = input;
61
- this.#outputLevel = output;
62
- this.invalidate();
63
- }
64
- }
65
-
66
- /** Updates the latest transcript fragment. */
67
- setTranscript(transcript: LiveTranscript | undefined): void {
68
- if (this.#transcript?.role !== transcript?.role || this.#transcript?.text !== transcript?.text) {
69
- this.#transcript = transcript ? { ...transcript } : undefined;
70
- this.invalidate();
71
- }
67
+ /** Updates the microphone volume level (0..1). */
68
+ setInputLevel(level: number): void {
69
+ const next = Number.isFinite(level) ? Math.min(1, Math.max(0, level)) : 0;
70
+ if (this.#inputLevel === next) return;
71
+ this.#inputLevel = next;
72
+ if (next > this.#displayLevel) this.#displayLevel = next;
73
+ this.invalidate();
72
74
  }
73
75
 
74
- /** Updates the animation frame for spinners and waveforms. */
76
+ /** Advances the spectrum animation and its peak decay. */
75
77
  setFrame(frame: number): void {
76
- if (this.#frame !== frame) {
78
+ const nextLevel = Math.max(this.#inputLevel, this.#displayLevel * 0.84);
79
+ if (this.#frame !== frame || this.#displayLevel !== nextLevel) {
77
80
  this.#frame = frame;
81
+ this.#displayLevel = nextLevel;
78
82
  this.invalidate();
79
83
  }
80
84
  }
81
85
 
86
+ /** Updates the user's streaming voice transcript. */
87
+ setTranscript(text: string): void {
88
+ const normalized = normalizeTranscript(text);
89
+ if (this.#userTranscript === normalized) return;
90
+ this.#userTranscript = normalized;
91
+ this.invalidate();
92
+ }
93
+
94
+ /** Clears the user's voice transcript row. */
95
+ clearTranscript(): void {
96
+ if (!this.#userTranscript) return;
97
+ this.#userTranscript = "";
98
+ this.invalidate();
99
+ }
100
+
82
101
  /** Processes user keypresses. */
83
102
  handleInput(data: string): void {
84
- if (matchesKey(data, "escape")) {
103
+ if (matchesKey(data, "escape") || matchesKey(data, "ctrl+c")) {
85
104
  this.#options.onStop();
86
105
  } else if (matchesKey(data, "space")) {
87
106
  this.#options.onToggleMute();
@@ -93,67 +112,61 @@ export class LiveVisualizer implements Component {
93
112
  this.#cache = undefined;
94
113
  }
95
114
 
96
- /** Renders the visualizer into a fixed array of rows at the given width. */
115
+ /** Renders the microphone spectrum into a compact fixed-height panel. */
97
116
  render(width: number): readonly string[] {
98
117
  if (
99
118
  this.#cache &&
100
119
  this.#cache.width === width &&
101
120
  this.#cache.phase === this.#phase &&
102
- this.#cache.inputLevel === this.#inputLevel &&
103
- this.#cache.outputLevel === this.#outputLevel &&
121
+ this.#cache.displayLevel === this.#displayLevel &&
104
122
  this.#cache.frame === this.#frame &&
105
- this.#cache.transcriptRole === this.#transcript?.role &&
106
- this.#cache.transcriptText === this.#transcript?.text
123
+ this.#cache.userTranscript === this.#userTranscript
107
124
  ) {
108
125
  return this.#cache.lines;
109
126
  }
110
127
 
111
128
  const lines = this.#renderLines(width);
112
-
113
129
  this.#cache = {
114
130
  width,
115
131
  phase: this.#phase,
116
- inputLevel: this.#inputLevel,
117
- outputLevel: this.#outputLevel,
132
+ displayLevel: this.#displayLevel,
118
133
  frame: this.#frame,
119
- transcriptRole: this.#transcript?.role,
120
- transcriptText: this.#transcript?.text,
134
+ userTranscript: this.#userTranscript,
121
135
  lines,
122
136
  };
123
-
124
137
  return lines;
125
138
  }
126
139
 
127
140
  #renderLines(maxWidth: number): readonly string[] {
128
- const w = Math.max(2, Math.min(maxWidth, 120));
129
- const innerW = w - 2;
130
-
131
- const topBorder = theme.fg("border", `┌${"".repeat(innerW)}${w > 1 ? "" : ""}`);
132
-
133
- const maxWaveW = Math.min(40, Math.max(0, w - 4));
134
- const sideW = Math.max(0, Math.floor((maxWaveW - 3) / 2));
135
-
136
- const inWave = this.#generateWaveform(this.#inputLevel, sideW, true);
137
- const outWave = this.#generateWaveform(this.#outputLevel, sideW, false);
138
-
139
- const inColor: ThemeColor = this.#phase === "muted" ? "dim" : "success";
140
- const outColor: ThemeColor = this.#phase === "error" ? "error" : "accent";
141
-
142
- const waveContent = truncateToWidth(
143
- theme.fg(inColor, inWave) + theme.fg("dim", " │ ") + theme.fg(outColor, outWave),
144
- innerW,
145
- );
146
- const waveLen = visibleWidth(waveContent);
141
+ const width = Math.max(2, maxWidth);
142
+ const innerWidth = width - 2;
143
+ const border = (content: string): string =>
144
+ theme.fg("border", "") + content + (width > 1 ? theme.fg("border", "│") : "");
145
+ const top = theme.fg("border", `┌${"─".repeat(innerWidth)}${width > 1 ? "┐" : ""}`);
146
+ const spectrumColor: ThemeColor = this.#phase === "muted" ? "dim" : this.#phase === "error" ? "error" : "success";
147
+ const spectrum = this.#generateSpectrum(innerWidth, 2);
148
+ const spectrumRows = spectrum.map(row => border(theme.fg(spectrumColor, row)));
149
+ const transcript = this.#renderTranscript(this.#userTranscript, innerWidth, border);
150
+ return [top, ...spectrumRows, transcript, this.#renderFooter(width, innerWidth)];
151
+ }
147
152
 
148
- const wavePadL = Math.floor((innerW - waveLen) / 2);
149
- const wavePadR = innerW - waveLen - wavePadL;
150
- const row1 =
151
- theme.fg("border", "│") +
152
- " ".repeat(Math.max(0, wavePadL)) +
153
- waveContent +
154
- " ".repeat(Math.max(0, wavePadR)) +
155
- (w > 1 ? theme.fg("border", "│") : "");
153
+ #renderTranscript(transcript: string, innerWidth: number, border: (content: string) => string): string {
154
+ const content = truncateFromStart(transcript, innerWidth);
155
+ const padding = " ".repeat(Math.max(0, innerWidth - visibleWidth(content)));
156
+ return border(theme.fg("accent", content) + padding);
157
+ }
156
158
 
159
+ #renderFooter(width: number, innerWidth: number): string {
160
+ const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
161
+ const staticIcons: Record<LivePhase, string> = {
162
+ connecting: "○",
163
+ listening: "●",
164
+ working: "○",
165
+ speaking: "»",
166
+ muted: "×",
167
+ error: "!",
168
+ };
169
+ const icon = this.#phase === "working" ? spinners[this.#frame % spinners.length] : staticIcons[this.#phase];
157
170
  const phaseColors: Record<LivePhase, ThemeColor> = {
158
171
  connecting: "dim",
159
172
  listening: "success",
@@ -162,73 +175,40 @@ export class LiveVisualizer implements Component {
162
175
  muted: "dim",
163
176
  error: "error",
164
177
  };
165
-
166
- const spinners = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
167
- let phaseIcon: string;
168
- if (this.#phase === "working") {
169
- phaseIcon = spinners[this.#frame % spinners.length];
170
- } else {
171
- const staticIcons: Record<LivePhase, string> = {
172
- connecting: "○",
173
- listening: "",
174
- working: "○",
175
- speaking: "»",
176
- muted: "×",
177
- error: "!",
178
- };
179
- phaseIcon = staticIcons[this.#phase];
178
+ const status = `${icon} ${this.#phase}`;
179
+ const fullLabel = ` ${status} · space mute · esc end `;
180
+ const shortLabel = ` ${status} `;
181
+ const label =
182
+ innerWidth >= visibleWidth(fullLabel) + 1
183
+ ? fullLabel
184
+ : innerWidth >= visibleWidth(shortLabel) + 1
185
+ ? shortLabel
186
+ : "";
187
+ if (!label) {
188
+ return theme.fg("border", `└${"─".repeat(innerWidth)}${width > 1 ? "┘" : ""}`);
180
189
  }
181
-
182
- const phaseText = truncateToWidth(` ${phaseIcon} ${this.#phase.toUpperCase()} `, innerW);
183
- const coloredPhase = theme.fg(phaseColors[this.#phase], phaseText);
184
-
185
- const pLen = visibleWidth(coloredPhase);
186
- const pPadL = Math.floor((innerW - pLen) / 2);
187
- const pPadR = innerW - pLen - pPadL;
188
- const row2 =
189
- theme.fg("border", "│") +
190
- " ".repeat(Math.max(0, pPadL)) +
191
- coloredPhase +
192
- " ".repeat(Math.max(0, pPadR)) +
193
- (w > 1 ? theme.fg("border", "│") : "");
194
-
195
- const row3 = theme.fg("border", "│") + " ".repeat(Math.max(0, innerW)) + (w > 1 ? theme.fg("border", "│") : "");
196
-
197
- let transcriptText = "";
198
- if (this.#transcript) {
199
- const cleanText = sanitizeText(this.#transcript.text).replace(/[\r\n\t]+/g, " ");
200
- const roleStr = this.#transcript.role === "user" ? "User: " : "Assistant: ";
201
- transcriptText = theme.fg("dim", roleStr + cleanText);
202
- }
203
- const row4Content = truncateToWidth(` ${transcriptText}`, Math.max(0, innerW - 2)) || "";
204
- const r4Pad = Math.max(0, innerW - visibleWidth(row4Content));
205
- const row4 = theme.fg("border", "│") + row4Content + " ".repeat(r4Pad) + (w > 1 ? theme.fg("border", "│") : "");
206
-
207
- const footerContent = truncateToWidth(" space mute · esc end ", innerW);
208
- const dashCount = Math.max(0, innerW - visibleWidth(footerContent));
209
- let bottomBorder: string;
210
- if (innerW >= visibleWidth(footerContent) + 2) {
211
- bottomBorder =
212
- theme.fg("border", "└─") +
213
- theme.fg("dim", footerContent) +
214
- theme.fg("border", "─".repeat(dashCount - 2) + (w > 1 ? "┘" : ""));
215
- } else {
216
- bottomBorder = theme.fg("border", `└${"─".repeat(Math.max(0, innerW))}${w > 1 ? "┘" : ""}`);
217
- }
218
-
219
- return [topBorder, row1, row2, row3, row4, bottomBorder];
190
+ const remaining = Math.max(0, innerWidth - visibleWidth(label) - 1);
191
+ return (
192
+ theme.fg("border", "└─") +
193
+ theme.fg(phaseColors[this.#phase], truncateToWidth(label, innerWidth - 1)) +
194
+ theme.fg("border", `${"─".repeat(remaining)}${width > 1 ? "┘" : ""}`)
195
+ );
220
196
  }
221
197
 
222
- #generateWaveform(level: number, width: number, reverse: boolean): string {
223
- const chars = [" ", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
224
- let out = "";
225
- for (let i = 0; i < width; i++) {
226
- const dist = reverse ? width - 1 - i : i;
227
- const decay = Math.max(0, 1 - (dist / width) * 1.5);
228
- const val = level * decay * (0.5 + 0.5 * Math.sin(this.#frame * 0.5 + dist * 0.8));
229
- const charIdx = Math.max(0, Math.min(chars.length - 1, Math.floor(val * chars.length)));
230
- out += chars[charIdx];
198
+ #generateSpectrum(width: number, rows: number): string[] {
199
+ const blocks = [" ", "▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
200
+ const output = Array.from({ length: rows }, () => "");
201
+ const energy = this.#phase === "muted" ? 0 : Math.min(1, Math.sqrt(this.#displayLevel * 5));
202
+ const maxHeight = rows * (blocks.length - 1);
203
+ for (let column = 0; column < width; column += 1) {
204
+ const carrier = 0.5 + 0.5 * Math.sin(this.#frame * 0.43 + column * 0.71);
205
+ const shimmer = 0.5 + 0.5 * Math.sin(this.#frame * 0.19 - column * 1.17);
206
+ const height = Math.round(energy * (0.3 + carrier * 0.5 + shimmer * 0.2) * maxHeight);
207
+ for (let row = 0; row < rows; row += 1) {
208
+ const units = Math.max(0, Math.min(blocks.length - 1, height - (rows - row - 1) * 8));
209
+ output[row] += blocks[units];
210
+ }
231
211
  }
232
- return out;
212
+ return output;
233
213
  }
234
214
  }
@@ -235,6 +235,11 @@ export class AssistantMessageComponent extends Container {
235
235
  * on a fresh block that has no live token throughput of its own. */
236
236
  #thinkingRateLive = false;
237
237
 
238
+ #textColorTransform?: (text: string) => string;
239
+
240
+ setTextColorTransform(transform?: (text: string) => string): void {
241
+ this.#textColorTransform = transform;
242
+ }
238
243
  constructor(
239
244
  message?: AssistantMessage,
240
245
  private hideThinkingBlock = false,
@@ -796,8 +801,8 @@ export class AssistantMessageComponent extends Container {
796
801
  if (content.type === "text" && canonicalizeMessage(content.text)) {
797
802
  // Set paddingY=0 to avoid extra spacing before tool executions
798
803
  const trimmed = content.text.trim();
799
- const md = new Markdown(trimmed, 1, 0, getMarkdownTheme());
800
- md.transientRenderCache = this.#lastUpdateTransient;
804
+ const mdOptions = this.#textColorTransform ? { color: this.#textColorTransform } : undefined;
805
+ const md = new Markdown(trimmed, 1, 0, getMarkdownTheme(), mdOptions);
801
806
  this.#contentContainer.addChild(md);
802
807
  captureItems?.push({ md, contentIndex: i, blockType: "text", lastText: trimmed });
803
808
  hasRenderedContent = true;
@@ -2,7 +2,7 @@ import type { Component } from "@oh-my-pi/pi-tui";
2
2
  import { Box, Container } from "@oh-my-pi/pi-tui";
3
3
  import type { MessageRenderer } from "../../extensibility/extensions/types";
4
4
  import { theme } from "../../modes/theme/theme";
5
- import type { CustomMessage } from "../../session/messages";
5
+ import { type CustomMessage, LIVE_DELEGATION_MESSAGE_TYPE } from "../../session/messages";
6
6
  import { renderFramedMessage } from "./message-frame";
7
7
 
8
8
  /**
@@ -49,12 +49,15 @@ export class CustomMessageComponent extends Container {
49
49
  // The transcript dispatch routes both `custom` and legacy `hookMessage` roles here:
50
50
  // tag hooks with the hook glyph, other injected messages with a neutral package.
51
51
  const isHook = (this.message.role as string) === "hookMessage";
52
+ const isLiveDelegation = this.message.customType === LIVE_DELEGATION_MESSAGE_TYPE;
52
53
  const custom = renderFramedMessage({
53
54
  message: this.message,
54
55
  box: this.#box,
55
56
  expanded: this.#expanded,
56
57
  customRenderer: this.customRenderer,
57
58
  icon: isHook ? theme.icon.extensionHook : theme.icon.package,
59
+ hideHeader: isLiveDelegation,
60
+ borderColor: isLiveDelegation ? "borderAccent" : undefined,
58
61
  });
59
62
 
60
63
  if (custom) {
@@ -11,7 +11,7 @@
11
11
  import type { TextContent } from "@oh-my-pi/pi-ai";
12
12
  import type { Box, Component } from "@oh-my-pi/pi-tui";
13
13
  import { Markdown, Spacer, Text } from "@oh-my-pi/pi-tui";
14
- import { getMarkdownTheme, type Theme, theme } from "../../modes/theme/theme";
14
+ import { getMarkdownTheme, type Theme, type ThemeColor, theme } from "../../modes/theme/theme";
15
15
 
16
16
  /** Message shape consumed by the shared frame. */
17
17
  export interface FramedMessage {
@@ -36,6 +36,10 @@ export interface RebuildFrameOptions<M extends FramedMessage> {
36
36
  expanded: boolean;
37
37
  /** Icon glyph shown before the customType in the default header (e.g. a hook/extension icon). */
38
38
  icon?: string;
39
+ /** Hide the default type header while retaining the message body. */
40
+ hideHeader?: boolean;
41
+ /** Semantic color for the outline, defaulting to the muted border. */
42
+ borderColor?: ThemeColor;
39
43
  /** Collapse the markdown body to this many lines when `expanded` is false. Omit to never collapse. */
40
44
  collapseAfterLines?: number;
41
45
  customRenderer?: FramedRenderer<M>;
@@ -43,9 +47,9 @@ export interface RebuildFrameOptions<M extends FramedMessage> {
43
47
 
44
48
  /**
45
49
  * Attempt the custom renderer; on failure or undefined return, populate `box`
46
- * with the default outlined card an `icon customType` header + markdown body
47
- * and return undefined. When the custom renderer succeeds, return its Component
48
- * so the caller can mount it and skip the default box.
50
+ * with the configured outline, optional type header, and markdown body. When
51
+ * the custom renderer succeeds, return its Component so the caller can mount
52
+ * it and skip the default box.
49
53
  */
50
54
  export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameOptions<M>): Component | undefined {
51
55
  if (opts.customRenderer) {
@@ -59,11 +63,13 @@ export function renderFramedMessage<M extends FramedMessage>(opts: RebuildFrameO
59
63
 
60
64
  opts.box.clear();
61
65
  // Match the skill card: a subtle rounded outline so injected messages read as cards.
62
- opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg("borderMuted", t) });
66
+ opts.box.setBorder({ chars: theme.boxRound, color: t => theme.fg(opts.borderColor ?? "borderMuted", t) });
63
67
 
64
- const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType;
65
- opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0));
66
- opts.box.addChild(new Spacer(1));
68
+ if (!opts.hideHeader) {
69
+ const tag = opts.icon ? `${opts.icon} ${opts.message.customType}` : opts.message.customType;
70
+ opts.box.addChild(new Text(theme.fg("customMessageLabel", theme.bold(tag)), 0, 0));
71
+ opts.box.addChild(new Spacer(1));
72
+ }
67
73
 
68
74
  let text: string;
69
75
  if (typeof opts.message.content === "string") {
@@ -0,0 +1,62 @@
1
+ import { Container, type SelectItem, SelectList, type SgrMouseEvent, Spacer, TruncatedText } from "@oh-my-pi/pi-tui";
2
+ import { getSelectListTheme, theme } from "../../modes/theme/theme";
3
+ import type { SessionPinAccount } from "../../slash-commands/helpers/session-pin";
4
+ import { DynamicBorder } from "./dynamic-border";
5
+
6
+ const ACCOUNT_SELECTOR_MAX_VISIBLE = 10;
7
+ const ACCOUNT_LIST_ROW_OFFSET = 4;
8
+
9
+ /** Account picker opened by `/session pin` for the current model provider. */
10
+ export class SessionAccountSelectorComponent extends Container {
11
+ #selectList: SelectList;
12
+
13
+ constructor(
14
+ providerName: string,
15
+ accounts: readonly SessionPinAccount[],
16
+ onSelect: (account: SessionPinAccount) => void,
17
+ onCancel: () => void,
18
+ ) {
19
+ super();
20
+ const accountsByValue = new Map<string, SessionPinAccount>();
21
+ const items: SelectItem[] = accounts.map(account => {
22
+ const value = String(account.credentialId);
23
+ accountsByValue.set(value, account);
24
+ return {
25
+ value,
26
+ label: account.label,
27
+ description: account.active ? "active for this session" : undefined,
28
+ };
29
+ });
30
+
31
+ this.addChild(new DynamicBorder());
32
+ this.addChild(new Spacer(1));
33
+ this.addChild(new TruncatedText(theme.bold(`Select a ${providerName} account for this session:`)));
34
+ this.addChild(new Spacer(1));
35
+
36
+ this.#selectList = new SelectList(
37
+ items,
38
+ Math.min(Math.max(items.length, 1), ACCOUNT_SELECTOR_MAX_VISIBLE),
39
+ getSelectListTheme(),
40
+ );
41
+ const activeIndex = accounts.findIndex(account => account.active);
42
+ if (activeIndex >= 0) this.#selectList.setSelectedIndex(activeIndex);
43
+ this.#selectList.onSelect = item => {
44
+ const account = accountsByValue.get(item.value);
45
+ if (account) onSelect(account);
46
+ };
47
+ this.#selectList.onCancel = onCancel;
48
+ this.addChild(this.#selectList);
49
+ this.addChild(new Spacer(1));
50
+ this.addChild(new DynamicBorder());
51
+ }
52
+
53
+ /** Forward keyboard navigation and cancellation when the wrapper owns focus. */
54
+ handleInput(keyData: string): void {
55
+ this.#selectList.handleInput(keyData);
56
+ }
57
+
58
+ /** Route mouse selection through the title rows into the account list. */
59
+ routeMouse(event: SgrMouseEvent, line: number, col: number): void {
60
+ this.#selectList.routeMouse(event, line - ACCOUNT_LIST_ROW_OFFSET, col);
61
+ }
62
+ }
@@ -19,25 +19,10 @@ import { EDIT_MODE_STRATEGIES, type EditMode, type PerFileDiffPreview } from "..
19
19
  import type { Theme } from "../../modes/theme/theme";
20
20
  import { getThemeEpoch, theme } from "../../modes/theme/theme";
21
21
  import { BASH_DEFAULT_PREVIEW_LINES } from "../../tools/bash";
22
+ import { formatDefaultToolExecution } from "../../tools/default-renderer";
22
23
  import { EVAL_DEFAULT_PREVIEW_LINES } from "../../tools/eval";
23
24
  import { isWaitingPollDetails } from "../../tools/hub";
24
- import {
25
- formatArgsInline,
26
- JSON_TREE_MAX_DEPTH_COLLAPSED,
27
- JSON_TREE_MAX_DEPTH_EXPANDED,
28
- JSON_TREE_MAX_LINES_COLLAPSED,
29
- JSON_TREE_MAX_LINES_EXPANDED,
30
- JSON_TREE_SCALAR_LEN_COLLAPSED,
31
- JSON_TREE_SCALAR_LEN_EXPANDED,
32
- renderJsonTreeLines,
33
- } from "../../tools/json-tree";
34
- import {
35
- formatExpandHint,
36
- formatStatusIcon,
37
- replaceTabs,
38
- resolveImageOptions,
39
- truncateToWidth,
40
- } from "../../tools/render-utils";
25
+ import { formatStatusIcon, replaceTabs, resolveImageOptions } from "../../tools/render-utils";
41
26
  import { type FirstResultViewportRepaint, toolRenderers } from "../../tools/renderers";
42
27
  import { TODO_STRIKE_TOTAL_FRAMES, type TodoToolDetails } from "../../tools/todo";
43
28
  import { isFramedBlockComponent, markFramedBlockComponent, renderStatusLine, WidthAwareText } from "../../tui";
@@ -402,7 +387,21 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
402
387
  // strips PLAIN-blank edges, so framed/minimal blocks (no bg set) drop these
403
388
  // lines and keep their tight spacing — only tinted lines survive.
404
389
  this.#contentBox = new Box(0, 1);
405
- this.#contentText = new WidthAwareText(contentWidth => this.#formatToolExecution(contentWidth), 1, 1);
390
+ this.#contentText = new WidthAwareText(
391
+ contentWidth =>
392
+ formatDefaultToolExecution(
393
+ {
394
+ label: this.#toolLabel,
395
+ args: this.#args,
396
+ result: this.#result ? { output: this.#getTextOutput(), isError: this.#result.isError } : undefined,
397
+ options: this.#renderState,
398
+ },
399
+ contentWidth,
400
+ theme,
401
+ ),
402
+ 1,
403
+ 1,
404
+ );
406
405
 
407
406
  // Use Box for custom tools or built-in tools that have renderers
408
407
  const hasRenderer = toolName in toolRenderers;
@@ -1353,96 +1352,4 @@ export class ToolExecutionComponent extends Container implements NativeScrollbac
1353
1352
 
1354
1353
  return output;
1355
1354
  }
1356
-
1357
- /**
1358
- * Format a generic tool execution (fallback for tools without custom renderers)
1359
- */
1360
- #formatToolExecution(contentWidth: number): string {
1361
- const lines: string[] = [];
1362
- const icon = this.#isPartial
1363
- ? this.#spinnerFrame !== undefined
1364
- ? "running"
1365
- : "pending"
1366
- : this.#result?.isError
1367
- ? "error"
1368
- : "done";
1369
- lines.push(renderStatusLine({ icon, spinnerFrame: this.#spinnerFrame, title: this.#toolLabel }, theme));
1370
-
1371
- const argsObject = this.#args && typeof this.#args === "object" ? (this.#args as Record<string, unknown>) : null;
1372
- if (!this.#expanded && argsObject && Object.keys(argsObject).length > 0) {
1373
- // Budget the inline preview against the render width, leaving room for
1374
- // the ` └─ ` connector prefix instead of a fixed cap.
1375
- const inlineBudget = Math.max(20, contentWidth - Bun.stringWidth(theme.tree.last) - 2);
1376
- const preview = formatArgsInline(argsObject, inlineBudget);
1377
- if (preview) {
1378
- lines.push(` ${theme.fg("dim", theme.tree.last)} ${theme.fg("dim", preview)}`);
1379
- }
1380
- }
1381
-
1382
- if (this.#expanded && this.#args !== undefined) {
1383
- lines.push("");
1384
- lines.push(theme.fg("dim", "Args"));
1385
- const tree = renderJsonTreeLines(
1386
- this.#args,
1387
- theme,
1388
- JSON_TREE_MAX_DEPTH_EXPANDED,
1389
- JSON_TREE_MAX_LINES_EXPANDED,
1390
- JSON_TREE_SCALAR_LEN_EXPANDED,
1391
- );
1392
- lines.push(...tree.lines);
1393
- if (tree.truncated) {
1394
- lines.push(theme.fg("dim", "…"));
1395
- }
1396
- lines.push("");
1397
- }
1398
-
1399
- if (!this.#result) {
1400
- return lines.join("\n");
1401
- }
1402
-
1403
- const textContent = this.#getTextOutput().trimEnd();
1404
- if (!textContent) {
1405
- lines.push(theme.fg("dim", "(no output)"));
1406
- return lines.join("\n");
1407
- }
1408
-
1409
- if (textContent.startsWith("{") || textContent.startsWith("[")) {
1410
- try {
1411
- const parsed = JSON.parse(textContent);
1412
- const maxDepth = this.#expanded ? JSON_TREE_MAX_DEPTH_EXPANDED : JSON_TREE_MAX_DEPTH_COLLAPSED;
1413
- const maxLines = this.#expanded ? JSON_TREE_MAX_LINES_EXPANDED : JSON_TREE_MAX_LINES_COLLAPSED;
1414
- const maxScalarLen = this.#expanded ? JSON_TREE_SCALAR_LEN_EXPANDED : JSON_TREE_SCALAR_LEN_COLLAPSED;
1415
- const tree = renderJsonTreeLines(parsed, theme, maxDepth, maxLines, maxScalarLen);
1416
-
1417
- if (tree.lines.length > 0) {
1418
- lines.push(...tree.lines);
1419
- if (!this.#expanded) {
1420
- lines.push(formatExpandHint(theme, this.#expanded, true));
1421
- } else if (tree.truncated) {
1422
- lines.push(theme.fg("dim", "…"));
1423
- }
1424
- return lines.join("\n");
1425
- }
1426
- } catch {
1427
- // Fall through to raw output
1428
- }
1429
- }
1430
-
1431
- const outputLines = textContent.split("\n");
1432
- const maxOutputLines = this.#expanded ? 12 : 4;
1433
- const displayLines = outputLines.slice(0, maxOutputLines);
1434
-
1435
- for (const line of displayLines) {
1436
- lines.push(theme.fg("toolOutput", truncateToWidth(replaceTabs(line), contentWidth)));
1437
- }
1438
-
1439
- if (outputLines.length > maxOutputLines) {
1440
- const remaining = outputLines.length - maxOutputLines;
1441
- lines.push(`${theme.fg("dim", `… ${remaining} more lines`)} ${formatExpandHint(theme, this.#expanded, true)}`);
1442
- } else if (!this.#expanded) {
1443
- lines.push(formatExpandHint(theme, this.#expanded, true));
1444
- }
1445
-
1446
- return lines.join("\n");
1447
- }
1448
1355
  }