@oh-my-pi/pi-coding-agent 15.9.5 → 15.10.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 (192) hide show
  1. package/CHANGELOG.md +98 -1
  2. package/dist/types/cli/args.d.ts +1 -1
  3. package/dist/types/cli/gallery-cli.d.ts +43 -0
  4. package/dist/types/cli/gallery-fixtures/agentic.d.ts +2 -0
  5. package/dist/types/cli/gallery-fixtures/codeintel.d.ts +3 -0
  6. package/dist/types/cli/gallery-fixtures/edit.d.ts +3 -0
  7. package/dist/types/cli/gallery-fixtures/fs.d.ts +2 -0
  8. package/dist/types/cli/gallery-fixtures/index.d.ts +4 -0
  9. package/dist/types/cli/gallery-fixtures/interaction.d.ts +3 -0
  10. package/dist/types/cli/gallery-fixtures/memory.d.ts +2 -0
  11. package/dist/types/cli/gallery-fixtures/misc.d.ts +3 -0
  12. package/dist/types/cli/gallery-fixtures/search.d.ts +3 -0
  13. package/dist/types/cli/gallery-fixtures/shell.d.ts +3 -0
  14. package/dist/types/cli/gallery-fixtures/types.d.ts +44 -0
  15. package/dist/types/cli/gallery-fixtures/web.d.ts +2 -0
  16. package/dist/types/cli/gallery-screenshot.d.ts +35 -0
  17. package/dist/types/commands/gallery.d.ts +47 -0
  18. package/dist/types/config/keybindings.d.ts +10 -2
  19. package/dist/types/config/model-id-affixes.d.ts +2 -0
  20. package/dist/types/config/model-registry.d.ts +8 -1
  21. package/dist/types/config/settings-schema.d.ts +43 -7
  22. package/dist/types/edit/file-snapshot-store.d.ts +1 -1
  23. package/dist/types/eval/backend.d.ts +6 -6
  24. package/dist/types/eval/bridge-timeout.d.ts +27 -0
  25. package/dist/types/eval/idle-timeout.d.ts +16 -14
  26. package/dist/types/eval/js/executor.d.ts +3 -3
  27. package/dist/types/eval/py/executor.d.ts +2 -2
  28. package/dist/types/eval/py/spawn-options.d.ts +58 -0
  29. package/dist/types/extensibility/plugins/marketplace-auto-update.d.ts +8 -0
  30. package/dist/types/lsp/types.d.ts +10 -0
  31. package/dist/types/main.d.ts +3 -2
  32. package/dist/types/memory-backend/index.d.ts +2 -1
  33. package/dist/types/memory-backend/resolve.d.ts +1 -1
  34. package/dist/types/memory-backend/types.d.ts +1 -1
  35. package/dist/types/modes/components/assistant-message.d.ts +5 -0
  36. package/dist/types/modes/components/copy-selector.d.ts +22 -0
  37. package/dist/types/modes/components/custom-editor.d.ts +2 -1
  38. package/dist/types/modes/components/model-selector.d.ts +1 -0
  39. package/dist/types/modes/components/tool-execution.d.ts +18 -0
  40. package/dist/types/modes/controllers/command-controller.d.ts +0 -1
  41. package/dist/types/modes/controllers/selector-controller.d.ts +2 -1
  42. package/dist/types/modes/index.d.ts +5 -4
  43. package/dist/types/modes/interactive-mode.d.ts +2 -2
  44. package/dist/types/modes/setup-version.d.ts +11 -0
  45. package/dist/types/modes/setup-wizard/index.d.ts +2 -1
  46. package/dist/types/modes/setup-wizard/scenes/web-search.d.ts +2 -1
  47. package/dist/types/modes/types.d.ts +2 -2
  48. package/dist/types/modes/utils/copy-targets.d.ts +53 -0
  49. package/dist/types/sdk.d.ts +1 -1
  50. package/dist/types/task/executor.d.ts +7 -0
  51. package/dist/types/telemetry-export.d.ts +1 -1
  52. package/dist/types/tools/eval-render.d.ts +1 -0
  53. package/dist/types/tools/fetch.d.ts +15 -7
  54. package/dist/types/tools/render-utils.d.ts +33 -0
  55. package/dist/types/tools/renderers.d.ts +16 -2
  56. package/dist/types/tools/search.d.ts +1 -1
  57. package/dist/types/tools/write.d.ts +2 -0
  58. package/dist/types/tui/code-cell.d.ts +6 -0
  59. package/dist/types/tui/output-block.d.ts +11 -0
  60. package/dist/types/web/scrapers/github.d.ts +22 -0
  61. package/dist/types/web/search/providers/perplexity.d.ts +8 -1
  62. package/dist/types/web/search/types.d.ts +1 -1
  63. package/package.json +9 -9
  64. package/scripts/dev-launch +42 -0
  65. package/scripts/dev-launch-preload.ts +19 -0
  66. package/src/autoresearch/dashboard.ts +11 -21
  67. package/src/cli/args.ts +2 -2
  68. package/src/cli/claude-trace-cli.ts +13 -1
  69. package/src/cli/gallery-cli.ts +223 -0
  70. package/src/cli/gallery-fixtures/agentic.ts +292 -0
  71. package/src/cli/gallery-fixtures/codeintel.ts +188 -0
  72. package/src/cli/gallery-fixtures/edit.ts +194 -0
  73. package/src/cli/gallery-fixtures/fs.ts +153 -0
  74. package/src/cli/gallery-fixtures/index.ts +40 -0
  75. package/src/cli/gallery-fixtures/interaction.ts +49 -0
  76. package/src/cli/gallery-fixtures/memory.ts +81 -0
  77. package/src/cli/gallery-fixtures/misc.ts +221 -0
  78. package/src/cli/gallery-fixtures/search.ts +213 -0
  79. package/src/cli/gallery-fixtures/shell.ts +167 -0
  80. package/src/cli/gallery-fixtures/types.ts +41 -0
  81. package/src/cli/gallery-fixtures/web.ts +158 -0
  82. package/src/cli/gallery-screenshot.ts +279 -0
  83. package/src/cli-commands.ts +1 -0
  84. package/src/commands/gallery.ts +52 -0
  85. package/src/commands/launch.ts +1 -1
  86. package/src/config/keybindings.ts +68 -2
  87. package/src/config/model-equivalence.ts +35 -12
  88. package/src/config/model-id-affixes.ts +39 -22
  89. package/src/config/model-registry.ts +16 -16
  90. package/src/config/settings-schema.ts +29 -6
  91. package/src/config/settings.ts +11 -0
  92. package/src/dap/client.ts +14 -16
  93. package/src/debug/raw-sse.ts +18 -4
  94. package/src/edit/file-snapshot-store.ts +1 -1
  95. package/src/edit/index.ts +1 -1
  96. package/src/edit/renderer.ts +43 -55
  97. package/src/edit/streaming.ts +1 -1
  98. package/src/eval/__tests__/agent-bridge.test.ts +102 -58
  99. package/src/eval/__tests__/bridge-timeout.test.ts +64 -0
  100. package/src/eval/__tests__/idle-timeout.test.ts +26 -12
  101. package/src/eval/__tests__/kernel-spawn.test.ts +103 -0
  102. package/src/eval/__tests__/llm-bridge.test.ts +10 -10
  103. package/src/eval/agent-bridge.ts +38 -12
  104. package/src/eval/backend.ts +6 -6
  105. package/src/eval/bridge-timeout.ts +44 -0
  106. package/src/eval/idle-timeout.ts +33 -15
  107. package/src/eval/js/executor.ts +10 -10
  108. package/src/eval/llm-bridge.ts +4 -5
  109. package/src/eval/py/executor.ts +6 -6
  110. package/src/eval/py/kernel.ts +11 -1
  111. package/src/eval/py/spawn-options.ts +126 -0
  112. package/src/export/ttsr.ts +9 -0
  113. package/src/extensibility/extensions/runner.ts +3 -0
  114. package/src/extensibility/plugins/doctor.ts +0 -1
  115. package/src/extensibility/plugins/marketplace-auto-update.ts +49 -0
  116. package/src/goals/tools/goal-tool.ts +2 -2
  117. package/src/internal-urls/docs-index.generated.ts +7 -6
  118. package/src/lsp/client.ts +179 -52
  119. package/src/lsp/index.ts +38 -4
  120. package/src/lsp/render.ts +3 -3
  121. package/src/lsp/types.ts +10 -0
  122. package/src/main.ts +47 -52
  123. package/src/memory-backend/index.ts +13 -1
  124. package/src/memory-backend/resolve.ts +3 -5
  125. package/src/memory-backend/types.ts +1 -1
  126. package/src/modes/components/agent-dashboard.ts +13 -4
  127. package/src/modes/components/assistant-message.ts +22 -1
  128. package/src/modes/components/copy-selector.ts +249 -0
  129. package/src/modes/components/custom-editor.ts +10 -1
  130. package/src/modes/components/extensions/extension-list.ts +17 -8
  131. package/src/modes/components/history-search.ts +19 -11
  132. package/src/modes/components/model-selector.ts +125 -29
  133. package/src/modes/components/oauth-selector.ts +28 -12
  134. package/src/modes/components/session-observer-overlay.ts +13 -15
  135. package/src/modes/components/session-selector.ts +24 -13
  136. package/src/modes/components/status-line.ts +3 -5
  137. package/src/modes/components/tool-execution.ts +83 -24
  138. package/src/modes/components/tree-selector.ts +19 -7
  139. package/src/modes/components/user-message-selector.ts +25 -14
  140. package/src/modes/controllers/command-controller.ts +13 -118
  141. package/src/modes/controllers/event-controller.ts +26 -10
  142. package/src/modes/controllers/input-controller.ts +11 -3
  143. package/src/modes/controllers/selector-controller.ts +40 -3
  144. package/src/modes/index.ts +5 -4
  145. package/src/modes/interactive-mode.ts +21 -7
  146. package/src/modes/setup-version.ts +11 -0
  147. package/src/modes/setup-wizard/index.ts +3 -2
  148. package/src/modes/setup-wizard/scenes/web-search.ts +3 -2
  149. package/src/modes/theme/theme.ts +46 -10
  150. package/src/modes/types.ts +2 -2
  151. package/src/modes/utils/context-usage.ts +10 -6
  152. package/src/modes/utils/copy-targets.ts +254 -0
  153. package/src/modes/utils/hotkeys-markdown.ts +1 -0
  154. package/src/prompts/tools/ast-edit.md +1 -1
  155. package/src/prompts/tools/ast-grep.md +1 -1
  156. package/src/prompts/tools/read.md +1 -1
  157. package/src/prompts/tools/search.md +1 -1
  158. package/src/sdk.ts +21 -23
  159. package/src/session/agent-session.ts +13 -9
  160. package/src/slash-commands/builtin-registry.ts +4 -12
  161. package/src/slash-commands/helpers/usage-report.ts +2 -0
  162. package/src/task/executor.ts +20 -2
  163. package/src/task/render.ts +37 -11
  164. package/src/telemetry-export.ts +25 -7
  165. package/src/tools/bash.ts +18 -8
  166. package/src/tools/browser/render.ts +5 -4
  167. package/src/tools/debug.ts +3 -3
  168. package/src/tools/eval-backends.ts +6 -17
  169. package/src/tools/eval-render.ts +28 -10
  170. package/src/tools/eval.ts +19 -23
  171. package/src/tools/fetch.ts +99 -89
  172. package/src/tools/read.ts +7 -7
  173. package/src/tools/render-utils.ts +63 -3
  174. package/src/tools/renderers.ts +16 -1
  175. package/src/tools/report-tool-issue.ts +1 -1
  176. package/src/tools/search.ts +173 -81
  177. package/src/tools/ssh.ts +21 -8
  178. package/src/tools/todo.ts +20 -7
  179. package/src/tools/write.ts +39 -9
  180. package/src/tui/code-cell.ts +19 -4
  181. package/src/tui/output-block.ts +14 -0
  182. package/src/web/scrapers/github.ts +255 -3
  183. package/src/web/scrapers/youtube.ts +3 -2
  184. package/src/web/search/providers/perplexity.ts +199 -51
  185. package/src/web/search/render.ts +42 -57
  186. package/src/web/search/types.ts +5 -1
  187. package/dist/types/eval/heartbeat.d.ts +0 -45
  188. package/src/eval/__tests__/heartbeat.test.ts +0 -84
  189. package/src/eval/__tests__/shared-executors.test.ts +0 -609
  190. package/src/eval/heartbeat.ts +0 -74
  191. /package/dist/types/eval/__tests__/{heartbeat.test.d.ts → bridge-timeout.test.d.ts} +0 -0
  192. /package/dist/types/eval/__tests__/{shared-executors.test.d.ts → kernel-spawn.test.d.ts} +0 -0
@@ -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 @@ type ConfigurableEditorAction = Extract<
10
10
  | "app.clear"
11
11
  | "app.exit"
12
12
  | "app.suspend"
13
+ | "app.display.reset"
13
14
  | "app.thinking.cycle"
14
15
  | "app.model.cycleForward"
15
16
  | "app.model.cycleBackward"
@@ -30,10 +31,11 @@ const DEFAULT_ACTION_KEYS: Record<ConfigurableEditorAction, KeyId[]> = {
30
31
  "app.clear": ["ctrl+c"],
31
32
  "app.exit": ["ctrl+d"],
32
33
  "app.suspend": ["ctrl+z"],
34
+ "app.display.reset": ["ctrl+l"],
33
35
  "app.thinking.cycle": ["shift+tab"],
34
36
  "app.model.cycleForward": ["ctrl+p"],
35
37
  "app.model.cycleBackward": ["shift+ctrl+p"],
36
- "app.model.select": ["ctrl+l"],
38
+ "app.model.select": ["alt+m"],
37
39
  "app.model.selectTemporary": ["alt+p"],
38
40
  "app.tools.expand": ["ctrl+o"],
39
41
  "app.thinking.toggle": ["ctrl+t"],
@@ -65,6 +67,7 @@ export class CustomEditor extends Editor {
65
67
  onEscape?: () => void;
66
68
  onClear?: () => void;
67
69
  onExit?: () => void;
70
+ onDisplayReset?: () => void;
68
71
  onCycleThinkingLevel?: () => void;
69
72
  onCycleModelForward?: () => void;
70
73
  onCycleModelBackward?: () => void;
@@ -158,6 +161,12 @@ export class CustomEditor extends Editor {
158
161
  return;
159
162
  }
160
163
 
164
+ // Intercept configured display reset shortcut
165
+ if (this.#matchesAction(data, "app.display.reset") && this.onDisplayReset) {
166
+ this.onDisplayReset();
167
+ return;
168
+ }
169
+
161
170
  // Intercept configured suspend shortcut
162
171
  if (this.#matchesAction(data, "app.suspend") && this.onSuspend) {
163
172
  this.onSuspend();
@@ -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
  }