@oh-my-pi/pi-coding-agent 15.9.5 → 15.9.67

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 (98) hide show
  1. package/CHANGELOG.md +35 -0
  2. package/dist/types/config/keybindings.d.ts +4 -1
  3. package/dist/types/config/settings-schema.d.ts +11 -1
  4. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  5. package/dist/types/eval/__tests__/kernel-spawn.test.d.ts +1 -0
  6. package/dist/types/eval/backend.d.ts +6 -6
  7. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  8. package/dist/types/eval/idle-timeout.d.ts +16 -14
  9. package/dist/types/eval/js/executor.d.ts +3 -3
  10. package/dist/types/eval/py/executor.d.ts +2 -2
  11. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  12. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  13. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  14. package/dist/types/modes/components/model-selector.d.ts +1 -0
  15. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  16. package/dist/types/modes/controllers/selector-controller.d.ts +1 -0
  17. package/dist/types/modes/interactive-mode.d.ts +1 -1
  18. package/dist/types/modes/types.d.ts +1 -1
  19. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  20. package/dist/types/tools/eval-render.d.ts +8 -0
  21. package/dist/types/tools/render-utils.d.ts +25 -0
  22. package/dist/types/tui/code-cell.d.ts +6 -0
  23. package/dist/types/tui/output-block.d.ts +11 -0
  24. package/package.json +9 -9
  25. package/src/autoresearch/dashboard.ts +11 -21
  26. package/src/cli/claude-trace-cli.ts +13 -1
  27. package/src/config/keybindings.ts +58 -1
  28. package/src/config/settings-schema.ts +11 -1
  29. package/src/debug/raw-sse.ts +18 -4
  30. package/src/edit/file-snapshot-store.ts +1 -1
  31. package/src/edit/index.ts +1 -1
  32. package/src/edit/renderer.ts +7 -7
  33. package/src/edit/streaming.ts +1 -1
  34. package/src/eval/__tests__/agent-bridge.test.ts +28 -27
  35. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  36. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  37. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  38. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  39. package/src/eval/__tests__/shared-executors.test.ts +2 -2
  40. package/src/eval/agent-bridge.ts +4 -5
  41. package/src/eval/backend.ts +6 -6
  42. package/src/eval/bridge-timeout.ts +44 -0
  43. package/src/eval/idle-timeout.ts +33 -15
  44. package/src/eval/js/executor.ts +10 -10
  45. package/src/eval/llm-bridge.ts +4 -5
  46. package/src/eval/py/executor.ts +6 -6
  47. package/src/eval/py/kernel.ts +11 -1
  48. package/src/eval/py/spawn-options.ts +126 -0
  49. package/src/export/ttsr.ts +9 -0
  50. package/src/extensibility/extensions/runner.ts +2 -0
  51. package/src/internal-urls/docs-index.generated.ts +6 -5
  52. package/src/lsp/client.ts +80 -2
  53. package/src/lsp/index.ts +38 -4
  54. package/src/lsp/render.ts +3 -3
  55. package/src/main.ts +1 -1
  56. package/src/modes/components/agent-dashboard.ts +13 -4
  57. package/src/modes/components/assistant-message.ts +22 -1
  58. package/src/modes/components/copy-selector.ts +249 -0
  59. package/src/modes/components/extensions/extension-list.ts +17 -8
  60. package/src/modes/components/history-search.ts +19 -11
  61. package/src/modes/components/model-selector.ts +125 -29
  62. package/src/modes/components/oauth-selector.ts +28 -12
  63. package/src/modes/components/session-observer-overlay.ts +13 -15
  64. package/src/modes/components/session-selector.ts +24 -13
  65. package/src/modes/components/tool-execution.ts +27 -13
  66. package/src/modes/components/tree-selector.ts +19 -7
  67. package/src/modes/components/user-message-selector.ts +25 -14
  68. package/src/modes/controllers/command-controller.ts +0 -116
  69. package/src/modes/controllers/event-controller.ts +26 -10
  70. package/src/modes/controllers/selector-controller.ts +38 -1
  71. package/src/modes/interactive-mode.ts +4 -4
  72. package/src/modes/theme/theme.ts +46 -10
  73. package/src/modes/types.ts +1 -1
  74. package/src/modes/utils/copy-targets.ts +254 -0
  75. package/src/prompts/tools/ast-edit.md +1 -1
  76. package/src/prompts/tools/ast-grep.md +1 -1
  77. package/src/prompts/tools/read.md +1 -1
  78. package/src/prompts/tools/search.md +1 -1
  79. package/src/session/agent-session.ts +6 -2
  80. package/src/slash-commands/builtin-registry.ts +3 -11
  81. package/src/task/render.ts +38 -11
  82. package/src/tools/bash.ts +18 -8
  83. package/src/tools/browser/render.ts +5 -4
  84. package/src/tools/debug.ts +3 -3
  85. package/src/tools/eval-render.ts +24 -9
  86. package/src/tools/eval.ts +14 -19
  87. package/src/tools/fetch.ts +5 -5
  88. package/src/tools/read.ts +7 -7
  89. package/src/tools/render-utils.ts +46 -0
  90. package/src/tools/ssh.ts +21 -8
  91. package/src/tools/write.ts +17 -8
  92. package/src/tui/code-cell.ts +19 -4
  93. package/src/tui/output-block.ts +14 -0
  94. package/src/web/search/render.ts +3 -3
  95. package/dist/types/eval/heartbeat.d.ts +0 -45
  96. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  97. package/src/eval/heartbeat.ts +0 -74
  98. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
package/src/lsp/client.ts CHANGED
@@ -1,3 +1,4 @@
1
+ import * as path from "node:path";
1
2
  import { isEnoent, logger, ptree, untilAborted } from "@oh-my-pi/pi-utils";
2
3
  import { ToolAbortError, throwIfAborted } from "../tools/tool-errors";
3
4
  import { applyWorkspaceEdit } from "./edits";
@@ -147,6 +148,7 @@ const CLIENT_CAPABILITIES = {
147
148
  failureHandling: "textOnlyTransactional",
148
149
  },
149
150
  configuration: true,
151
+ workspaceFolders: true,
150
152
  symbol: {
151
153
  dynamicRegistration: false,
152
154
  symbolKind: {
@@ -318,6 +320,22 @@ async function startMessageReader(client: LspClient): Promise<void> {
318
320
  }
319
321
  }
320
322
 
323
+ /**
324
+ * Build the workspace folder list advertised to the server. Identical shape
325
+ * for `initialize` params and `workspace/workspaceFolders` server requests.
326
+ */
327
+ function currentWorkspaceFolders(client: LspClient): Array<{ uri: string; name: string }> {
328
+ return [{ uri: fileToUri(client.cwd), name: path.basename(client.cwd) || "workspace" }];
329
+ }
330
+
331
+ /**
332
+ * Handle workspace/workspaceFolders requests from the server.
333
+ */
334
+ async function handleWorkspaceFoldersRequest(client: LspClient, message: LspJsonRpcRequest): Promise<void> {
335
+ if (typeof message.id !== "number") return;
336
+ await sendResponse(client, message.id, currentWorkspaceFolders(client), "workspace/workspaceFolders");
337
+ }
338
+
321
339
  /**
322
340
  * Handle workspace/configuration requests from the server.
323
341
  */
@@ -364,6 +382,10 @@ async function handleServerRequest(client: LspClient, message: LspJsonRpcRequest
364
382
  await handleConfigurationRequest(client, message);
365
383
  return;
366
384
  }
385
+ if (message.method === "workspace/workspaceFolders") {
386
+ await handleWorkspaceFoldersRequest(client, message);
387
+ return;
388
+ }
367
389
  if (message.method === "workspace/applyEdit") {
368
390
  await handleApplyEditRequest(client, message);
369
391
  return;
@@ -412,7 +434,60 @@ async function sendResponse(
412
434
  /** Timeout for warmup initialize requests (5 seconds) */
413
435
  export const WARMUP_TIMEOUT_MS = 5000;
414
436
 
415
- /** Max time to wait for the server to report project loading completion via $/progress */
437
+ /** Max time to poll rust-analyzer after progress ends but before Cargo workspaces are ready. */
438
+ const RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS = 5_000;
439
+ const RUST_ANALYZER_WORKSPACE_READY_POLL_MS = 100;
440
+ const RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS = 2_000;
441
+ const rustAnalyzerReadyClients = new WeakSet<LspClient>();
442
+
443
+ function commandBasename(command: string): string {
444
+ const slash = command.lastIndexOf("/");
445
+ const backslash = command.lastIndexOf("\\");
446
+ const separator = Math.max(slash, backslash);
447
+ return separator === -1 ? command : command.slice(separator + 1);
448
+ }
449
+
450
+ function isRustAnalyzerClient(client: LspClient): boolean {
451
+ return (
452
+ commandBasename(client.config.command) === "rust-analyzer" ||
453
+ (client.config.resolvedCommand ? commandBasename(client.config.resolvedCommand) === "rust-analyzer" : false)
454
+ );
455
+ }
456
+
457
+ function isRustAnalyzerStatusTimeout(err: unknown): boolean {
458
+ return err instanceof Error && err.message.startsWith("LSP request rust-analyzer/analyzerStatus timed out after ");
459
+ }
460
+
461
+ async function waitForRustAnalyzerWorkspace(client: LspClient, signal?: AbortSignal): Promise<void> {
462
+ if (rustAnalyzerReadyClients.has(client)) {
463
+ return;
464
+ }
465
+ const started = Date.now();
466
+ const deadline = started + RUST_ANALYZER_WORKSPACE_READY_TIMEOUT_MS;
467
+ while (true) {
468
+ throwIfAborted(signal);
469
+ let status: unknown;
470
+ try {
471
+ status = await sendRequest(client, "rust-analyzer/analyzerStatus", {}, signal, 1_000);
472
+ } catch (err) {
473
+ if (!isRustAnalyzerStatusTimeout(err) || Date.now() >= deadline) {
474
+ return;
475
+ }
476
+ await Bun.sleep(RUST_ANALYZER_WORKSPACE_READY_POLL_MS);
477
+ continue;
478
+ }
479
+ const ready = typeof status === "string" && !status.startsWith("No workspaces");
480
+ if (ready && Date.now() - started >= RUST_ANALYZER_WORKSPACE_READY_SETTLE_MS) {
481
+ rustAnalyzerReadyClients.add(client);
482
+ return;
483
+ }
484
+ if (Date.now() >= deadline) {
485
+ return;
486
+ }
487
+ await Bun.sleep(RUST_ANALYZER_WORKSPACE_READY_POLL_MS);
488
+ }
489
+ }
490
+
416
491
  const PROJECT_LOAD_TIMEOUT_MS = 15_000;
417
492
 
418
493
  /** Max time to wait for graceful LSP shutdown and process exit. */
@@ -530,7 +605,7 @@ export async function getOrCreateClient(config: ServerConfig, cwd: string, initT
530
605
  rootPath: cwd,
531
606
  capabilities: CLIENT_CAPABILITIES,
532
607
  initializationOptions: config.initOptions ?? {},
533
- workspaceFolders: [{ uri: fileToUri(cwd), name: cwd.split("/").pop() ?? "workspace" }],
608
+ workspaceFolders: currentWorkspaceFolders(client),
534
609
  },
535
610
  undefined, // signal
536
611
  initTimeoutMs,
@@ -635,6 +710,9 @@ export async function waitForProjectLoaded(client: LspClient, signal?: AbortSign
635
710
  ? [new Promise<void>(resolve => signal.addEventListener("abort", () => resolve(), { once: true }))]
636
711
  : []),
637
712
  ]);
713
+ if (isRustAnalyzerClient(client)) {
714
+ await waitForRustAnalyzerWorkspace(client, signal);
715
+ }
638
716
  }
639
717
 
640
718
  /**
package/src/lsp/index.ts CHANGED
@@ -304,6 +304,32 @@ const SINGLE_DIAGNOSTICS_WAIT_TIMEOUT_MS = 3000;
304
304
  const BATCH_DIAGNOSTICS_WAIT_TIMEOUT_MS = 400;
305
305
  const MAX_GLOB_DIAGNOSTIC_TARGETS = 20;
306
306
  const WORKSPACE_SYMBOL_LIMIT = 200;
307
+ const PROJECT_INDEXED_ACTIONS: ReadonlySet<string> = new Set([
308
+ "definition",
309
+ "type_definition",
310
+ "implementation",
311
+ "references",
312
+ "rename",
313
+ "hover",
314
+ ]);
315
+
316
+ const RUST_WORKSPACE_MARKERS = ["Cargo.toml", "rust-analyzer.toml"] as const;
317
+
318
+ function hasRustWorkspaceAncestor(filePath: string): boolean {
319
+ let dir = path.dirname(filePath);
320
+ while (true) {
321
+ for (const marker of RUST_WORKSPACE_MARKERS) {
322
+ if (fs.existsSync(path.join(dir, marker))) {
323
+ return true;
324
+ }
325
+ }
326
+ const parent = path.dirname(dir);
327
+ if (parent === dir) {
328
+ return false;
329
+ }
330
+ dir = parent;
331
+ }
332
+ }
307
333
 
308
334
  function limitDiagnosticMessages(messages: string[]): string[] {
309
335
  if (messages.length <= DIAGNOSTIC_MESSAGE_LIMIT) {
@@ -1940,10 +1966,21 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1940
1966
  try {
1941
1967
  const client = await getOrCreateClient(serverConfig, this.session.cwd);
1942
1968
  const targetFile = resolvedFile;
1969
+ const isRustAnalyzerServer =
1970
+ serverName === "rust-analyzer" ||
1971
+ path.basename(serverConfig.command) === "rust-analyzer" ||
1972
+ (serverConfig.resolvedCommand ? path.basename(serverConfig.resolvedCommand) === "rust-analyzer" : false);
1973
+ const needsProjectIndex =
1974
+ targetFile !== null && PROJECT_INDEXED_ACTIONS.has(action) && isProjectAwareLspServer(serverConfig);
1975
+ const rustWorkspaceWait =
1976
+ needsProjectIndex && isRustAnalyzerServer && targetFile !== null && hasRustWorkspaceAncestor(targetFile);
1943
1977
 
1944
1978
  if (targetFile) {
1945
1979
  await ensureFileOpen(client, targetFile, signal);
1946
1980
  }
1981
+ if (rustWorkspaceWait) {
1982
+ await waitForProjectLoaded(client, signal);
1983
+ }
1947
1984
 
1948
1985
  // For project-aware servers, references/rename/definition without a `symbol`
1949
1986
  // silently falls back to the first non-whitespace column on the line, which
@@ -1968,10 +2005,7 @@ export class LspTool implements AgentTool<typeof lspSchema, LspToolDetails, Them
1968
2005
 
1969
2006
  let output: string;
1970
2007
 
1971
- // Wait for project loading to complete before cross-file operations
1972
- // to ensure the server has indexed all project files.
1973
- const crossFileActions = new Set(["definition", "type_definition", "implementation", "references", "rename"]);
1974
- if (crossFileActions.has(action)) {
2008
+ if (needsProjectIndex && !isRustAnalyzerServer) {
1975
2009
  await waitForProjectLoaded(client, signal);
1976
2010
  }
1977
2011
 
package/src/lsp/render.ts CHANGED
@@ -21,7 +21,7 @@ import {
21
21
  truncateToWidth,
22
22
  } from "../tools/render-utils";
23
23
  import { renderStatusLine } from "../tui";
24
- import { CachedOutputBlock } from "../tui/output-block";
24
+ import { CachedOutputBlock, markFramedBlockComponent } from "../tui/output-block";
25
25
  import type { LspParams, LspToolDetails } from "./types";
26
26
 
27
27
  // =============================================================================
@@ -138,7 +138,7 @@ export function renderResult(
138
138
 
139
139
  const outputBlock = new CachedOutputBlock();
140
140
 
141
- return {
141
+ return markFramedBlockComponent({
142
142
  render(width: number): string[] {
143
143
  // Read mutable state at render time
144
144
  const { expanded, isPartial, spinnerFrame } = options;
@@ -194,7 +194,7 @@ export function renderResult(
194
194
  invalidate() {
195
195
  outputBlock.invalidate();
196
196
  },
197
- };
197
+ });
198
198
  }
199
199
 
200
200
  // =============================================================================
package/src/main.ts CHANGED
@@ -268,7 +268,7 @@ async function runInteractiveMode(
268
268
  force: forceSetupWizard,
269
269
  });
270
270
 
271
- await mode.init({ suppressWelcomeIntro: setupScenes.length > 0 });
271
+ await mode.init({ suppressWelcomeIntro: resuming || setupScenes.length > 0 });
272
272
 
273
273
  if (setupScenes.length > 0) {
274
274
  await runSetupWizard(mode, setupScenes);
@@ -27,6 +27,7 @@ import {
27
27
  matchesKey,
28
28
  padding,
29
29
  replaceTabs,
30
+ ScrollView,
30
31
  Spacer,
31
32
  Text,
32
33
  truncateToWidth,
@@ -205,9 +206,12 @@ class AgentListPane implements Component {
205
206
  return lines;
206
207
  }
207
208
 
209
+ const overflow = this.agents.length > this.maxVisible;
210
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
208
211
  const start = this.scrollOffset;
209
212
  const end = Math.min(start + this.maxVisible, this.agents.length);
210
213
 
214
+ const rows: string[] = [];
211
215
  for (let i = start; i < end; i++) {
212
216
  const agent = this.agents[i];
213
217
  const selected = i === this.selectedIndex;
@@ -224,12 +228,17 @@ class AgentListPane implements Component {
224
228
  line = theme.fg("dim", line);
225
229
  }
226
230
 
227
- lines.push(truncateToWidth(line, width));
231
+ rows.push(truncateToWidth(line, rowWidth));
228
232
  }
229
233
 
230
- if (this.agents.length > this.maxVisible) {
231
- lines.push(theme.fg("muted", ` (${this.selectedIndex + 1}/${this.agents.length})`));
232
- }
234
+ const sv = new ScrollView(rows, {
235
+ height: rows.length,
236
+ scrollbar: "auto",
237
+ totalRows: this.agents.length,
238
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
239
+ });
240
+ sv.setScrollOffset(this.scrollOffset);
241
+ lines.push(...sv.render(width));
233
242
 
234
243
  return lines;
235
244
  }
@@ -18,6 +18,15 @@ export class AssistantMessageComponent extends Container {
18
18
  #convertedKittyImages = new Map<string, ImageContent>();
19
19
  #kittyConversionsInFlight = new Set<string>();
20
20
  #transcriptBlockFinalized: boolean;
21
+ /**
22
+ * When true, the turn-ending `Error: …` line for `stopReason === "error"` is
23
+ * suppressed because the same error is currently shown in the pinned banner
24
+ * above the editor (see `EventController` + `ErrorBannerComponent`). Avoids
25
+ * rendering the identical error twice (inline + banner) at the error moment.
26
+ * Restored to `false` when the banner is cleared at the next turn so the
27
+ * transcript keeps the error in history.
28
+ */
29
+ #errorPinned = false;
21
30
 
22
31
  constructor(
23
32
  message?: AssistantMessage,
@@ -49,6 +58,18 @@ export class AssistantMessageComponent extends Container {
49
58
  this.hideThinkingBlock = hide;
50
59
  }
51
60
 
61
+ /**
62
+ * Toggle suppression of the inline `Error: …` line while the same error is
63
+ * pinned in the banner above the editor. Re-renders so the change is visible.
64
+ */
65
+ setErrorPinned(pinned: boolean): void {
66
+ if (this.#errorPinned === pinned) return;
67
+ this.#errorPinned = pinned;
68
+ if (this.#lastMessage) {
69
+ this.updateContent(this.#lastMessage);
70
+ }
71
+ }
72
+
52
73
  isTranscriptBlockFinalized(): boolean {
53
74
  return this.#transcriptBlockFinalized;
54
75
  }
@@ -246,7 +267,7 @@ export class AssistantMessageComponent extends Container {
246
267
  this.#contentContainer.addChild(new Spacer(1));
247
268
  }
248
269
  this.#contentContainer.addChild(new Text(theme.fg("error", abortMessage), 1, 0));
249
- } else if (message.stopReason === "error") {
270
+ } else if (message.stopReason === "error" && !this.#errorPinned) {
250
271
  const errorMsg = message.errorMessage || "Unknown error";
251
272
  this.#contentContainer.addChild(new Spacer(1));
252
273
  this.#contentContainer.addChild(new Text(theme.fg("error", `Error: ${errorMsg}`), 1, 0));
@@ -0,0 +1,249 @@
1
+ import { type Component, matchesKey, padding, Text, truncateToWidth, visibleWidth } from "@oh-my-pi/pi-tui";
2
+ import { replaceTabs } from "../../tools/render-utils";
3
+ import { highlightCode, theme } from "../theme/theme";
4
+ import type { CopyTarget } from "../utils/copy-targets";
5
+ import {
6
+ matchesSelectCancel,
7
+ matchesSelectDown,
8
+ matchesSelectPageDown,
9
+ matchesSelectPageUp,
10
+ matchesSelectUp,
11
+ } from "../utils/keybinding-matchers";
12
+ import { keyHint, rawKeyHint } from "./keybinding-hints";
13
+
14
+ /** Minimum rows reserved for the tree even on short terminals. */
15
+ const MIN_TREE_ROWS = 3;
16
+ /** Fixed chrome rows: top border, two dividers, footer, bottom border. */
17
+ const CHROME_ROWS = 5;
18
+
19
+ export interface CopySelectorCallbacks {
20
+ /** A copy target was chosen — copy its `content`. */
21
+ onPick: (target: CopyTarget) => void;
22
+ /** The picker was dismissed. */
23
+ onCancel: () => void;
24
+ }
25
+
26
+ interface FlatNode {
27
+ target: CopyTarget;
28
+ depth: number;
29
+ /** Last among its siblings (drives └─ vs ├─). */
30
+ isLast: boolean;
31
+ /** Per-ancestor flag: does ancestor at that level have a following sibling? */
32
+ ancestorHasNext: boolean[];
33
+ }
34
+
35
+ /** Pad or truncate a (possibly ANSI-styled) string to exactly `width` columns. */
36
+ function fit(text: string, width: number): string {
37
+ if (width <= 0) return "";
38
+ const w = visibleWidth(text);
39
+ if (w === width) return text;
40
+ if (w < width) return text + padding(width - w);
41
+ const cut = truncateToWidth(text, width);
42
+ const cw = visibleWidth(cut);
43
+ return cw < width ? cut + padding(width - cw) : cut;
44
+ }
45
+
46
+ function paint(s: string): string {
47
+ return theme.fg("border", s);
48
+ }
49
+
50
+ function topBorder(width: number, title: string): string {
51
+ const box = theme.boxSharp;
52
+ const inner = Math.max(0, width - 2);
53
+ if (!title) return paint(box.topLeft + box.horizontal.repeat(inner) + box.topRight);
54
+ const shown = truncateToWidth(` ${title} `, Math.max(0, inner - 2));
55
+ const fillWidth = Math.max(0, inner - 1 - visibleWidth(shown));
56
+ return (
57
+ paint(box.topLeft + box.horizontal) +
58
+ theme.bold(theme.fg("accent", shown)) +
59
+ paint(box.horizontal.repeat(fillWidth) + box.topRight)
60
+ );
61
+ }
62
+
63
+ function divider(width: number): string {
64
+ const box = theme.boxSharp;
65
+ return paint(box.teeRight + box.horizontal.repeat(Math.max(0, width - 2)) + box.teeLeft);
66
+ }
67
+
68
+ function bottomBorder(width: number): string {
69
+ const box = theme.boxSharp;
70
+ return paint(box.bottomLeft + box.horizontal.repeat(Math.max(0, width - 2)) + box.bottomRight);
71
+ }
72
+
73
+ /** Wrap pre-styled content in vertical borders with single-column insets. */
74
+ function row(content: string, width: number): string {
75
+ const box = theme.boxSharp;
76
+ return `${paint(box.vertical)} ${fit(content, Math.max(0, width - 4))} ${paint(box.vertical)}`;
77
+ }
78
+
79
+ /** Render one tree connector as exactly three cells (e.g. "├─ ", "└─ ", "|--"). */
80
+ function connectorCells(symbol: string): string {
81
+ const chars = Array.from(symbol);
82
+ return (chars[0] ?? " ") + (chars[1] ?? theme.tree.horizontal) + (chars[2] ?? " ");
83
+ }
84
+
85
+ /** The 3-cell ancestor gutter: a vertical guide when the ancestor continues. */
86
+ function gutterCells(hasNext: boolean): string {
87
+ return `${hasNext ? theme.tree.vertical : " "} `;
88
+ }
89
+
90
+ /**
91
+ * Fullscreen `/copy` picker rendered as a `/tree`-style tree inside one
92
+ * outlined box: a title, the tree of copy targets (recent assistant messages
93
+ * with their code blocks nested beneath), a live preview of the highlighted
94
+ * node, and a keybinding footer. Every node copies its `content` on Enter.
95
+ */
96
+ export class CopySelectorComponent implements Component {
97
+ #roots: CopyTarget[];
98
+ #cursorId: string;
99
+ #treeRows = MIN_TREE_ROWS;
100
+ // Reused across renders to wrap preview content to the pane width.
101
+ #previewText = new Text("", 0, 0);
102
+
103
+ constructor(
104
+ roots: CopyTarget[],
105
+ private readonly callbacks: CopySelectorCallbacks,
106
+ ) {
107
+ this.#roots = roots;
108
+ this.#cursorId = roots[0]?.id ?? "";
109
+ }
110
+
111
+ invalidate(): void {}
112
+
113
+ #flatten(): FlatNode[] {
114
+ const out: FlatNode[] = [];
115
+ const walk = (nodes: CopyTarget[], depth: number, ancestorHasNext: boolean[]) => {
116
+ nodes.forEach((target, i) => {
117
+ const isLast = i === nodes.length - 1;
118
+ out.push({ target, depth, isLast, ancestorHasNext });
119
+ if (target.children?.length) walk(target.children, depth + 1, [...ancestorHasNext, !isLast]);
120
+ });
121
+ };
122
+ walk(this.#roots, 0, []);
123
+ return out;
124
+ }
125
+
126
+ handleInput(keyData: string): void {
127
+ if (matchesSelectCancel(keyData)) {
128
+ this.callbacks.onCancel();
129
+ return;
130
+ }
131
+
132
+ const flat = this.#flatten();
133
+ if (flat.length === 0) return;
134
+ const idx = Math.max(
135
+ 0,
136
+ flat.findIndex(n => n.target.id === this.#cursorId),
137
+ );
138
+
139
+ if (matchesSelectUp(keyData)) {
140
+ this.#cursorId = flat[idx === 0 ? flat.length - 1 : idx - 1]!.target.id;
141
+ } else if (matchesSelectDown(keyData)) {
142
+ this.#cursorId = flat[idx === flat.length - 1 ? 0 : idx + 1]!.target.id;
143
+ } else if (matchesSelectPageUp(keyData)) {
144
+ this.#cursorId = flat[Math.max(0, idx - this.#treeRows)]!.target.id;
145
+ } else if (matchesSelectPageDown(keyData)) {
146
+ this.#cursorId = flat[Math.min(flat.length - 1, idx + this.#treeRows)]!.target.id;
147
+ } else if (matchesKey(keyData, "enter") || matchesKey(keyData, "return") || keyData === "\n") {
148
+ const target = flat[idx]!.target;
149
+ if (target.content !== undefined) this.callbacks.onPick(target);
150
+ }
151
+ }
152
+
153
+ #renderTree(width: number, flat: FlatNode[], cursorIdx: number, rows: number): string[] {
154
+ const inner = Math.max(0, width - 4);
155
+ const start = Math.max(0, Math.min(cursorIdx - Math.floor(rows / 2), Math.max(0, flat.length - rows)));
156
+ const out: string[] = [];
157
+ for (let r = 0; r < rows; r++) {
158
+ const i = start + r;
159
+ const node = flat[i];
160
+ if (!node) {
161
+ out.push(row("", width));
162
+ continue;
163
+ }
164
+ const target = node.target;
165
+ const isSelected = i === cursorIdx;
166
+
167
+ let prefix = "";
168
+ for (let l = 0; l < node.depth - 1; l++) prefix += gutterCells(node.ancestorHasNext[l]!);
169
+ if (node.depth > 0) prefix += connectorCells(node.isLast ? theme.tree.last : theme.tree.branch);
170
+
171
+ const cursor = isSelected ? "❯ " : " ";
172
+ const hint = target.hint ?? "";
173
+ const hintWidth = hint ? visibleWidth(hint) + 2 : 0;
174
+ const used = visibleWidth(cursor) + visibleWidth(prefix);
175
+ const labelPlain = truncateToWidth(target.label, Math.max(1, inner - used - hintWidth));
176
+ const left = isSelected
177
+ ? theme.fg("accent", cursor) + theme.fg("dim", prefix) + theme.bold(theme.fg("accent", labelPlain))
178
+ : cursor + theme.fg("dim", prefix) + labelPlain;
179
+ const gap = Math.max(1, inner - used - visibleWidth(labelPlain) - visibleWidth(hint));
180
+ out.push(row(left + padding(gap) + (hint ? theme.fg("dim", hint) : ""), width));
181
+ }
182
+ return out;
183
+ }
184
+
185
+ #renderPreview(width: number, target: CopyTarget | undefined, rows: number): string[] {
186
+ const out: string[] = [];
187
+ const hint = target?.hint;
188
+ out.push(row(theme.fg("dim", `Preview${hint ? ` · ${hint}` : ""}`), width));
189
+
190
+ const contentRows = rows - 1;
191
+ if (!target || contentRows <= 0) {
192
+ while (out.length < rows) out.push(row("", width));
193
+ return out;
194
+ }
195
+
196
+ // Code/command previews are syntax-highlighted; everything else is shown
197
+ // as plain text. Both are wrapped (not hard-truncated) to the pane width.
198
+ const isCode = target.language !== undefined;
199
+ const source = isCode
200
+ ? highlightCode(replaceTabs(target.preview), target.language).join("\n")
201
+ : replaceTabs(target.preview);
202
+ this.#previewText.setText(source);
203
+ const wrapped = this.#previewText.render(Math.max(1, width - 4));
204
+
205
+ const hasMore = wrapped.length > contentRows;
206
+ const visibleCount = hasMore ? contentRows - 1 : Math.min(wrapped.length, contentRows);
207
+ for (let k = 0; k < contentRows; k++) {
208
+ if (k < visibleCount) {
209
+ out.push(row(isCode ? wrapped[k]! : theme.fg("muted", wrapped[k]!), width));
210
+ } else if (k === visibleCount && hasMore) {
211
+ out.push(row(theme.fg("dim", `… ${wrapped.length - visibleCount} more lines`), width));
212
+ } else {
213
+ out.push(row("", width));
214
+ }
215
+ }
216
+ return out;
217
+ }
218
+
219
+ render(width: number): string[] {
220
+ const height = process.stdout.rows || 40;
221
+ const flat = this.#flatten();
222
+ const cursorIdx = Math.max(
223
+ 0,
224
+ flat.findIndex(n => n.target.id === this.#cursorId),
225
+ );
226
+ const selected = flat[cursorIdx]?.target;
227
+
228
+ const available = Math.max(MIN_TREE_ROWS + 1, height - CHROME_ROWS);
229
+ const treeRows = Math.max(1, Math.min(flat.length, Math.floor(available / 2)));
230
+ this.#treeRows = treeRows;
231
+ const previewRows = Math.max(1, available - treeRows);
232
+
233
+ const footer = [
234
+ rawKeyHint("↑↓", "move"),
235
+ keyHint("tui.select.confirm", "copy"),
236
+ keyHint("tui.select.cancel", "quit"),
237
+ ].join(theme.fg("dim", " · "));
238
+
239
+ return [
240
+ topBorder(width, "Copy to clipboard"),
241
+ ...this.#renderTree(width, flat, cursorIdx, treeRows),
242
+ divider(width),
243
+ ...this.#renderPreview(width, selected, previewRows),
244
+ divider(width),
245
+ row(footer, width),
246
+ bottomBorder(width),
247
+ ];
248
+ }
249
+ }
@@ -10,6 +10,7 @@ import {
10
10
  extractPrintableText,
11
11
  matchesKey,
12
12
  padding,
13
+ ScrollView,
13
14
  truncateToWidth,
14
15
  visibleWidth,
15
16
  } from "@oh-my-pi/pi-tui";
@@ -134,25 +135,33 @@ export class ExtensionList implements Component {
134
135
  const startIdx = this.#scrollOffset;
135
136
  const endIdx = Math.min(startIdx + this.#maxVisible, this.#listItems.length);
136
137
 
138
+ // Reserve the rightmost column for the scrollbar when overflowing
139
+ const overflow = this.#listItems.length > this.#maxVisible;
140
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
141
+
137
142
  // Render visible items
143
+ const rows: string[] = [];
138
144
  for (let i = startIdx; i < endIdx; i++) {
139
145
  const listItem = this.#listItems[i];
140
146
  const isSelected = this.#focused && i === this.#selectedIndex;
141
147
 
142
148
  if (listItem.type === "master") {
143
- lines.push(this.#renderMasterSwitch(listItem, isSelected, width));
149
+ rows.push(this.#renderMasterSwitch(listItem, isSelected, rowWidth));
144
150
  } else if (listItem.type === "kind-header") {
145
- lines.push(this.#renderKindHeader(listItem, isSelected, width));
151
+ rows.push(this.#renderKindHeader(listItem, isSelected, rowWidth));
146
152
  } else {
147
- lines.push(this.#renderExtensionRow(listItem.item, isSelected, width, masterDisabled));
153
+ rows.push(this.#renderExtensionRow(listItem.item, isSelected, rowWidth, masterDisabled));
148
154
  }
149
155
  }
150
156
 
151
- // Scroll indicator
152
- if (this.#listItems.length > this.#maxVisible) {
153
- const indicator = theme.fg("muted", ` (${this.#selectedIndex + 1}/${this.#listItems.length})`);
154
- lines.push(indicator);
155
- }
157
+ const sv = new ScrollView(rows, {
158
+ height: rows.length,
159
+ scrollbar: "auto",
160
+ totalRows: this.#listItems.length,
161
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
162
+ });
163
+ sv.setScrollOffset(this.#scrollOffset);
164
+ lines.push(...sv.render(width));
156
165
 
157
166
  return lines;
158
167
  }
@@ -5,6 +5,7 @@ import {
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
8
+ ScrollView,
8
9
  Spacer,
9
10
  Text,
10
11
  truncateToWidth,
@@ -115,15 +116,19 @@ class HistoryResultsList implements Component {
115
116
  );
116
117
  const endIndex = Math.min(startIndex + this.#maxVisible, this.#results.length);
117
118
 
119
+ const overflow = this.#results.length > this.#maxVisible;
120
+ const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
121
+ const rows: string[] = [];
122
+
118
123
  for (let i = startIndex; i < endIndex; i++) {
119
124
  const entry = this.#results[i];
120
125
  const isSelected = i === this.#selectedIndex;
121
126
 
122
127
  const timeStr = relativeTime(entry.created_at);
123
128
  const timeWidth = visibleWidth(timeStr);
124
- const showTime = width >= gutterWidth + 12 + timeWidth;
129
+ const showTime = rowWidth >= gutterWidth + 12 + timeWidth;
125
130
 
126
- const promptBudget = Math.max(4, width - gutterWidth - (showTime ? timeWidth + 1 : 0));
131
+ const promptBudget = Math.max(4, rowWidth - gutterWidth - (showTime ? timeWidth + 1 : 0));
127
132
  const normalized = entry.prompt.replace(/\s+/g, " ").trim();
128
133
  const plain = truncateToWidth(normalized, promptBudget);
129
134
  const highlighted = highlightTokens(plain, this.#tokens);
@@ -133,21 +138,24 @@ class HistoryResultsList implements Component {
133
138
 
134
139
  if (showTime) {
135
140
  // Pad the prompt region so the timestamp sits flush right with a one-cell gap.
136
- line = `${truncateToWidth(line, width - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
141
+ line = `${truncateToWidth(line, rowWidth - timeWidth - 1, Ellipsis.Unicode, true)} ${theme.fg("dim", timeStr)}`;
137
142
  }
138
143
 
139
- lines.push(
144
+ rows.push(
140
145
  isSelected
141
- ? theme.bg("selectedBg", truncateToWidth(line, width, Ellipsis.Omit, true))
142
- : truncateToWidth(line, width),
146
+ ? theme.bg("selectedBg", truncateToWidth(line, rowWidth, Ellipsis.Omit, true))
147
+ : truncateToWidth(line, rowWidth),
143
148
  );
144
149
  }
145
150
 
146
- if (startIndex > 0 || endIndex < this.#results.length) {
147
- const scrollText = ` ${this.#selectedIndex + 1}/${this.#results.length}`;
148
- lines.push(theme.fg("muted", truncateToWidth(scrollText, width, Ellipsis.Omit)));
149
- }
150
-
151
+ const sv = new ScrollView(rows, {
152
+ height: rows.length,
153
+ scrollbar: "auto",
154
+ totalRows: this.#results.length,
155
+ theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
156
+ });
157
+ sv.setScrollOffset(startIndex);
158
+ lines.push(...sv.render(width));
151
159
  return lines;
152
160
  }
153
161
  }