@oh-my-pi/pi-tui 18.11.2 → 18.12.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 (61) hide show
  1. package/CHANGELOG.md +37 -100
  2. package/README.md +36 -0
  3. package/dist/types/components/markdown.d.ts +4 -3
  4. package/dist/types/overlays/approval-dialog.d.ts +25 -15
  5. package/dist/types/overlays/model-picker.d.ts +2 -0
  6. package/dist/types/overlays/session-selector.d.ts +2 -0
  7. package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
  8. package/dist/types/render/hanging-wrap.d.ts +2 -0
  9. package/dist/types/render/render-utils.d.ts +9 -1
  10. package/dist/types/status-line/types.d.ts +3 -0
  11. package/dist/types/terminal-capabilities.d.ts +8 -1
  12. package/dist/types/terminal-multiplexer.d.ts +6 -0
  13. package/dist/types/theme/symbols.d.ts +1 -1
  14. package/dist/types/theme/theme.d.ts +11 -0
  15. package/dist/types/tools/ast-edit.d.ts +2 -2
  16. package/dist/types/tools/ast-grep.d.ts +2 -2
  17. package/dist/types/tools/edit.d.ts +2 -0
  18. package/dist/types/tools/find.d.ts +14 -4
  19. package/dist/types/tools/glob.d.ts +3 -3
  20. package/dist/types/tools/grep.d.ts +2 -2
  21. package/dist/types/tools/read.d.ts +13 -5
  22. package/dist/types/tools/task.d.ts +8 -0
  23. package/dist/types/tools/write.d.ts +2 -0
  24. package/package.json +9 -9
  25. package/src/chat/assistant-message.ts +3 -1
  26. package/src/chat/read-tool-group.ts +17 -9
  27. package/src/chat/tool-execution.ts +23 -6
  28. package/src/components/image.ts +31 -14
  29. package/src/components/markdown.ts +74 -5
  30. package/src/overlays/approval-dialog.ts +104 -36
  31. package/src/overlays/model-picker.ts +58 -21
  32. package/src/overlays/session-selector.ts +14 -9
  33. package/src/prompt/internal-url-autocomplete.ts +1 -0
  34. package/src/render/hanging-wrap.ts +28 -1
  35. package/src/render/render-utils.ts +16 -2
  36. package/src/status-line/component.ts +1 -0
  37. package/src/status-line/segments.ts +16 -5
  38. package/src/status-line/types.ts +1 -0
  39. package/src/terminal-capabilities.ts +35 -21
  40. package/src/terminal-multiplexer.ts +14 -0
  41. package/src/terminal.ts +11 -2
  42. package/src/theme/symbols.ts +1 -0
  43. package/src/theme/theme-class.ts +1 -0
  44. package/src/theme/theme.ts +46 -14
  45. package/src/tmux.ts +7 -1
  46. package/src/tools/ast-edit.ts +28 -4
  47. package/src/tools/ast-grep.ts +24 -4
  48. package/src/tools/debug.ts +2 -1
  49. package/src/tools/default-renderer.ts +3 -2
  50. package/src/tools/edit.ts +77 -16
  51. package/src/tools/find.ts +65 -15
  52. package/src/tools/glob.ts +51 -17
  53. package/src/tools/grep.ts +94 -11
  54. package/src/tools/lsp.ts +23 -18
  55. package/src/tools/mcp.ts +2 -2
  56. package/src/tools/read.ts +52 -22
  57. package/src/tools/task.ts +17 -7
  58. package/src/tools/web-search.ts +2 -1
  59. package/src/tools/write.ts +17 -5
  60. package/src/tools/yield.ts +2 -2
  61. package/src/tui.ts +36 -0
@@ -1,15 +1,19 @@
1
1
  /**
2
2
  * Tool-approval prompt in the composer's place, presented like the ask
3
- * dialog: the approval title and its details over an action bar. Approving
4
- * takes a deliberate chord (`app.approval.approve`, Ctrl+Enter by default),
5
- * so an Enter typed for the composer as the prompt appears never approves a
6
- * call, and the chord itself only arms {@link APPROVE_ARM_MS} after mount: it
7
- * is also the follow-up key, pressed mid-run exactly when prompts appear.
8
- * Esc denies. With an {@link ApprovalDialogDraft}, every other key edits the
9
- * composer mounted beneath the dialog, so a message can steer the turn while
10
- * the call waits; an empty composer ignores Enter and arrows scroll the details.
11
- * The prompt stays a few rows so the composer keeps its room; with a full
12
- * prompt, the expand key (`app.tools.expand`) swaps it in, wrapped.
3
+ * dialog: the approval title and its details over a list of choices —
4
+ * Approve, an optional "approve for this session", and Deny — and an action
5
+ * bar. The arrows move between the choices and Enter takes the highlighted
6
+ * one; `app.approval.approve` (Ctrl+Enter by default) approves this call
7
+ * outright and Esc denies. Enter and the approve chord only arm
8
+ * {@link APPROVE_ARM_MS} after mount, so a key meant for the composer (Enter,
9
+ * or the follow-up chord pressed mid-run) as the prompt appears never answers
10
+ * it. With an {@link ApprovalDialogDraft}, every other key edits the composer
11
+ * mounted beneath the dialog, so a message can deny the call and steer the
12
+ * turn instead: while the composer holds a draft, the arrows and Enter edit and
13
+ * send it, and the highlight sits on Deny, relabeled to say Enter sends.
14
+ * The prompt stays a few rows so the composer keeps its room; Page Up/Down
15
+ * scroll the details, and with a full prompt the expand key
16
+ * (`app.tools.expand`) swaps it in, wrapped.
13
17
  */
14
18
  import { type Component, Ellipsis, ScrollView, type TUI, truncateToWidth, wrapTextWithAnsi } from "../index";
15
19
  import { formatKeyHints, formatTooltipKey } from "../app-keybindings";
@@ -27,8 +31,8 @@ import {
27
31
  matchesSelectPageUp,
28
32
  matchesSelectUp,
29
33
  } from "../keybinding-matchers";
30
- import { col, node } from "../native/describe";
31
- import type { NativeNode, NativeUiEvent } from "../native/node";
34
+ import { col, item, node } from "../native/describe";
35
+ import { leafKey, type NativeNode, type NativeUiEvent } from "../native/node";
32
36
  import { actionBar, actionButton } from "../native/overlay";
33
37
  import { PREVIEW_LIMITS, sanitizeDisplayLines } from "../render/render-utils";
34
38
  import { theme } from "../theme/theme";
@@ -36,8 +40,17 @@ import { CountdownTimer } from "../chrome/countdown-timer";
36
40
 
37
41
  /** Detail rows shown before the rest scrolls, so the composer beneath keeps its room. */
38
42
  const MAX_DETAIL_ROWS: number = PREVIEW_LIMITS.COLLAPSED_ITEMS;
39
- /** The approve key is ignored this long after mount, so a follow-up sent as the prompt appears cannot approve it. */
43
+ /** Enter and the approve key are ignored this long after mount, so a key sent as the prompt appears cannot answer it. */
40
44
  export const APPROVE_ARM_MS = 500;
45
+ /** The Deny row while a composer draft holds Enter: sending it denies the call and steers the turn. */
46
+ const DENY_AND_SEND_LABEL = "Deny and send message";
47
+
48
+ type ApprovalAnswer = "once" | "session" | "deny";
49
+
50
+ interface ApprovalChoice {
51
+ readonly label: string;
52
+ readonly answer: ApprovalAnswer;
53
+ }
41
54
 
42
55
  interface ApprovalDialogCallbacks {
43
56
  onApprove(): void;
@@ -65,6 +78,8 @@ interface ApprovalDialogOptions {
65
78
  onTimeoutReset?: () => void;
66
79
  /** Route typing to the composer instead of dropping it. */
67
80
  draft?: ApprovalDialogDraft;
81
+ /** Offer to approve this and later like calls for the rest of the session, as the row between Approve and Deny. */
82
+ session?: { label: string; onApprove(): void };
68
83
  }
69
84
 
70
85
  /** A prompt's detail rows: every line after the title, trailing blanks dropped. */
@@ -81,11 +96,17 @@ export class ApprovalDialogComponent implements Component {
81
96
  #expanded = false;
82
97
  readonly #panel: OverlayPanel;
83
98
  readonly #body = new PanelRows();
99
+ readonly #choiceRows = new PanelRows();
84
100
  readonly #footer = new PanelRows();
85
101
  #scrollOffset = 0;
86
102
  #bodyRows = MAX_DETAIL_ROWS;
103
+ /** In list order; the first (Approve) is highlighted on mount and Deny is last. */
104
+ readonly #choices: readonly ApprovalChoice[];
105
+ #choice = 0;
87
106
  #closed = false;
88
107
  #native: NativeNode | undefined;
108
+ /** Whether a draft held the keys when {@link #native} was described: it moves the highlight. */
109
+ #nativeDrafting = false;
89
110
  readonly #armedAt = performance.now() + APPROVE_ARM_MS;
90
111
  readonly #countdown: CountdownTimer | undefined;
91
112
 
@@ -101,9 +122,15 @@ export class ApprovalDialogComponent implements Component {
101
122
  this.#summary = promptDetails(lines);
102
123
  this.#full =
103
124
  options.fullPrompt === undefined ? undefined : promptDetails(sanitizeDisplayLines(options.fullPrompt));
125
+ this.#choices = [
126
+ { label: "Approve", answer: "once" },
127
+ ...(options.session ? [{ label: options.session.label, answer: "session" as const }] : []),
128
+ { label: "Deny", answer: "deny" },
129
+ ];
104
130
  this.#panel = new OverlayPanel(title, "omp.overlay.approval");
105
131
  this.#footer.setHeight(1);
106
132
  this.#panel.addChild(this.#body);
133
+ this.#panel.addChild(this.#choiceRows);
107
134
  this.#panel.addChild(new PanelDivider());
108
135
  this.#panel.addChild(this.#footer);
109
136
  if (options.timeout && options.timeout > 0) {
@@ -116,7 +143,7 @@ export class ApprovalDialogComponent implements Component {
116
143
  },
117
144
  () => {
118
145
  options.onTimeout?.();
119
- this.#finish(false);
146
+ this.#finish("deny");
120
147
  },
121
148
  );
122
149
  }
@@ -137,7 +164,7 @@ export class ApprovalDialogComponent implements Component {
137
164
  if (this.#closed) return;
138
165
  this.#restartCountdown();
139
166
  if (matchesAppApprove(keyData)) {
140
- if (performance.now() >= this.#armedAt) this.#finish(true);
167
+ if (performance.now() >= this.#armedAt) this.#finish("once");
141
168
  return;
142
169
  }
143
170
  if (this.#full && matchesAppToolsExpand(keyData)) {
@@ -151,7 +178,7 @@ export class ApprovalDialogComponent implements Component {
151
178
  return;
152
179
  }
153
180
  if (matchesSelectCancel(keyData)) {
154
- this.#finish(false);
181
+ this.#finish("deny");
155
182
  return;
156
183
  }
157
184
  if (draft?.hasDraft()) {
@@ -159,14 +186,24 @@ export class ApprovalDialogComponent implements Component {
159
186
  return;
160
187
  }
161
188
  const page = Math.max(1, this.#bodyRows - 1);
162
- if (matchesSelectUp(keyData)) this.#scroll(-1);
163
- else if (matchesSelectDown(keyData)) this.#scroll(1);
189
+ if (matchesSelectUp(keyData)) this.#moveChoice(-1);
190
+ else if (matchesSelectDown(keyData)) this.#moveChoice(1);
164
191
  else if (matchesSelectPageUp(keyData)) this.#scroll(-page);
165
192
  else if (matchesSelectPageDown(keyData)) this.#scroll(page);
166
- // An empty composer swallows Enter: it would otherwise submit nothing, or interrupt the run.
193
+ else if (getKeybindings().matches(keyData, "tui.select.confirm")) {
194
+ if (performance.now() >= this.#armedAt) this.#finish(this.#choices[this.#choice]!.answer);
195
+ }
196
+ // Enter never reaches an empty composer: it would submit nothing, or interrupt the run.
167
197
  else if (draft && !getKeybindings().matches(keyData, "tui.input.submit")) this.#editDraft(draft, keyData);
168
198
  }
169
199
 
200
+ #moveChoice(delta: number): void {
201
+ const count = this.#choices.length;
202
+ this.#choice = (this.#choice + delta + count) % count;
203
+ this.#native = undefined;
204
+ this.options.tui?.requestRender();
205
+ }
206
+
170
207
  #editDraft(draft: ApprovalDialogDraft, keyData: string): void {
171
208
  draft.handleInput(keyData);
172
209
  this.options.tui?.requestRender();
@@ -225,18 +262,32 @@ export class ApprovalDialogComponent implements Component {
225
262
  this.#body.setLines(lines);
226
263
  }
227
264
  this.#body.setHeight(rows);
228
- this.#footer.setLines([theme.fg("dim", this.#footerHint(overflows))]);
265
+ const drafting = this.options.draft?.hasDraft() ?? false;
266
+ // While a draft holds Enter the highlight sits on Deny (sending the draft denies), else on the arrows' choice.
267
+ const shown = drafting ? this.#choices.length - 1 : this.#choice;
268
+ this.#choiceRows.setLines(
269
+ this.#choices.map((choice, index) => {
270
+ const label = drafting && choice.answer === "deny" ? DENY_AND_SEND_LABEL : choice.label;
271
+ if (index !== shown) return truncateToWidth(` ${label}`, innerWidth, Ellipsis.Unicode);
272
+ const color = choice.answer === "deny" ? "error" : "accent";
273
+ const row = truncateToWidth(`${theme.nav.cursor} ${label}`, innerWidth, Ellipsis.Unicode);
274
+ return theme.bold(theme.fg(color, row));
275
+ }),
276
+ );
277
+ this.#footer.setLines([theme.fg("dim", this.#footerHint(overflows, drafting))]);
229
278
  return this.#panel.render(width);
230
279
  }
231
280
 
232
281
  /**
233
282
  * In the dock, in the composer's place and framed as the composer is (an
234
283
  * `omp.editor.*` root role), like the ask dialog, rather than a card over
235
- * the transcript: the title, the details, then Details (with a full prompt),
236
- * Deny, and the primary Approve, whose keys show in their tooltips.
284
+ * the transcript: the title, the details, the choices with the keyboard's
285
+ * highlight (a click takes one), then Details (with a full prompt), Deny,
286
+ * and the primary Approve, whose keys show in their tooltips.
237
287
  */
238
288
  describe(): NativeNode {
239
- if (this.#native) return this.#native;
289
+ const drafting = this.options.draft?.hasDraft() ?? false;
290
+ if (this.#native && this.#nativeDrafting === drafting) return this.#native;
240
291
  const cancelKey = getKeybindings().getKeys("tui.select.cancel")[0];
241
292
  const approveKey = appApproveKeys()[0];
242
293
  const deny = actionButton("Deny", "deny", {
@@ -268,6 +319,15 @@ export class ApprovalDialogComponent implements Component {
268
319
  );
269
320
  children.push(node("col", { role: "omp.approval.details" }, rows, "details"));
270
321
  }
322
+ const choices = this.#choices.map(choice =>
323
+ item(choice.answer, {
324
+ label: choice.label,
325
+ role: "omp.approval.choice",
326
+ tone: choice.answer === "deny" ? "error" : "accent",
327
+ }),
328
+ );
329
+ const selected = drafting ? "deny" : this.#choices[this.#choice]!.answer;
330
+ children.push(node("list", { selected, role: "omp.approval.choices" }, choices, "choices"));
271
331
  // The countdown, when one runs, leads the bar like the ask dialog's.
272
332
  children.push(
273
333
  actionBar([...(this.#countdown ? [this.#countdown.describe()] : []), null, ...expand, deny, approve]),
@@ -275,15 +335,22 @@ export class ApprovalDialogComponent implements Component {
275
335
  // Under the `omp.editor.ask` prefix: Tern's console layout sizes an ask's rows to the
276
336
  // composer column by that prefix, where a bare `omp.editor.*` role spans the whole dock.
277
337
  this.#native = col(children, { role: "omp.editor.ask.approval", gap: "md" });
338
+ this.#nativeDrafting = drafting;
278
339
  return this.#native;
279
340
  }
280
341
 
281
- /** Deny, Approve, and Details run exactly what Esc, the approve key, and the expand key run. */
342
+ /** Deny, Approve, and Details run exactly what Esc, the approve key, and the expand key run; a click on a choice takes it. */
282
343
  handleNativeEvent(event: NativeUiEvent): void {
283
- if (this.#closed || event.type !== "action") return;
284
- if (event.act === "approve") this.#finish(true);
285
- else if (event.act === "deny") this.#finish(false);
286
- else if (event.act === "expand") this.toggleDetails();
344
+ if (this.#closed) return;
345
+ if (event.type === "action") {
346
+ if (event.act === "approve") this.#finish("once");
347
+ else if (event.act === "deny") this.#finish("deny");
348
+ else if (event.act === "expand") this.toggleDetails();
349
+ return;
350
+ }
351
+ if ((event.type !== "select" && event.type !== "activate") || leafKey(event.key) !== "choices") return;
352
+ const choice = this.#choices.find(candidate => candidate.answer === event.item);
353
+ if (choice) this.#finish(choice.answer);
287
354
  }
288
355
 
289
356
  /** Detail rows at `width`: one clipped row per detail, or wrapped rows once expanded. */
@@ -294,16 +361,16 @@ export class ApprovalDialogComponent implements Component {
294
361
  return this.#details.flatMap(line => (line === "" ? [""] : wrapTextWithAnsi(theme.fg("accent", line), width)));
295
362
  }
296
363
 
297
- #footerHint(overflows: boolean): string {
364
+ #footerHint(overflows: boolean, drafting: boolean): string {
298
365
  const approve = formatKeyHints(appApproveKeys());
299
366
  const expand = this.#full
300
367
  ? ` · ${formatKeyHints([appToolsExpandKey()])} ${this.#expanded ? "less" : "details"}`
301
368
  : "";
302
- const hint = `${approve} approve · ${editorKey("tui.select.cancel")} deny${expand}`;
303
- const draft = this.options.draft;
304
- if (draft?.hasDraft()) return `${hint} · ${editorKey("tui.input.submit")} deny and send`;
305
- const scroll = overflows ? ` · ${editorKeys("tui.select.up", "tui.select.down")} scroll` : "";
306
- return `${hint}${scroll}${draft ? " · type to steer instead" : ""}`;
369
+ const keys = `${approve} approve · ${editorKey("tui.select.cancel")} deny${expand}`;
370
+ if (drafting) return `${editorKey("tui.input.submit")} deny and send · ${keys}`;
371
+ const choose = `${editorKeys("tui.select.up", "tui.select.down")} choose · ${editorKey("tui.select.confirm")} select`;
372
+ const scroll = overflows ? ` · ${editorKeys("tui.select.pageUp", "tui.select.pageDown")} scroll` : "";
373
+ return `${choose} · ${keys}${scroll}${this.options.draft ? " · type to deny and steer" : ""}`;
307
374
  }
308
375
 
309
376
  #scroll(delta: number): void {
@@ -311,11 +378,12 @@ export class ApprovalDialogComponent implements Component {
311
378
  this.options.tui?.requestRender();
312
379
  }
313
380
 
314
- #finish(approved: boolean): void {
381
+ #finish(answer: ApprovalAnswer): void {
315
382
  if (this.#closed) return;
316
383
  this.#closed = true;
317
384
  this.#countdown?.dispose();
318
- if (approved) this.callbacks.onApprove();
385
+ if (answer === "once") this.callbacks.onApprove();
386
+ else if (answer === "session") this.options.session?.onApprove();
319
387
  else this.callbacks.onDeny();
320
388
  }
321
389
  }
@@ -76,6 +76,8 @@ export interface ModelPickerOptions {
76
76
  taskModeKeys?: readonly KeyId[];
77
77
  /** `provider/id` highlighted and preselected in task mode (current Task subagent model). */
78
78
  taskSelector?: string;
79
+ /** Present selection-only chrome; consumers handle the chosen selector without mutating a session. */
80
+ selectOnly?: boolean;
79
81
  }
80
82
 
81
83
  /** Fixed chrome rows: top border, status row, footer, bottom border. */
@@ -90,9 +92,10 @@ const HEIGHT_FRACTION = 0.4;
90
92
  const STATUS_HINT = "Session-only switch — role models stay unchanged";
91
93
  const QUICK_ROLE_STATUS_HINT = "Quick role switch — applies its model and thinking for this session";
92
94
  const TASK_STATUS_HINT = "Task subagent switch — spawned task agents use this model (session-only)";
95
+ const SELECT_ONLY_STATUS_HINT = "Selection only — does not change the session model";
93
96
 
94
97
  /** Footer hint for the active mode; keys resolve at render time so theme/keybinding changes apply. */
95
- function footerHint(mode: "session" | "role" | "task"): string {
98
+ function footerHint(mode: "session" | "role" | "task" | "selection"): string {
96
99
  const upDown = editorKeys("tui.select.up", "tui.select.down");
97
100
  const enter = formatKeyHint("enter");
98
101
  const close = `${editorKey("tui.select.cancel")} close`;
@@ -101,6 +104,8 @@ function footerHint(mode: "session" | "role" | "task"): string {
101
104
  return `${upDown} roles · ${enter} apply role model · type to search · ${close}`;
102
105
  case "task":
103
106
  return `${upDown} models · ${enter} use for Task subagents · type to search · ${close}`;
107
+ case "selection":
108
+ return `${upDown} models · ${enter} select model · type to search · ${close}`;
104
109
  default:
105
110
  return `${upDown} models · ${enter} use for this session · type to search · @ quick roles · ${close}`;
106
111
  }
@@ -128,6 +133,7 @@ export class ModelPickerComponent implements Component {
128
133
  #taskMatchKeys = new Set<string>();
129
134
  #taskModeKey: KeyId | undefined;
130
135
  #taskSelector: string | undefined;
136
+ #selectOnly: boolean;
131
137
  #nativeRoot: { memo: string; node: NativeNode } | undefined;
132
138
  #pickerRoot:
133
139
  | {
@@ -151,10 +157,11 @@ export class ModelPickerComponent implements Component {
151
157
  this.#settings = settings;
152
158
  this.#registry = registry;
153
159
  this.#scopedModels = scopedModels;
160
+ this.#selectOnly = options.selectOnly ?? false;
154
161
  this.#currentSelector = options.currentSelector;
155
162
  this.#currentQuickRoleSelector = options.currentQuickRole ? `@${options.currentQuickRole}` : undefined;
156
163
  this.#taskSelector = options.taskSelector;
157
- if (callbacks.onPickTask) {
164
+ if (!this.#selectOnly && callbacks.onPickTask) {
158
165
  this.#taskModeKey = options.taskModeKeys?.[0];
159
166
  for (const key of options.taskModeKeys ?? []) addKeyAliases(this.#taskMatchKeys, key);
160
167
  }
@@ -165,7 +172,7 @@ export class ModelPickerComponent implements Component {
165
172
 
166
173
  this.#browser = new ModelBrowser(settings, {
167
174
  currentContextTokens: options.currentContextTokens,
168
- markOverContext: true,
175
+ markOverContext: !this.#selectOnly,
169
176
  emptyText: () =>
170
177
  this.#roleMode
171
178
  ? ` No quick roles in the ${editorKey("app.model.cycleForward") || formatKeyHint("ctrl+p")} cycle`
@@ -250,13 +257,13 @@ export class ModelPickerComponent implements Component {
250
257
 
251
258
  /** Switch browser content only when a leading `@` changes the search mode. */
252
259
  #syncItemsForQuery(query: string, refresh = false): void {
253
- const roleMode = query.startsWith("@") && !this.#taskMode;
260
+ const roleMode = !this.#selectOnly && query.startsWith("@") && !this.#taskMode;
254
261
  const modeChanged = roleMode !== this.#roleMode;
255
262
  if (!modeChanged && !refresh) return;
256
263
 
257
264
  this.#roleMode = roleMode;
258
265
  this.#browser.setShowProvider(!roleMode);
259
- this.#browser.setMarkOverContext(!roleMode && !this.#taskMode);
266
+ this.#browser.setMarkOverContext(!this.#selectOnly && !roleMode && !this.#taskMode);
260
267
  this.#browser.setPreserveQueryOrder(roleMode);
261
268
  const currentSelector = roleMode
262
269
  ? this.#currentQuickRoleSelector
@@ -299,20 +306,32 @@ export class ModelPickerComponent implements Component {
299
306
  this.#browser.setMaxVisible(Math.max(MIN_VISIBLE, listBudget));
300
307
 
301
308
  const inner = Math.max(1, width - 4);
309
+ const selectionOnly = this.#selectOnly;
302
310
  const status = this.#configError
303
311
  ? theme.fg("error", ` ${this.#configError}`)
304
312
  : this.#taskMode
305
313
  ? theme.fg("error", ` ${TASK_STATUS_HINT}`)
306
- : theme.fg("muted", ` ${this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`);
314
+ : theme.fg(
315
+ "muted",
316
+ ` ${selectionOnly ? SELECT_ONLY_STATUS_HINT : this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT}`,
317
+ );
307
318
 
308
319
  const borderColor: ThemeColor | undefined = this.#taskMode ? "error" : undefined;
309
- let footer = footerHint(this.#taskMode ? "task" : this.#roleMode ? "role" : "session");
320
+ let footer = footerHint(
321
+ this.#taskMode ? "task" : this.#roleMode ? "role" : selectionOnly ? "selection" : "session",
322
+ );
310
323
  if (this.#taskModeKey !== undefined && !this.#roleMode) {
311
324
  footer += ` · ${formatKeyHint(this.#taskModeKey)} ${this.#taskMode ? "session model" : "task model"}`;
312
325
  }
313
326
 
314
327
  const out: string[] = [];
315
- out.push(topBorder(width, this.#taskMode ? "Switch Task Model" : "Switch Model", borderColor));
328
+ out.push(
329
+ topBorder(
330
+ width,
331
+ this.#taskMode ? "Switch Task Model" : selectionOnly ? "Select Model" : "Switch Model",
332
+ borderColor,
333
+ ),
334
+ );
316
335
  out.push(row(status, width, borderColor));
317
336
  for (const line of this.#browser.render(inner)) {
318
337
  out.push(row(line, width, borderColor));
@@ -359,7 +378,7 @@ export class ModelPickerComponent implements Component {
359
378
  }
360
379
 
361
380
  #describePicker(): NativeNode {
362
- const mode = this.#taskMode ? "task" : this.#roleMode ? "role" : "session";
381
+ const mode = this.#selectOnly ? "selection" : this.#taskMode ? "task" : this.#roleMode ? "role" : "session";
363
382
  const query = this.#browser.query;
364
383
  const view = this.#browser.pickerOrder({
365
384
  providers: false,
@@ -390,13 +409,15 @@ export class ModelPickerComponent implements Component {
390
409
  ? [span(this.#configError, "error")]
391
410
  : mode === "task"
392
411
  ? [span(TASK_STATUS_HINT, "warning")]
393
- : mode === "role"
394
- ? QUICK_ROLE_STATUS_HINT
395
- : STATUS_HINT;
412
+ : mode === "selection"
413
+ ? SELECT_ONLY_STATUS_HINT
414
+ : mode === "role"
415
+ ? QUICK_ROLE_STATUS_HINT
416
+ : STATUS_HINT;
396
417
  const cycleKey = editorKey("app.model.cycleForward") || formatKeyHint("ctrl+p");
397
418
  const node = picker(
398
419
  {
399
- title: mode === "task" ? "Switch task model" : "Switch model",
420
+ title: mode === "task" ? "Switch task model" : mode === "selection" ? "Select model" : "Switch model",
400
421
  subtitle,
401
422
  icon: "cpu",
402
423
  noun: mode === "role" ? "roles" : "models",
@@ -405,7 +426,8 @@ export class ModelPickerComponent implements Component {
405
426
  preview: "below",
406
427
  query,
407
428
  cursor,
408
- placeholder: mode === "task" ? "Search models…" : "Search models, @ for quick roles…",
429
+ placeholder:
430
+ mode === "task" || mode === "selection" ? "Search models…" : "Search models, @ for quick roles…",
409
431
  columns: MODEL_PICKER_COLUMNS,
410
432
  ...catalogue,
411
433
  order: view.order,
@@ -417,11 +439,17 @@ export class ModelPickerComponent implements Component {
417
439
  actions: compact([
418
440
  pickerAction(
419
441
  "use",
420
- mode === "role" ? "Apply role" : mode === "task" ? "Use for Task subagents" : "Use for session",
442
+ mode === "role"
443
+ ? "Apply role"
444
+ : mode === "task"
445
+ ? "Use for Task subagents"
446
+ : mode === "selection"
447
+ ? "Select model"
448
+ : "Use for session",
421
449
  "enter",
422
450
  { primary: true },
423
451
  ),
424
- this.#taskModeKey !== undefined && mode !== "role"
452
+ this.#taskModeKey !== undefined && mode !== "role" && mode !== "selection"
425
453
  ? pickerAction("task", "Task model", this.#taskModeKey, { on: mode === "task" })
426
454
  : undefined,
427
455
  query.length > 0 ? { ...CLOSE_ACTION, label: "Clear search" } : CLOSE_ACTION,
@@ -435,7 +463,7 @@ export class ModelPickerComponent implements Component {
435
463
 
436
464
  /** Root card over the status line, the embedded browser, and the mode's key hints. */
437
465
  #describeCard(): NativeNode {
438
- const mode = this.#taskMode ? "task" : this.#roleMode ? "role" : "session";
466
+ const mode = this.#selectOnly ? "selection" : this.#taskMode ? "task" : this.#roleMode ? "role" : "session";
439
467
  const memo = `${mode}\0${this.#configError ?? ""}\0${this.#taskModeKey ?? ""}`;
440
468
  if (this.#nativeRoot?.memo === memo) return this.#nativeRoot.node;
441
469
 
@@ -443,7 +471,14 @@ export class ModelPickerComponent implements Component {
443
471
  ? span(this.#configError, "error")
444
472
  : this.#taskMode
445
473
  ? span(TASK_STATUS_HINT, "error")
446
- : span(this.#roleMode ? QUICK_ROLE_STATUS_HINT : STATUS_HINT, "muted");
474
+ : span(
475
+ mode === "selection"
476
+ ? SELECT_ONLY_STATUS_HINT
477
+ : this.#roleMode
478
+ ? QUICK_ROLE_STATUS_HINT
479
+ : STATUS_HINT,
480
+ "muted",
481
+ );
447
482
  const hints: (NativeHint | undefined)[] = [
448
483
  actionHint(["tui.select.up", "tui.select.down"], mode === "role" ? "roles" : "models"),
449
484
  {
@@ -453,20 +488,22 @@ export class ModelPickerComponent implements Component {
453
488
  ? "apply role model"
454
489
  : mode === "task"
455
490
  ? "use for Task subagents"
456
- : "use for this session",
491
+ : mode === "selection"
492
+ ? "select model"
493
+ : "use for this session",
457
494
  },
458
495
  { keys: [], label: "type to search" },
459
496
  ];
460
497
  if (mode === "session") hints.push({ keys: ["@"], label: "quick roles" });
461
498
  hints.push(actionHint("tui.select.cancel", "close"));
462
- if (this.#taskModeKey !== undefined && !this.#roleMode) {
499
+ if (this.#taskModeKey !== undefined && !this.#roleMode && mode !== "selection") {
463
500
  hints.push({ keys: [this.#taskModeKey], label: this.#taskMode ? "session model" : "task model" });
464
501
  }
465
502
  // `card` directly: the task-mode tone is a common prop `overlayCard` doesn't take.
466
503
  const node = card(
467
504
  {
468
505
  role: "omp.overlay.model-picker",
469
- head: this.#taskMode ? "Switch Task Model" : "Switch Model",
506
+ head: mode === "task" ? "Switch Task Model" : mode === "selection" ? "Select Model" : "Switch Model",
470
507
  tone: this.#taskMode ? "error" : undefined,
471
508
  },
472
509
  [text([status], { wrap: "word" }), this.#browser, hintsRow(hints)],
@@ -1338,6 +1338,8 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
1338
1338
  allSessions?: T[];
1339
1339
  /** Picker heading; defaults to "Resume Session". */
1340
1340
  title?: string;
1341
+ /** Primary action label; defaults to "Resume" (ANSI footer defaults to "select"). */
1342
+ actionLabel?: string;
1341
1343
  /** Fixed scope label, or false to omit the scope suffix. */
1342
1344
  scopeLabel?: string | false;
1343
1345
  /**
@@ -1405,6 +1407,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1405
1407
  readonly #getTerminalRows: () => number;
1406
1408
  readonly #fillHeight: boolean;
1407
1409
  readonly #title: string;
1410
+ readonly #actionLabel: string;
1408
1411
  readonly #scopeLabel: string | false | undefined;
1409
1412
  /** What `#messageContainer` shows, for the native description. */
1410
1413
  #message: SessionPickerMessage | undefined;
@@ -1462,6 +1465,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1462
1465
  this.#getTerminalRows = options.getTerminalRows ?? (() => 24);
1463
1466
  this.#fillHeight = options.fillHeight ?? false;
1464
1467
  this.#title = options.title ?? "Resume Session";
1468
+ this.#actionLabel = options.actionLabel ?? "Resume";
1465
1469
  this.#pickerTitle = options.title;
1466
1470
  this.#standalone = options.standalone ?? false;
1467
1471
  this.#scopeLabel = options.scopeLabel;
@@ -1496,9 +1500,11 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1496
1500
  onExit();
1497
1501
  };
1498
1502
  this.#sessionList.onRequestRender = () => this.#onRequestRender?.();
1499
- this.#sessionList.onDeleteRequest = (session: T) => {
1500
- this.#showDeleteConfirmation(session);
1501
- };
1503
+ if (this.#onDelete) {
1504
+ this.#sessionList.onDeleteRequest = (session: T) => {
1505
+ this.#showDeleteConfirmation(session);
1506
+ };
1507
+ }
1502
1508
  if (this.#loadAllSessions || this.#globalSessions) {
1503
1509
  this.#sessionList.onToggleScope = () => {
1504
1510
  void this.#toggleScope();
@@ -1676,7 +1682,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1676
1682
  const cancel = interruptKey();
1677
1683
  const hint = theme.fg(
1678
1684
  "muted",
1679
- `[${formatKeyHints(["delete", "backspace"])} delete · ${formatKeyHint("enter")} select · ${formatKeyHint("tab")} ${scopeHint} · ${cancel} cancel]`,
1685
+ `[${this.#onDelete ? `${formatKeyHints(["delete", "backspace"])} delete · ` : ""}${formatKeyHint("enter")} select · ${formatKeyHint("tab")} ${scopeHint} · ${cancel} cancel]`,
1680
1686
  );
1681
1687
  return [row("", width), row(hint, width), row("", width), bottomBorder(width)];
1682
1688
  }
@@ -1724,10 +1730,9 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1724
1730
  } else {
1725
1731
  children.push(this.#sessionList.searchInput, list);
1726
1732
  }
1727
- const hints: (NativeHint | undefined)[] = [
1728
- { keys: ["delete", "backspace"], label: "delete" },
1729
- { keys: ["enter"], label: "select" },
1730
- ];
1733
+ const hints: (NativeHint | undefined)[] = [];
1734
+ if (this.#onDelete) hints.push({ keys: ["delete", "backspace"], label: "delete" });
1735
+ hints.push({ keys: ["enter"], label: "select" });
1731
1736
  if (this.#sessionList.onToggleScope) {
1732
1737
  hints.push({ keys: ["tab"], label: this.#scope === "all" ? "current folder" : "all projects" });
1733
1738
  }
@@ -1774,7 +1779,7 @@ export class SessionSelectorComponent<T extends SessionSelectorEntry = SessionSe
1774
1779
  }
1775
1780
  const loading = message?.kind === "loading";
1776
1781
  const toggle = list.onToggleScope !== undefined;
1777
- const actions = [pickerAction("resume", "Resume", "enter", { primary: true })];
1782
+ const actions = [pickerAction("resume", this.#actionLabel, "enter", { primary: true })];
1778
1783
  if (this.#onDelete) actions.push(pickerAction("delete", "Delete", "backspace"));
1779
1784
  if (toggle) {
1780
1785
  actions.push(pickerAction("scope", this.#scope === "all" ? "This folder" : "All projects", "tab"));
@@ -76,6 +76,7 @@ export interface InternalUrlCallerContext {
76
76
  cwd?: string;
77
77
  sessionFile?: string;
78
78
  sessionId?: string;
79
+ seanceSourceArtifactsDir?: string;
79
80
  }
80
81
 
81
82
  /**
@@ -17,7 +17,7 @@ export function wrapTreeLine(line: string, width: number, theme: Theme): string[
17
17
  const index = line.indexOf(marker);
18
18
  if (index < 0) continue;
19
19
  const before = line.slice(0, index);
20
- if (!/^[ │]*$/.test(Bun.stripANSI(before))) continue;
20
+ if (!/^ *$/.test(Bun.stripANSI(before).replaceAll(theme.tree.vertical, ""))) continue;
21
21
  const end = index + marker.length + 1;
22
22
  if (line[end - 1] !== " ") continue;
23
23
  const connectorWidth = visibleWidth(connector);
@@ -29,3 +29,30 @@ export function wrapTreeLine(line: string, width: number, theme: Theme): string[
29
29
  }
30
30
  return undefined;
31
31
  }
32
+
33
+ const SGR = /\x1b\[[0-9;]*m/y;
34
+
35
+ /** End of the leading run of spaces, `vertical` guides and SGR escapes that a tree detail row hangs beneath. */
36
+ function guideRunEnd(line: string, vertical: string): number {
37
+ let index = 0;
38
+ while (index < line.length) {
39
+ if (line[index] === " ") {
40
+ index++;
41
+ } else if (vertical && line.startsWith(vertical, index)) {
42
+ index += vertical.length;
43
+ } else {
44
+ SGR.lastIndex = index;
45
+ if (!SGR.test(line)) break;
46
+ index = SGR.lastIndex;
47
+ }
48
+ }
49
+ return index;
50
+ }
51
+
52
+ /** Hang a tree row beneath its connector, or a connector-less detail row beneath its guide rails. */
53
+ export function wrapGuidedLine(line: string, width: number, theme: Theme): string[] {
54
+ const tree = wrapTreeLine(line, width, theme);
55
+ if (tree) return tree;
56
+ const prefix = line.slice(0, guideRunEnd(line, theme.tree.vertical));
57
+ return wrapPrefixedLine(prefix, prefix, line.slice(prefix.length), width);
58
+ }
@@ -233,15 +233,29 @@ export function getPreviewLines(text: string, maxLines: number, maxLineLen: numb
233
233
  return lines.slice(0, maxLines).map(l => truncateToWidth(l.trim(), maxLineLen, ellipsis));
234
234
  }
235
235
 
236
+ /** Collapse a possibly multi-line string into one line; whitespace runs (tabs included) become one space. */
237
+ export function flattenLine(text: string): string {
238
+ return text.replace(/\s+/g, " ").trim();
239
+ }
240
+
236
241
  /**
237
242
  * Collapse a possibly multi-line string into a single line, then truncate it to
238
243
  * `maxWidth` display cells. {@link truncateToWidth} alone caps width but
239
244
  * newlines are zero-width, so multi-line content (markdown briefs, tool args,
240
245
  * provider errors) would otherwise spill a single status row across several
241
- * visual lines. Whitespace runs collapse to one space, so tabs are handled too.
246
+ * visual lines.
242
247
  */
243
248
  export function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string {
244
- return truncateToWidth(text.replace(/\s+/g, " ").trim(), maxWidth, ellipsis);
249
+ return truncateToWidth(flattenLine(text), maxWidth, ellipsis);
250
+ }
251
+
252
+ /**
253
+ * Clip a result row to `maxWidth` only while collapsed. Expanded rows stay whole
254
+ * so the frame wraps them: the expand key ({@link formatExpandHint}) must reveal
255
+ * text, not re-clip it.
256
+ */
257
+ export function clipCollapsed(text: string, maxWidth: number, expanded: boolean | undefined): string {
258
+ return expanded ? text : truncateToWidth(text, maxWidth);
245
259
  }
246
260
 
247
261
  // =============================================================================
@@ -1977,6 +1977,7 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
1977
1977
  * cadence while a late successful fetch can still refresh the cached segment.
1978
1978
  */
1979
1979
  refreshUsageInBackground(): void {
1980
+ if (this.#disposed) return;
1980
1981
  const now = Date.now();
1981
1982
  const session = this.session;
1982
1983
  const usageContextKey = this.#getUsageContextKey(session);