@oh-my-pi/pi-coding-agent 16.1.15 → 16.1.17

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 (143) hide show
  1. package/CHANGELOG.md +78 -0
  2. package/dist/cli.js +3531 -3812
  3. package/dist/types/auto-thinking/classifier.d.ts +4 -2
  4. package/dist/types/cli/args.d.ts +2 -5
  5. package/dist/types/cli/flag-tables.d.ts +2 -2
  6. package/dist/types/cli/session-picker.d.ts +4 -2
  7. package/dist/types/commands/launch.d.ts +1 -1
  8. package/dist/types/config/model-discovery.d.ts +1 -0
  9. package/dist/types/config/model-registry.d.ts +4 -0
  10. package/dist/types/config/settings-schema.d.ts +12 -1
  11. package/dist/types/eval/agent-bridge.d.ts +19 -0
  12. package/dist/types/eval/js/shared/helpers.d.ts +1 -13
  13. package/dist/types/eval/js/shared/types.d.ts +1 -1
  14. package/dist/types/eval/js/worker-protocol.d.ts +1 -1
  15. package/dist/types/eval/py/executor.d.ts +1 -1
  16. package/dist/types/extensibility/plugins/legacy-pi-compat.d.ts +22 -7
  17. package/dist/types/extensibility/plugins/loader.d.ts +16 -9
  18. package/dist/types/extensibility/tool-event-input.d.ts +2 -0
  19. package/dist/types/hindsight/content.d.ts +2 -10
  20. package/dist/types/internal-urls/local-protocol.d.ts +18 -1
  21. package/dist/types/main.d.ts +2 -0
  22. package/dist/types/modes/components/chat-transcript-builder.d.ts +2 -0
  23. package/dist/types/modes/components/custom-editor.d.ts +3 -0
  24. package/dist/types/modes/components/plugin-settings.d.ts +5 -0
  25. package/dist/types/modes/components/session-selector.d.ts +25 -0
  26. package/dist/types/modes/components/status-line/types.d.ts +1 -0
  27. package/dist/types/modes/controllers/command-controller.d.ts +2 -0
  28. package/dist/types/modes/controllers/input-controller.d.ts +14 -0
  29. package/dist/types/modes/controllers/selector-controller.d.ts +11 -0
  30. package/dist/types/session/agent-session.d.ts +6 -6
  31. package/dist/types/session/provider-image-budget.d.ts +3 -0
  32. package/dist/types/session/session-context.d.ts +6 -5
  33. package/dist/types/task/isolation-runner.d.ts +128 -0
  34. package/dist/types/task/parallel.d.ts +4 -0
  35. package/dist/types/task/worktree.d.ts +14 -1
  36. package/dist/types/thinking.d.ts +23 -1
  37. package/dist/types/tiny/title-client.d.ts +2 -2
  38. package/dist/types/tools/eval-render.d.ts +3 -0
  39. package/dist/types/tools/eval.d.ts +11 -17
  40. package/dist/types/tools/todo.d.ts +26 -28
  41. package/dist/types/tui/output-block.d.ts +8 -0
  42. package/dist/types/utils/image-resize.d.ts +2 -0
  43. package/dist/types/utils/tools-manager.d.ts +2 -0
  44. package/dist/types/web/search/providers/exa.d.ts +2 -0
  45. package/package.json +12 -12
  46. package/scripts/build-binary.ts +18 -4
  47. package/src/auto-thinking/classifier.ts +7 -2
  48. package/src/cli/args.ts +4 -5
  49. package/src/cli/flag-tables.ts +3 -3
  50. package/src/cli/gallery-fixtures/interaction.ts +6 -9
  51. package/src/cli/gallery-fixtures/shell.ts +15 -23
  52. package/src/cli/profile-alias.ts +38 -7
  53. package/src/cli/session-picker.ts +17 -3
  54. package/src/cli/usage-cli.ts +5 -1
  55. package/src/commands/launch.ts +3 -3
  56. package/src/config/model-discovery.ts +59 -8
  57. package/src/config/model-registry.ts +74 -3
  58. package/src/config/settings-schema.ts +13 -1
  59. package/src/discovery/omp-extension-roots.ts +1 -3
  60. package/src/edit/renderer.ts +34 -12
  61. package/src/eval/__tests__/agent-bridge.test.ts +462 -3
  62. package/src/eval/__tests__/helpers-local-roots.test.ts +2 -5
  63. package/src/eval/__tests__/julia-prelude.test.ts +1 -30
  64. package/src/eval/__tests__/prelude-agent.test.ts +42 -8
  65. package/src/eval/agent-bridge.ts +301 -71
  66. package/src/eval/jl/prelude.jl +32 -227
  67. package/src/eval/jl/runner.jl +38 -12
  68. package/src/eval/js/shared/helpers.ts +1 -114
  69. package/src/eval/js/shared/prelude.txt +13 -27
  70. package/src/eval/js/shared/runtime.ts +0 -6
  71. package/src/eval/js/shared/types.ts +1 -1
  72. package/src/eval/js/worker-protocol.ts +1 -1
  73. package/src/eval/py/__tests__/prelude.test.ts +13 -0
  74. package/src/eval/py/executor.ts +1 -1
  75. package/src/eval/py/prelude.py +47 -105
  76. package/src/eval/py/runner.py +0 -6
  77. package/src/eval/rb/prelude.rb +21 -189
  78. package/src/eval/rb/runner.rb +116 -9
  79. package/src/export/html/tool-views.generated.js +29 -29
  80. package/src/extensibility/extensions/wrapper.ts +3 -2
  81. package/src/extensibility/hooks/tool-wrapper.ts +4 -3
  82. package/src/extensibility/plugins/legacy-pi-compat.ts +40 -8
  83. package/src/extensibility/plugins/loader.ts +71 -23
  84. package/src/extensibility/plugins/marketplace/manager.ts +134 -0
  85. package/src/extensibility/tool-event-input.ts +57 -0
  86. package/src/hindsight/content.ts +21 -12
  87. package/src/internal-urls/docs-index.generated.txt +1 -1
  88. package/src/internal-urls/local-protocol.ts +100 -53
  89. package/src/main.ts +15 -4
  90. package/src/mcp/tool-bridge.ts +27 -2
  91. package/src/mnemopi/state.ts +5 -2
  92. package/src/modes/acp/acp-event-mapper.ts +7 -2
  93. package/src/modes/components/agent-transcript-viewer.ts +193 -41
  94. package/src/modes/components/chat-transcript-builder.ts +6 -0
  95. package/src/modes/components/custom-editor.test.ts +18 -1
  96. package/src/modes/components/custom-editor.ts +77 -45
  97. package/src/modes/components/hook-editor.ts +15 -2
  98. package/src/modes/components/plugin-settings.ts +7 -1
  99. package/src/modes/components/session-selector.ts +143 -29
  100. package/src/modes/components/settings-selector.ts +2 -2
  101. package/src/modes/components/status-line/component.ts +52 -8
  102. package/src/modes/components/status-line/segments.ts +5 -1
  103. package/src/modes/components/status-line/types.ts +1 -0
  104. package/src/modes/components/welcome.ts +12 -14
  105. package/src/modes/controllers/command-controller.ts +21 -5
  106. package/src/modes/controllers/input-controller.ts +115 -3
  107. package/src/modes/controllers/selector-controller.ts +19 -1
  108. package/src/modes/interactive-mode.ts +3 -3
  109. package/src/modes/rpc/rpc-mode.ts +6 -0
  110. package/src/modes/utils/copy-targets.ts +7 -2
  111. package/src/modes/utils/ui-helpers.ts +3 -3
  112. package/src/prompts/system/system-prompt.md +3 -3
  113. package/src/prompts/system/workflow-notice.md +3 -3
  114. package/src/prompts/tools/bash.md +16 -0
  115. package/src/prompts/tools/eval.md +19 -19
  116. package/src/prompts/tools/todo.md +1 -1
  117. package/src/sdk.ts +8 -10
  118. package/src/session/agent-session.ts +422 -97
  119. package/src/session/provider-image-budget.ts +86 -0
  120. package/src/session/session-context.ts +14 -7
  121. package/src/session/session-storage.ts +24 -2
  122. package/src/session/snapcompact-inline.ts +19 -3
  123. package/src/slash-commands/builtin-registry.ts +0 -22
  124. package/src/slash-commands/helpers/usage-report.ts +9 -1
  125. package/src/task/index.ts +61 -207
  126. package/src/task/isolation-runner.ts +354 -0
  127. package/src/task/parallel.ts +6 -1
  128. package/src/task/worktree.ts +46 -9
  129. package/src/thinking.ts +29 -2
  130. package/src/tiny/title-client.ts +75 -21
  131. package/src/tools/ask.ts +44 -38
  132. package/src/tools/bash.ts +9 -2
  133. package/src/tools/browser/tab-worker.ts +1 -1
  134. package/src/tools/eval-render.ts +34 -27
  135. package/src/tools/eval.ts +100 -103
  136. package/src/tools/index.ts +8 -1
  137. package/src/tools/read.ts +136 -60
  138. package/src/tools/todo.ts +60 -64
  139. package/src/tui/code-cell.ts +1 -1
  140. package/src/tui/output-block.ts +11 -0
  141. package/src/utils/image-resize.ts +30 -0
  142. package/src/utils/tools-manager.ts +67 -10
  143. package/src/web/search/providers/exa.ts +85 -1
@@ -4,6 +4,7 @@ import { getEditorTheme, initTheme } from "../theme/theme";
4
4
  import {
5
5
  CustomEditor,
6
6
  extractBracketedImagePastePaths,
7
+ extractBracketedPastePaths,
7
8
  SPACE_HOLD_MECHANICAL_RUN,
8
9
  SPACE_HOLD_RELEASE_MS,
9
10
  SPACE_REPEAT_MAX_GAP_MS,
@@ -73,7 +74,7 @@ describe("CustomEditor placeholder decoration", () => {
73
74
  });
74
75
  });
75
76
 
76
- describe("CustomEditor bracketed image-path paste", () => {
77
+ describe("CustomEditor bracketed path paste", () => {
77
78
  it("leaves a pasted bare .png filename on the normal text path", () => {
78
79
  expect(extractBracketedImagePastePaths(bracketedPaste("icon-photo-default.png"))).toBeUndefined();
79
80
  });
@@ -86,6 +87,22 @@ describe("CustomEditor bracketed image-path paste", () => {
86
87
  "C:\\Users\\me\\icon-photo-default.png",
87
88
  ]);
88
89
  });
90
+
91
+ it("extracts explicit non-image paths without classifying them as image paths", () => {
92
+ expect(extractBracketedPastePaths(bracketedPaste("/tmp/report.csv"))).toEqual(["/tmp/report.csv"]);
93
+ expect(extractBracketedImagePastePaths(bracketedPaste("/tmp/report.csv"))).toBeUndefined();
94
+ });
95
+
96
+ it("routes non-image path pastes through the file-path hook", async () => {
97
+ const { editor } = makeEditor();
98
+ const pasted = Promise.withResolvers<string>();
99
+ editor.onPasteFilePath = path => pasted.resolve(path);
100
+
101
+ editor.handleInput(bracketedPaste("/tmp/report.csv"));
102
+
103
+ expect(await pasted.promise).toBe("/tmp/report.csv");
104
+ expect(editor.getText()).toBe("");
105
+ });
89
106
  });
90
107
 
91
108
  describe("CustomEditor space-hold push-to-talk", () => {
@@ -62,7 +62,6 @@ function buildMatchKeys(keys: readonly KeyId[]): Set<string> {
62
62
  const BRACKETED_PASTE_START = "\x1b[200~";
63
63
  const BRACKETED_PASTE_END = "\x1b[201~";
64
64
  const BRACKETED_IMAGE_PATH_REGEX = /\.(?:png|jpe?g|gif|webp)$/i;
65
- const BRACKETED_IMAGE_PATH_BOUNDARY_REGEX = /\.(?:png|jpe?g|gif|webp)(?=$|["']?\s)/gi;
66
65
  const SHELL_ESCAPED_PATH_CHAR_REGEX = /\\([\\\s'"()[\]{}&;<>|?*!$`])/g;
67
66
  const URI_SCHEME_REGEX = /^[a-z][a-z0-9+.-]*:/i;
68
67
  const FILE_URI_REGEX = /^file:\/\//i;
@@ -100,19 +99,7 @@ function isPastedPathSeparator(char: string | undefined): boolean {
100
99
  return char === undefined || char === " " || char === "\t" || char === "\r" || char === "\n";
101
100
  }
102
101
 
103
- function imagePathBoundaryEnd(payload: string, segmentStart: number, extensionEnd: number): number | undefined {
104
- const quote = payload[segmentStart];
105
- const afterExtension = payload[extensionEnd];
106
- if (quote === '"' || quote === "'") {
107
- return afterExtension === quote && isPastedPathSeparator(payload[extensionEnd + 1])
108
- ? extensionEnd + 1
109
- : undefined;
110
- }
111
- if (isPastedPathSeparator(afterExtension)) return extensionEnd;
112
- return undefined;
113
- }
114
-
115
- function normalizePastedImagePath(path: string): string {
102
+ function normalizePastedPath(path: string): string {
116
103
  const trimmed = path.trim();
117
104
  const first = trimmed[0];
118
105
  const last = trimmed[trimmed.length - 1];
@@ -121,13 +108,60 @@ function normalizePastedImagePath(path: string): string {
121
108
  return unquoted.replace(SHELL_ESCAPED_PATH_CHAR_REGEX, "$1");
122
109
  }
123
110
 
124
- function isExplicitPastedImagePath(path: string): boolean {
111
+ function isExplicitPastedPath(path: string): boolean {
125
112
  if (WINDOWS_DRIVE_PATH_REGEX.test(path) || FILE_URI_REGEX.test(path)) return true;
126
113
  if (URI_SCHEME_REGEX.test(path)) return false;
127
114
  return path.includes("/") || path.includes("\\");
128
115
  }
129
116
 
130
- export function extractBracketedImagePastePaths(data: string): string[] | undefined {
117
+ function isImagePath(path: string): boolean {
118
+ return BRACKETED_IMAGE_PATH_REGEX.test(path);
119
+ }
120
+
121
+ function splitPastedPathSegments(payload: string): string[] | undefined {
122
+ const segments: string[] = [];
123
+ let segment = "";
124
+ let quote: string | undefined;
125
+ let escaped = false;
126
+
127
+ for (let i = 0; i < payload.length; i++) {
128
+ const char = payload[i];
129
+ if (escaped) {
130
+ segment += char;
131
+ escaped = false;
132
+ continue;
133
+ }
134
+ if (char === "\\") {
135
+ segment += char;
136
+ escaped = true;
137
+ continue;
138
+ }
139
+ if (quote) {
140
+ segment += char;
141
+ if (char === quote) quote = undefined;
142
+ continue;
143
+ }
144
+ if (char === '"' || char === "'") {
145
+ segment += char;
146
+ quote = char;
147
+ continue;
148
+ }
149
+ if (isPastedPathSeparator(char)) {
150
+ if (segment) {
151
+ segments.push(segment);
152
+ segment = "";
153
+ }
154
+ continue;
155
+ }
156
+ segment += char;
157
+ }
158
+
159
+ if (escaped || quote) return undefined;
160
+ if (segment) segments.push(segment);
161
+ return segments.length > 0 ? segments : undefined;
162
+ }
163
+
164
+ export function extractBracketedPastePaths(data: string): string[] | undefined {
131
165
  if (!data.startsWith(BRACKETED_PASTE_START)) return undefined;
132
166
  const endIndex = data.indexOf(BRACKETED_PASTE_END, BRACKETED_PASTE_START.length);
133
167
  if (endIndex === -1 || endIndex + BRACKETED_PASTE_END.length !== data.length) return undefined;
@@ -135,33 +169,23 @@ export function extractBracketedImagePastePaths(data: string): string[] | undefi
135
169
  const pasted = data.slice(BRACKETED_PASTE_START.length, endIndex).trim();
136
170
  if (!pasted) return undefined;
137
171
 
172
+ const segments = splitPastedPathSegments(pasted);
173
+ if (!segments) return undefined;
174
+
138
175
  const paths: string[] = [];
139
- let segmentStart = 0;
140
- BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = 0;
141
- for (
142
- let match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted);
143
- match;
144
- match = BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.exec(pasted)
145
- ) {
146
- const extensionEnd = match.index + match[0].length;
147
- const boundaryEnd = imagePathBoundaryEnd(pasted, segmentStart, extensionEnd);
148
- if (boundaryEnd === undefined) continue;
149
-
150
- const path = normalizePastedImagePath(pasted.slice(segmentStart, boundaryEnd));
151
- if (!path || !BRACKETED_IMAGE_PATH_REGEX.test(path) || !isExplicitPastedImagePath(path)) return undefined;
176
+ for (const segment of segments) {
177
+ const path = normalizePastedPath(segment);
178
+ if (!path || !isExplicitPastedPath(path)) return undefined;
152
179
  paths.push(path);
153
-
154
- segmentStart = boundaryEnd;
155
- while (segmentStart < pasted.length && isPastedPathSeparator(pasted[segmentStart])) {
156
- segmentStart++;
157
- }
158
- BRACKETED_IMAGE_PATH_BOUNDARY_REGEX.lastIndex = segmentStart;
159
180
  }
160
-
161
- if (paths.length === 0 || segmentStart !== pasted.length) return undefined;
162
181
  return paths;
163
182
  }
164
183
 
184
+ export function extractBracketedImagePastePaths(data: string): string[] | undefined {
185
+ const paths = extractBracketedPastePaths(data);
186
+ return paths?.every(isImagePath) ? paths : undefined;
187
+ }
188
+
165
189
  export function extractBracketedImagePastePath(data: string): string | undefined {
166
190
  const paths = extractBracketedImagePastePaths(data);
167
191
  return paths?.length === 1 ? paths[0] : undefined;
@@ -294,6 +318,8 @@ export class CustomEditor extends Editor {
294
318
  onPasteImage?: () => Promise<boolean>;
295
319
  /** Called when a bracketed paste contains one or more image-file paths. */
296
320
  onPasteImagePath?: (path: string) => void | Promise<void>;
321
+ /** Called when a bracketed paste contains one or more non-image file paths. */
322
+ onPasteFilePath?: (path: string) => void | Promise<void>;
297
323
  /** Called when the configured raw text-paste shortcut is pressed. */
298
324
  onPasteTextRaw?: () => void;
299
325
  /** Called when the configured dequeue shortcut is pressed. */
@@ -479,14 +505,20 @@ export class CustomEditor extends Editor {
479
505
  return;
480
506
  }
481
507
 
482
- const pastedImagePaths = extractBracketedImagePastePaths(data);
483
- if (pastedImagePaths && this.onPasteImagePath) {
484
- void (async () => {
485
- for (const path of pastedImagePaths) {
486
- await this.onPasteImagePath?.(path);
487
- }
488
- })();
489
- return;
508
+ const pastedPaths = extractBracketedPastePaths(data);
509
+ if (pastedPaths) {
510
+ const canHandlePaths = pastedPaths.every(path =>
511
+ isImagePath(path) ? this.onPasteImagePath !== undefined : this.onPasteFilePath !== undefined,
512
+ );
513
+ if (canHandlePaths) {
514
+ void (async () => {
515
+ for (const path of pastedPaths) {
516
+ if (isImagePath(path)) await this.onPasteImagePath?.(path);
517
+ else await this.onPasteFilePath?.(path);
518
+ }
519
+ })();
520
+ return;
521
+ }
490
522
  }
491
523
 
492
524
  const parsedKey = parseKey(data);
@@ -67,7 +67,7 @@ export class HookEditorComponent extends Container {
67
67
 
68
68
  // Hint
69
69
  const hint = this.#promptStyle
70
- ? "enter submit esc cancel ctrl+g external editor"
70
+ ? "enter or ctrl+q submit esc cancel ctrl+g external editor"
71
71
  : "ctrl+q/ctrl+enter submit esc cancel ctrl+g external editor";
72
72
  this.addChild(new Text(theme.fg("dim", hint), 1, 0));
73
73
 
@@ -95,8 +95,21 @@ export class HookEditorComponent extends Container {
95
95
  this.#editor.pasteText(text);
96
96
  }
97
97
 
98
- /** Prompt-style: raw Enter submits; Editor owns newline-producing sequences. */
98
+ /**
99
+ * Prompt-style: raw Enter submits; Editor owns newline-producing sequences.
100
+ * The follow-up chord (`app.message.followUp` → Ctrl+Q / Ctrl+Enter) also
101
+ * submits, so muscle memory from the main editor / hook-style surface works
102
+ * here and Windows Terminal — which can't deliver a distinct Ctrl+Enter
103
+ * event (#1903) — still has a working chord via Ctrl+Q (#3353).
104
+ */
99
105
  #handlePromptStyleInput(keyData: string): void {
106
+ // Submit on the follow-up chord first so it wins over Editor's own
107
+ // Ctrl+Enter newline handling. Mirrors #handleHookStyleInput.
108
+ if (matchesAppFollowUp(keyData)) {
109
+ this.#submitCurrentText();
110
+ return;
111
+ }
112
+
100
113
  // Prompt-style keeps Escape as an explicit cancel key and also honors app.interrupt remaps.
101
114
  if (matchesKey(keyData, "escape") || matchesKey(keyData, "esc") || matchesAppInterrupt(keyData)) {
102
115
  this.#onCancelCallback();
@@ -11,6 +11,7 @@
11
11
  import {
12
12
  Container,
13
13
  Input,
14
+ matchesKey,
14
15
  type SelectItem,
15
16
  SelectList,
16
17
  type SettingItem,
@@ -36,13 +37,18 @@ import { DynamicBorder } from "./dynamic-border";
36
37
 
37
38
  /**
38
39
  * Forwards a keystroke to `input`, but cancels via `onCancel` when the user presses Escape.
40
+ *
41
+ * Escape is decoded via `matchesKey` rather than a raw `\x1b` compare: inside the
42
+ * fullscreen settings overlay the kitty keyboard protocol is active (ghostty/kitty),
43
+ * where the Escape key arrives as the CSI-u sequence `\x1b[27u`, not a bare `\x1b`.
44
+ * The literal fallbacks preserve legacy single/double-escape on terminals without it.
39
45
  */
40
46
  export function handleInputOrEscape(
41
47
  data: string,
42
48
  input: { handleInput(data: string): void },
43
49
  onCancel: () => void,
44
50
  ): void {
45
- if (data === "\x1b" || data === "\x1b\x1b") {
51
+ if (data === "\x1b" || data === "\x1b\x1b" || matchesKey(data, "escape")) {
46
52
  onCancel();
47
53
  return;
48
54
  }
@@ -5,6 +5,7 @@ import {
5
5
  Input,
6
6
  matchesKey,
7
7
  padding,
8
+ parseSgrMouse,
8
9
  replaceTabs,
9
10
  ScrollView,
10
11
  Spacer,
@@ -161,6 +162,12 @@ export function mergeSessionRanking(
161
162
  class SessionList implements Component {
162
163
  #filteredSessions: SessionInfo[] = [];
163
164
  #selectedIndex: number = 0;
165
+ // Maps a 0-based line within this list's own render to a filtered-session
166
+ // index, or undefined for chrome rows (search line, blanks, scrollbar gap).
167
+ // Rebuilt every render so the picker's mouse hit-testing tracks the live
168
+ // scroll window. Only consulted while the picker holds the alternate screen
169
+ // (where the overlay enables mouse tracking and paints from screen row 0).
170
+ #hitRows: (number | undefined)[] = [];
164
171
  readonly #searchInput: Input;
165
172
  onSelect?: (session: SessionInfo) => void;
166
173
  onCancel?: () => void;
@@ -257,12 +264,32 @@ class SessionList implements Component {
257
264
  }
258
265
  }
259
266
 
267
+ /** Resolve a list-local rendered-line index to a filtered-session index. */
268
+ hitTestSession(line: number): number | undefined {
269
+ return this.#hitRows[line];
270
+ }
271
+
272
+ /** Wheel notch: move the selection one step (clamped, no wrap). */
273
+ handleWheel(delta: -1 | 1): void {
274
+ if (this.#filteredSessions.length === 0) return;
275
+ this.#selectedIndex = Math.max(0, Math.min(this.#filteredSessions.length - 1, this.#selectedIndex + delta));
276
+ }
277
+
278
+ /** Mouse click: select the session under the pointer and resume it. */
279
+ selectAndConfirm(index: number): void {
280
+ const session = this.#filteredSessions[index];
281
+ if (!session) return;
282
+ this.#selectedIndex = index;
283
+ this.onSelect?.(session);
284
+ }
285
+
260
286
  invalidate(): void {
261
287
  // No cached state to invalidate currently
262
288
  }
263
289
 
264
290
  render(width: number): readonly string[] {
265
291
  const lines: string[] = [];
292
+ this.#hitRows = [];
266
293
 
267
294
  // Render search input
268
295
  lines.push(...this.#searchInput.render(width));
@@ -311,9 +338,11 @@ class SessionList implements Component {
311
338
  // Each session block is built into sessionLines, then wrapped by ScrollView
312
339
  // so the right-edge scrollbar is proportional at the physical-line level.
313
340
  const sessionLines: string[] = [];
341
+ const sessionRowIndex: number[] = [];
314
342
  const overflow = this.#filteredSessions.length > maxVisible;
315
343
  const rowWidth = Math.max(0, width - (overflow ? 1 : 0));
316
344
  for (let i = startIndex; i < endIndex; i++) {
345
+ const blockStart = sessionLines.length;
317
346
  const session = this.#filteredSessions[i];
318
347
  const isSelected = i === this.#selectedIndex;
319
348
 
@@ -363,6 +392,7 @@ class SessionList implements Component {
363
392
 
364
393
  sessionLines.push(metadataLine);
365
394
  sessionLines.push(""); // Blank line between sessions
395
+ for (let k = blockStart; k < sessionLines.length; k++) sessionRowIndex[k] = i;
366
396
  }
367
397
 
368
398
  // Wrap the rendered window in a ScrollView for a proportional right-edge bar.
@@ -375,16 +405,10 @@ class SessionList implements Component {
375
405
  theme: { track: t => theme.fg("muted", t), thumb: t => theme.fg("accent", t) },
376
406
  });
377
407
  sv.setScrollOffset(Math.round(startIndex * linesPerItem));
378
- lines.push(...sv.render(width));
379
-
380
- // Add keybinding hint
381
- lines.push("");
382
- lines.push(
383
- theme.fg(
384
- "muted",
385
- ` [Del delete · Enter select · Tab ${this.#showCwd ? "current folder" : "all projects"} · Esc cancel]`,
386
- ),
387
- );
408
+ const sessionRegionStart = lines.length;
409
+ const svLines = sv.render(width);
410
+ for (let k = 0; k < svLines.length; k++) this.#hitRows[sessionRegionStart + k] = sessionRowIndex[k];
411
+ lines.push(...svLines);
388
412
 
389
413
  return lines;
390
414
  }
@@ -462,6 +486,13 @@ export interface SessionSelectorOptions {
462
486
  * Omitted only in tests; defaults to a conservative 24 rows.
463
487
  */
464
488
  getTerminalRows?: () => number;
489
+ /**
490
+ * Fill the whole viewport and pin the footer (hint + bottom border) to the
491
+ * last rows, so the footer stops drifting as the list window changes height.
492
+ * Set by the standalone `--resume` picker (fullscreen alternate screen); the
493
+ * in-editor selector leaves it off and renders compactly.
494
+ */
495
+ fillHeight?: boolean;
465
496
  }
466
497
 
467
498
  /**
@@ -470,6 +501,13 @@ export interface SessionSelectorOptions {
470
501
  export class SessionSelectorComponent extends Container {
471
502
  #sessionList: SessionList;
472
503
  #confirmationDialog: HookSelectorComponent | null = null;
504
+ // Hosts whichever of `#sessionList` / `#confirmationDialog` is live this
505
+ // frame. The delete dialog REPLACES the list in this slot rather than being
506
+ // appended below the picker chrome, so the picker is always
507
+ // `chrome + max(list, dialog) + chrome` and never overflows the viewport
508
+ // (issue #3283: an overflowing dialog frame committed the header into
509
+ // scrollback, stranding it above the viewport once the dialog closed).
510
+ #contentSlot: Container;
473
511
  #messageContainer: Container;
474
512
  #headerText: Text;
475
513
  #onDelete?: (session: SessionInfo) => Promise<boolean>;
@@ -479,6 +517,18 @@ export class SessionSelectorComponent extends Container {
479
517
  #globalSessions: SessionInfo[] | null = null;
480
518
  #scope: "folder" | "all" = "folder";
481
519
  #toggling = false;
520
+ // 0-based line where the session list begins within this component's own
521
+ // render, captured each frame. The fullscreen picker overlay paints from
522
+ // screen row 0, so a mouse row maps to `row - #listLineOffset` inside the
523
+ // list. Only meaningful while the picker holds the alternate screen.
524
+ #listLineOffset = 0;
525
+ // 0-based line where the pinned footer begins; clicks at or below it never
526
+ // hit-test the list, so a footer click on a cramped (trimmed) frame can't
527
+ // resume a session scrolled off-screen.
528
+ #footerStart = 0;
529
+ readonly #getTerminalRows: () => number;
530
+ readonly #fillHeight: boolean;
531
+ readonly #bottomBorder = new DynamicBorder();
482
532
 
483
533
  constructor(
484
534
  sessions: SessionInfo[],
@@ -494,6 +544,8 @@ export class SessionSelectorComponent extends Container {
494
544
  this.#loadAllSessions = options.loadAllSessions;
495
545
  this.#folderSessions = sessions;
496
546
  this.#globalSessions = options.allSessions ?? null;
547
+ this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
548
+ this.#fillHeight = options.fillHeight ?? false;
497
549
  // Add header
498
550
  this.addChild(new Spacer(1));
499
551
  this.#headerText = new Text(this.#headerLabel(), 1, 0);
@@ -517,11 +569,9 @@ export class SessionSelectorComponent extends Container {
517
569
  void this.#toggleScope();
518
570
  };
519
571
  }
520
- this.addChild(this.#sessionList);
521
-
522
- // Add bottom border
523
- this.addChild(new Spacer(1));
524
- this.addChild(new DynamicBorder());
572
+ this.#contentSlot = new Container();
573
+ this.#contentSlot.addChild(this.#sessionList);
574
+ this.addChild(this.#contentSlot);
525
575
  }
526
576
 
527
577
  #headerLabel(): string {
@@ -582,6 +632,15 @@ export class SessionSelectorComponent extends Container {
582
632
 
583
633
  #showDeleteConfirmation(session: SessionInfo): void {
584
634
  const displayName = session.title || session.firstMessage.slice(0, 40) || session.id;
635
+ const closeDialog = () => {
636
+ this.#confirmationDialog = null;
637
+ // Restore the SessionList into the content slot so the picker is back
638
+ // to its normal layout on the very next render — the same frame the
639
+ // dialog disappears.
640
+ this.#contentSlot.clear();
641
+ this.#contentSlot.addChild(this.#sessionList);
642
+ this.#onRequestRender?.();
643
+ };
585
644
  this.#confirmationDialog = new HookSelectorComponent(
586
645
  `Delete session?\n${displayName}`,
587
646
  ["Yes", "No"],
@@ -597,25 +656,61 @@ export class SessionSelectorComponent extends Container {
597
656
  this.#showError(err instanceof Error ? err.message : String(err));
598
657
  }
599
658
  }
600
- // Close confirmation dialog
601
- this.removeChild(this.#confirmationDialog!);
602
- this.#confirmationDialog = null;
603
- // Request rerender
604
- this.#onRequestRender?.();
605
- },
606
- () => {
607
- // Cancel - close confirmation dialog
608
- this.removeChild(this.#confirmationDialog!);
609
- this.#confirmationDialog = null;
610
- // Request rerender
611
- this.#onRequestRender?.();
659
+ closeDialog();
612
660
  },
661
+ closeDialog,
613
662
  );
614
- // Show confirmation dialog
615
- this.addChild(this.#confirmationDialog);
663
+ // Swap the SessionList out of the content slot and mount the dialog in its
664
+ // place: the dialog competes only with the SessionList's rendered budget,
665
+ // never the SessionList AND the picker chrome, so the picker frame stays
666
+ // inside the terminal viewport and the TUI never commits the header into
667
+ // scrollback (issue #3283).
668
+ this.#contentSlot.clear();
669
+ this.#contentSlot.addChild(this.#confirmationDialog);
670
+ this.#onRequestRender?.();
671
+ }
672
+
673
+ /**
674
+ * Concatenate the children's renders (like {@link Container}) while recording
675
+ * the line where the session list begins, so the fullscreen picker can hit-
676
+ * test mouse rows against the live list window. SessionList rebuilds its lines
677
+ * every frame, so Container's reference-memoization never applied here.
678
+ *
679
+ * In fill-height mode the body is padded (or, on a cramped terminal, trimmed)
680
+ * to leave exactly enough room for the footer at the screen bottom, so the
681
+ * footer is always visible and never drifts as the list window resizes. The
682
+ * in-editor selector just appends the footer directly.
683
+ */
684
+ render(width: number): readonly string[] {
685
+ const lines: string[] = [];
686
+ for (const child of this.children) {
687
+ const childLines = child.render(width);
688
+ if (child === this.#contentSlot) this.#listLineOffset = lines.length;
689
+ for (const line of childLines) lines.push(line);
690
+ }
691
+ const footer = this.#footerLines(width);
692
+ if (this.#fillHeight) {
693
+ const target = Math.max(0, this.#getTerminalRows() - footer.length);
694
+ if (lines.length > target) lines.length = target;
695
+ else for (let i = lines.length; i < target; i++) lines.push("");
696
+ }
697
+ this.#footerStart = lines.length;
698
+ for (const line of footer) lines.push(line);
699
+ return lines;
700
+ }
701
+
702
+ /** Blank · keybinding hint · bottom border. Rendered by {@link render}. */
703
+ #footerLines(width: number): string[] {
704
+ const scopeHint = this.#scope === "all" ? "current folder" : "all projects";
705
+ const hint = theme.fg("muted", ` [Del delete · Enter select · Tab ${scopeHint} · Esc cancel]`);
706
+ return ["", hint, "", ...this.#bottomBorder.render(width)];
616
707
  }
617
708
 
618
709
  handleInput(keyData: string): void {
710
+ if (keyData.startsWith("\x1b[<")) {
711
+ this.#handleMouse(keyData);
712
+ return;
713
+ }
619
714
  if (this.#confirmationDialog) {
620
715
  this.#confirmationDialog.handleInput(keyData);
621
716
  } else {
@@ -623,6 +718,25 @@ export class SessionSelectorComponent extends Container {
623
718
  }
624
719
  }
625
720
 
721
+ /**
722
+ * SGR mouse reports, delivered only while the picker holds the alternate
723
+ * screen (the fullscreen overlay enables tracking and paints from screen row
724
+ * 0). Wheel scrolls the list; a left click resumes the session under the
725
+ * pointer. Mouse is inert while the delete-confirmation dialog is open.
726
+ */
727
+ #handleMouse(data: string): void {
728
+ if (this.#confirmationDialog) return;
729
+ const event = parseSgrMouse(data);
730
+ if (!event) return;
731
+ if (event.wheel !== null) {
732
+ this.#sessionList.handleWheel(event.wheel);
733
+ return;
734
+ }
735
+ if (!event.leftClick || event.row >= this.#footerStart) return;
736
+ const index = this.#sessionList.hitTestSession(event.row - this.#listLineOffset);
737
+ if (index !== undefined) this.#sessionList.selectAndConfirm(index);
738
+ }
739
+
626
740
  getSessionList(): SessionList {
627
741
  return this.#sessionList;
628
742
  }
@@ -703,7 +703,7 @@ export class SettingsSelectorComponent implements Component {
703
703
  id: def.path,
704
704
  label: def.label,
705
705
  description: def.description,
706
- currentValue: currentValue as string,
706
+ currentValue: String(currentValue ?? ""),
707
707
  values: [...def.values],
708
708
  changed,
709
709
  };
@@ -723,7 +723,7 @@ export class SettingsSelectorComponent implements Component {
723
723
  id: def.path,
724
724
  label: def.label,
725
725
  description: def.description,
726
- currentValue: (currentValue as string) ?? "",
726
+ currentValue: String(currentValue ?? ""),
727
727
  submenu: (cv, done) => this.#createTextInput(def, cv, done),
728
728
  changed,
729
729
  };