@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.
- package/CHANGELOG.md +37 -100
- package/README.md +36 -0
- package/dist/types/components/markdown.d.ts +4 -3
- package/dist/types/overlays/approval-dialog.d.ts +25 -15
- package/dist/types/overlays/model-picker.d.ts +2 -0
- package/dist/types/overlays/session-selector.d.ts +2 -0
- package/dist/types/prompt/internal-url-autocomplete.d.ts +1 -0
- package/dist/types/render/hanging-wrap.d.ts +2 -0
- package/dist/types/render/render-utils.d.ts +9 -1
- package/dist/types/status-line/types.d.ts +3 -0
- package/dist/types/terminal-capabilities.d.ts +8 -1
- package/dist/types/terminal-multiplexer.d.ts +6 -0
- package/dist/types/theme/symbols.d.ts +1 -1
- package/dist/types/theme/theme.d.ts +11 -0
- package/dist/types/tools/ast-edit.d.ts +2 -2
- package/dist/types/tools/ast-grep.d.ts +2 -2
- package/dist/types/tools/edit.d.ts +2 -0
- package/dist/types/tools/find.d.ts +14 -4
- package/dist/types/tools/glob.d.ts +3 -3
- package/dist/types/tools/grep.d.ts +2 -2
- package/dist/types/tools/read.d.ts +13 -5
- package/dist/types/tools/task.d.ts +8 -0
- package/dist/types/tools/write.d.ts +2 -0
- package/package.json +9 -9
- package/src/chat/assistant-message.ts +3 -1
- package/src/chat/read-tool-group.ts +17 -9
- package/src/chat/tool-execution.ts +23 -6
- package/src/components/image.ts +31 -14
- package/src/components/markdown.ts +74 -5
- package/src/overlays/approval-dialog.ts +104 -36
- package/src/overlays/model-picker.ts +58 -21
- package/src/overlays/session-selector.ts +14 -9
- package/src/prompt/internal-url-autocomplete.ts +1 -0
- package/src/render/hanging-wrap.ts +28 -1
- package/src/render/render-utils.ts +16 -2
- package/src/status-line/component.ts +1 -0
- package/src/status-line/segments.ts +16 -5
- package/src/status-line/types.ts +1 -0
- package/src/terminal-capabilities.ts +35 -21
- package/src/terminal-multiplexer.ts +14 -0
- package/src/terminal.ts +11 -2
- package/src/theme/symbols.ts +1 -0
- package/src/theme/theme-class.ts +1 -0
- package/src/theme/theme.ts +46 -14
- package/src/tmux.ts +7 -1
- package/src/tools/ast-edit.ts +28 -4
- package/src/tools/ast-grep.ts +24 -4
- package/src/tools/debug.ts +2 -1
- package/src/tools/default-renderer.ts +3 -2
- package/src/tools/edit.ts +77 -16
- package/src/tools/find.ts +65 -15
- package/src/tools/glob.ts +51 -17
- package/src/tools/grep.ts +94 -11
- package/src/tools/lsp.ts +23 -18
- package/src/tools/mcp.ts +2 -2
- package/src/tools/read.ts +52 -22
- package/src/tools/task.ts +17 -7
- package/src/tools/web-search.ts +2 -1
- package/src/tools/write.ts +17 -5
- package/src/tools/yield.ts +2 -2
- 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
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
/**
|
|
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(
|
|
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(
|
|
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(
|
|
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.#
|
|
163
|
-
else if (matchesSelectDown(keyData)) this.#
|
|
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
|
-
|
|
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
|
|
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,
|
|
236
|
-
*
|
|
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
|
-
|
|
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
|
|
284
|
-
if (event.
|
|
285
|
-
|
|
286
|
-
|
|
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
|
|
303
|
-
|
|
304
|
-
|
|
305
|
-
const scroll = overflows ? ` · ${editorKeys("tui.select.
|
|
306
|
-
return `${
|
|
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(
|
|
381
|
+
#finish(answer: ApprovalAnswer): void {
|
|
315
382
|
if (this.#closed) return;
|
|
316
383
|
this.#closed = true;
|
|
317
384
|
this.#countdown?.dispose();
|
|
318
|
-
if (
|
|
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:
|
|
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(
|
|
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(
|
|
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(
|
|
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 === "
|
|
394
|
-
?
|
|
395
|
-
:
|
|
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:
|
|
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"
|
|
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(
|
|
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
|
-
:
|
|
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:
|
|
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.#
|
|
1500
|
-
this.#
|
|
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
|
-
|
|
1729
|
-
|
|
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",
|
|
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"));
|
|
@@ -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 (!/^
|
|
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.
|
|
246
|
+
* visual lines.
|
|
242
247
|
*/
|
|
243
248
|
export function previewLine(text: string, maxWidth: number, ellipsis?: Ellipsis): string {
|
|
244
|
-
return truncateToWidth(text
|
|
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);
|