@oh-my-pi/pi-tui 18.11.2 → 18.11.3
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 +16 -18
- 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/theme/symbols.d.ts +1 -1
- package/dist/types/tools/task.d.ts +8 -0
- package/package.json +9 -9
- 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/status-line/segments.ts +15 -5
- package/src/terminal-capabilities.ts +9 -19
- package/src/theme/symbols.ts +1 -0
- package/src/theme/theme-class.ts +1 -0
- package/src/tools/task.ts +8 -0
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,21 @@
|
|
|
2
2
|
|
|
3
3
|
## [Unreleased]
|
|
4
4
|
|
|
5
|
+
## [18.11.3] - 2026-10-16
|
|
6
|
+
|
|
7
|
+
### Added
|
|
8
|
+
|
|
9
|
+
- Added selection-only session and model picker actions for `/seance`, so choosing a source or model leaves the active session and model unchanged ([#15036](https://github.com/can1357/oh-my-pi/pull/15036) by [@anatoli-tsinovoy](https://github.com/anatoli-tsinovoy))
|
|
10
|
+
|
|
11
|
+
### Changed
|
|
12
|
+
|
|
13
|
+
- `ApprovalDialogComponent` shows a choice list (Approve, an optional `session` row, Deny): the arrow keys move between them and Enter (armed after `APPROVE_ARM_MS`) takes the highlighted one; while a composer draft holds Enter the highlight sits on Deny, relabeled "Deny and send message"; Tern clicks on a choice take it; Page Up/Down scroll the details
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
|
|
17
|
+
- Fixed a streamed Markdown list keeping every item out of native scrollback until the turn ended, so rows that scrolled off screen were unreachable mid-stream; items in front of the newest one now retire as soon as it begins ([#13676](https://github.com/can1357/oh-my-pi/issues/13676), [#15993](https://github.com/can1357/oh-my-pi/pull/15993) by [@shgew](https://github.com/shgew))
|
|
18
|
+
- Fixed SIXEL charts and SVG figures being resampled before display, which softened their text, on terminals whose cell height is not a multiple of 6 such as Windows Terminal ([#16007](https://github.com/can1357/oh-my-pi/issues/16007))
|
|
19
|
+
|
|
5
20
|
## [18.11.2] - 2026-10-16
|
|
6
21
|
|
|
7
22
|
### Changed
|
|
@@ -2568,21 +2583,4 @@
|
|
|
2568
2583
|
- Fixed visible tail line updates when appending new content during viewport overflow conditions
|
|
2569
2584
|
- Fixed cursor positioning instability when appending content under external cursor relocation by using absolute screen addressing instead of relative cursor movement
|
|
2570
2585
|
|
|
2571
|
-
|
|
2572
|
-
|
|
2573
|
-
### Breaking Changes
|
|
2574
|
-
|
|
2575
|
-
- Removed `getMermaidImage` callback from MarkdownTheme; replaced with `getMermaidAscii` that accepts ASCII string instead of image data
|
|
2576
|
-
- Removed mermaid module exports (`renderMermaidToPng`, `extractMermaidBlocks`, `prerenderMermaidBlocks`, `MermaidImage` interface)
|
|
2577
|
-
|
|
2578
|
-
### Changed
|
|
2579
|
-
|
|
2580
|
-
- Mermaid diagrams now render as ASCII text instead of terminal graphics protocol images
|
|
2581
|
-
|
|
2582
|
-
## [13.5.1] - 2026-03-01
|
|
2583
|
-
|
|
2584
|
-
### Fixed
|
|
2585
|
-
|
|
2586
|
-
- Fixed viewport shift handling to prevent stale content when mixed updates remap screen rows
|
|
2587
|
-
|
|
2588
|
-
Older entries are archived in [packages/tui/CHANGELOG.md@9748d65121ba](https://github.com/can1357/oh-my-pi/blob/9748d65121bac9589d3b136a6d839d76ec52dc2e/packages/tui/CHANGELOG.md).
|
|
2586
|
+
Older entries are archived in [packages/tui/CHANGELOG.md@20c388be2131](https://github.com/can1357/oh-my-pi/blob/20c388be2131e06f731a8759ec3ab6121d6ada1c/packages/tui/CHANGELOG.md).
|
|
@@ -185,9 +185,10 @@ export declare class Markdown implements Component {
|
|
|
185
185
|
*/
|
|
186
186
|
describe(_cx: DescribeContext): NativeNode;
|
|
187
187
|
/**
|
|
188
|
-
* Width-independent source prefix of the last render
|
|
189
|
-
*
|
|
190
|
-
* display-math opener whose block an append could still close
|
|
188
|
+
* Width-independent source prefix of the last render that no append can
|
|
189
|
+
* change: up to a frozen Markdown block boundary, stopping in front of a
|
|
190
|
+
* display-math opener whose block an append could still close, or, while a
|
|
191
|
+
* list streams as the last block, up to its newest top-level item. Only
|
|
191
192
|
* meaningful while streaming (transient render cache on); grows
|
|
192
193
|
* monotonically under append-only `setText`.
|
|
193
194
|
*/
|
|
@@ -1,19 +1,23 @@
|
|
|
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, type TUI } from "../index.js";
|
|
15
|
-
import type
|
|
16
|
-
/**
|
|
19
|
+
import { type NativeNode, type NativeUiEvent } from "../native/node.js";
|
|
20
|
+
/** Enter and the approve key are ignored this long after mount, so a key sent as the prompt appears cannot answer it. */
|
|
17
21
|
export declare const APPROVE_ARM_MS = 500;
|
|
18
22
|
interface ApprovalDialogCallbacks {
|
|
19
23
|
onApprove(): void;
|
|
@@ -39,6 +43,11 @@ interface ApprovalDialogOptions {
|
|
|
39
43
|
onTimeoutReset?: () => void;
|
|
40
44
|
/** Route typing to the composer instead of dropping it. */
|
|
41
45
|
draft?: ApprovalDialogDraft;
|
|
46
|
+
/** Offer to approve this and later like calls for the rest of the session, as the row between Approve and Deny. */
|
|
47
|
+
session?: {
|
|
48
|
+
label: string;
|
|
49
|
+
onApprove(): void;
|
|
50
|
+
};
|
|
42
51
|
}
|
|
43
52
|
export declare class ApprovalDialogComponent implements Component {
|
|
44
53
|
#private;
|
|
@@ -59,11 +68,12 @@ export declare class ApprovalDialogComponent implements Component {
|
|
|
59
68
|
/**
|
|
60
69
|
* In the dock, in the composer's place and framed as the composer is (an
|
|
61
70
|
* `omp.editor.*` root role), like the ask dialog, rather than a card over
|
|
62
|
-
* the transcript: the title, the details,
|
|
63
|
-
*
|
|
71
|
+
* the transcript: the title, the details, the choices with the keyboard's
|
|
72
|
+
* highlight (a click takes one), then Details (with a full prompt), Deny,
|
|
73
|
+
* and the primary Approve, whose keys show in their tooltips.
|
|
64
74
|
*/
|
|
65
75
|
describe(): NativeNode;
|
|
66
|
-
/** Deny, Approve, and Details run exactly what Esc, the approve key, and the expand key run. */
|
|
76
|
+
/** Deny, Approve, and Details run exactly what Esc, the approve key, and the expand key run; a click on a choice takes it. */
|
|
67
77
|
handleNativeEvent(event: NativeUiEvent): void;
|
|
68
78
|
}
|
|
69
79
|
export {};
|
|
@@ -57,6 +57,8 @@ export interface ModelPickerOptions {
|
|
|
57
57
|
taskModeKeys?: readonly KeyId[];
|
|
58
58
|
/** `provider/id` highlighted and preselected in task mode (current Task subagent model). */
|
|
59
59
|
taskSelector?: string;
|
|
60
|
+
/** Present selection-only chrome; consumers handle the chosen selector without mutating a session. */
|
|
61
|
+
selectOnly?: boolean;
|
|
60
62
|
}
|
|
61
63
|
/** Rows the browser renders around its list window (search + blank, blank + two detail rows). */
|
|
62
64
|
export declare const BROWSER_FRAME_ROWS = 5;
|
|
@@ -127,6 +127,8 @@ export interface SessionSelectorOptions<T extends SessionSelectorEntry = Session
|
|
|
127
127
|
allSessions?: T[];
|
|
128
128
|
/** Picker heading; defaults to "Resume Session". */
|
|
129
129
|
title?: string;
|
|
130
|
+
/** Primary action label; defaults to "Resume" (ANSI footer defaults to "select"). */
|
|
131
|
+
actionLabel?: string;
|
|
130
132
|
/** Fixed scope label, or false to omit the scope suffix. */
|
|
131
133
|
scopeLabel?: string | false;
|
|
132
134
|
/**
|
|
@@ -9,7 +9,7 @@ export type SymbolMap = Record<SymbolKey, string>;
|
|
|
9
9
|
* resolves through `Theme.cmd` to either a dedicated `cmd.*` symbol or an
|
|
10
10
|
* existing `icon.*` symbol shared with the rest of the UI.
|
|
11
11
|
*/
|
|
12
|
-
export type SlashCommandIconName = "action" | "prompt" | "extension" | "settings" | "gear" | "shield" | "wave" | "compass" | "inbox" | "swap" | "expand" | "computer" | "eye" | "todo" | "stats" | "news" | "keyboard" | "export" | "clipboard" | "share" | "broadcast" | "globe" | "copy" | "plus" | "restart" | "eraser" | "trash" | "compress" | "vibrate" | "handoff" | "history" | "question" | "rocket" | "stethoscope" | "redo" | "bug" | "memory" | "pencil" | "folderMove" | "folderPlus" | "folderMinus" | "hammer" | "power" | "cart" | "model" | "plan" | "prewalk" | "goal" | "pause" | "loop" | "session" | "jobs" | "gauge" | "context" | "agents" | "branch" | "tree" | "signIn" | "signOut" | "advisor" | "host" | "package" | "fast" | "voice" | "tools" | "rule" | "skill" | "mcp" | "pin";
|
|
12
|
+
export type SlashCommandIconName = "action" | "prompt" | "extension" | "settings" | "gear" | "shield" | "wave" | "compass" | "inbox" | "swap" | "expand" | "computer" | "eye" | "todo" | "stats" | "news" | "keyboard" | "export" | "clipboard" | "share" | "broadcast" | "globe" | "copy" | "plus" | "restart" | "eraser" | "trash" | "compress" | "vibrate" | "handoff" | "history" | "question" | "rocket" | "stethoscope" | "redo" | "bug" | "memory" | "pencil" | "folderMove" | "folderPlus" | "folderMinus" | "hammer" | "power" | "cart" | "model" | "plan" | "prewalk" | "goal" | "pause" | "loop" | "session" | "jobs" | "gauge" | "context" | "ghost" | "agents" | "branch" | "tree" | "signIn" | "signOut" | "advisor" | "host" | "package" | "fast" | "voice" | "tools" | "rule" | "skill" | "mcp" | "pin";
|
|
13
13
|
export declare const SYMBOL_PRESETS: Record<SymbolPreset, SymbolMap>;
|
|
14
14
|
export type SpinnerType = "status" | "activity";
|
|
15
15
|
export declare const SPINNER_FRAMES: Record<SymbolPreset, Record<SpinnerType, string[]>>;
|
|
@@ -102,6 +102,10 @@ export interface TaskItem {
|
|
|
102
102
|
name?: string;
|
|
103
103
|
/** Agent type to run this item (e.g. "scout"). Defaults to the spawn policy's default agent. */
|
|
104
104
|
agent?: string;
|
|
105
|
+
/** Historical source session selected by the bundled consult agent. */
|
|
106
|
+
sourceSession?: string;
|
|
107
|
+
/** Optional model selector override for the historical source session. */
|
|
108
|
+
model?: string | string[];
|
|
105
109
|
/** The work; required by the schema. */
|
|
106
110
|
task?: string;
|
|
107
111
|
/** How open-ended the work is; required by the schema and the child's sole `auto` thinking classification input. */
|
|
@@ -128,6 +132,10 @@ export interface TaskParams {
|
|
|
128
132
|
name?: string;
|
|
129
133
|
/** Agent type to spawn (flat form); omitted values resolve from the session spawn policy. */
|
|
130
134
|
agent?: string;
|
|
135
|
+
/** Historical source session selected by the bundled consult agent. */
|
|
136
|
+
sourceSession?: string;
|
|
137
|
+
/** Optional model selector override for the historical source session. */
|
|
138
|
+
model?: string | string[];
|
|
131
139
|
/** The work (flat form). */
|
|
132
140
|
task?: string;
|
|
133
141
|
/** How open-ended the work is (flat form); see {@link TaskItem.solutionSpace}. */
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"type": "module",
|
|
3
3
|
"name": "@oh-my-pi/pi-tui",
|
|
4
|
-
"version": "18.11.
|
|
4
|
+
"version": "18.11.3",
|
|
5
5
|
"description": "Terminal User Interface library with differential rendering for efficient text-based applications",
|
|
6
6
|
"homepage": "https://omp.sh",
|
|
7
7
|
"author": {
|
|
@@ -41,14 +41,14 @@
|
|
|
41
41
|
"gen:glyphs": "bun scripts/gen-glyphs.ts"
|
|
42
42
|
},
|
|
43
43
|
"dependencies": {
|
|
44
|
-
"@oh-my-pi/omptype": "18.11.
|
|
45
|
-
"@oh-my-pi/pi-agent-core": "18.11.
|
|
46
|
-
"@oh-my-pi/pi-ai": "18.11.
|
|
47
|
-
"@oh-my-pi/pi-catalog": "18.11.
|
|
48
|
-
"@oh-my-pi/pi-natives": "18.11.
|
|
49
|
-
"@oh-my-pi/pi-utils": "18.11.
|
|
50
|
-
"@oh-my-pi/pi-wire": "18.11.
|
|
51
|
-
"@oh-my-pi/snapcompact": "18.11.
|
|
44
|
+
"@oh-my-pi/omptype": "18.11.3",
|
|
45
|
+
"@oh-my-pi/pi-agent-core": "18.11.3",
|
|
46
|
+
"@oh-my-pi/pi-ai": "18.11.3",
|
|
47
|
+
"@oh-my-pi/pi-catalog": "18.11.3",
|
|
48
|
+
"@oh-my-pi/pi-natives": "18.11.3",
|
|
49
|
+
"@oh-my-pi/pi-utils": "18.11.3",
|
|
50
|
+
"@oh-my-pi/pi-wire": "18.11.3",
|
|
51
|
+
"@oh-my-pi/snapcompact": "18.11.3"
|
|
52
52
|
},
|
|
53
53
|
"devDependencies": {
|
|
54
54
|
"kitty-vt-wasm": "^0.2.0"
|
|
@@ -1779,6 +1779,33 @@ function loosenListItem(item: Tokens.ListItem): Tokens.ListItem {
|
|
|
1779
1779
|
};
|
|
1780
1780
|
}
|
|
1781
1781
|
|
|
1782
|
+
const REFERENCE_LABEL_RE = /\[[^[\]]*[^\s[\]][^[\]]*\]/;
|
|
1783
|
+
const LABEL_FREE_TOKENS = new Set(["checkbox", "code", "codespan", "escape", "html", "image", "math"]);
|
|
1784
|
+
|
|
1785
|
+
function labelSource(tokens: readonly Token[]): string {
|
|
1786
|
+
let source = "";
|
|
1787
|
+
for (const token of tokens) {
|
|
1788
|
+
if (token.type === "list") {
|
|
1789
|
+
for (const item of (token as Tokens.List).items) source += `${labelSource(item.tokens)}\n\n`;
|
|
1790
|
+
} else if (token.type === "table") {
|
|
1791
|
+
const table = token as TableToken;
|
|
1792
|
+
for (const cell of [...table.header, ...table.rows.flat()]) source += `${labelSource(cell.tokens ?? [])}\n\n`;
|
|
1793
|
+
} else if ("tokens" in token && token.tokens) {
|
|
1794
|
+
source += labelSource(token.tokens);
|
|
1795
|
+
} else {
|
|
1796
|
+
source += LABEL_FREE_TOKENS.has(token.type) ? " " : token.raw;
|
|
1797
|
+
}
|
|
1798
|
+
}
|
|
1799
|
+
return source;
|
|
1800
|
+
}
|
|
1801
|
+
|
|
1802
|
+
/** Whether a reference definition arriving later could still turn part of `item` into a link. */
|
|
1803
|
+
function hasUnresolvedReferenceLabel(item: Tokens.ListItem): boolean {
|
|
1804
|
+
return labelSource(item.tokens)
|
|
1805
|
+
.split(BLANK_LINE_RE)
|
|
1806
|
+
.some(block => REFERENCE_LABEL_RE.test(block));
|
|
1807
|
+
}
|
|
1808
|
+
|
|
1782
1809
|
function formatHyperlink(text: string, target: string): string {
|
|
1783
1810
|
if (!TERMINAL.hyperlinks || !target) {
|
|
1784
1811
|
return text;
|
|
@@ -2145,6 +2172,7 @@ interface StreamingListCache {
|
|
|
2145
2172
|
start: number;
|
|
2146
2173
|
tailStart: number;
|
|
2147
2174
|
stableCount: number;
|
|
2175
|
+
referenceStop?: number;
|
|
2148
2176
|
list: Tokens.List;
|
|
2149
2177
|
}
|
|
2150
2178
|
/**
|
|
@@ -2232,6 +2260,13 @@ export class Markdown implements Component {
|
|
|
2232
2260
|
// The frozen prefix's leading run in front of the first of those openers:
|
|
2233
2261
|
// no append can change its tokens, so getLastRenderStableText publishes it.
|
|
2234
2262
|
#streamSettledText?: string;
|
|
2263
|
+
// The text in front of the newest top-level item of a list that is still
|
|
2264
|
+
// streaming as the last block. Once an item's marker line has ended, no
|
|
2265
|
+
// append can add to the items in front of it, and #renderList draws each
|
|
2266
|
+
// item from its own tokens alone, so their rows are final. Kept while later
|
|
2267
|
+
// texts extend it, so the published prefix never shrinks when the list
|
|
2268
|
+
// stops being the last block.
|
|
2269
|
+
#streamListSettledText?: string;
|
|
2235
2270
|
// The prefix before a rewind that only a closer on the still-growing last
|
|
2236
2271
|
// line caused. Once a later text holds no closer line after it, that
|
|
2237
2272
|
// prefix is right again, so #lexTokens puts it back.
|
|
@@ -2411,14 +2446,18 @@ export class Markdown implements Component {
|
|
|
2411
2446
|
}
|
|
2412
2447
|
|
|
2413
2448
|
/**
|
|
2414
|
-
* Width-independent source prefix of the last render
|
|
2415
|
-
*
|
|
2416
|
-
* display-math opener whose block an append could still close
|
|
2449
|
+
* Width-independent source prefix of the last render that no append can
|
|
2450
|
+
* change: up to a frozen Markdown block boundary, stopping in front of a
|
|
2451
|
+
* display-math opener whose block an append could still close, or, while a
|
|
2452
|
+
* list streams as the last block, up to its newest top-level item. Only
|
|
2417
2453
|
* meaningful while streaming (transient render cache on); grows
|
|
2418
2454
|
* monotonically under append-only `setText`.
|
|
2419
2455
|
*/
|
|
2420
2456
|
getLastRenderStableText(): string {
|
|
2421
|
-
|
|
2457
|
+
if (!this.#transientRenderCache) return "";
|
|
2458
|
+
const settled = this.#streamSettledText ?? "";
|
|
2459
|
+
const list = this.#streamListSettledText;
|
|
2460
|
+
return list !== undefined && list.length > settled.length ? list : settled;
|
|
2422
2461
|
}
|
|
2423
2462
|
|
|
2424
2463
|
get transientRenderCache(): boolean {
|
|
@@ -2589,6 +2628,9 @@ export class Markdown implements Component {
|
|
|
2589
2628
|
#checkpointStreamingList(text: string, tokens: Token[]): void {
|
|
2590
2629
|
const previous = this.#streamingListCache;
|
|
2591
2630
|
this.#streamingListCache = undefined;
|
|
2631
|
+
if (this.#streamListSettledText !== undefined && !text.startsWith(this.#streamListSettledText)) {
|
|
2632
|
+
this.#streamListSettledText = undefined;
|
|
2633
|
+
}
|
|
2592
2634
|
let index = tokens.length - 1;
|
|
2593
2635
|
while (tokens[index]?.type === "space") index--;
|
|
2594
2636
|
const token = tokens[index];
|
|
@@ -2603,6 +2645,7 @@ export class Markdown implements Component {
|
|
|
2603
2645
|
let cursor = canResume ? previous.tailStart : start;
|
|
2604
2646
|
let tailStart = start;
|
|
2605
2647
|
let stableCount = 0;
|
|
2648
|
+
let referenceStop = canResume ? previous.referenceStop : undefined;
|
|
2606
2649
|
const indent = /^ */.exec(list.raw)![0].length;
|
|
2607
2650
|
for (let i = canResume ? previous.stableCount : 0; i < list.items.length; i++) {
|
|
2608
2651
|
const item = list.items[i]!;
|
|
@@ -2614,9 +2657,34 @@ export class Markdown implements Component {
|
|
|
2614
2657
|
tailStart = position;
|
|
2615
2658
|
stableCount = i;
|
|
2616
2659
|
}
|
|
2660
|
+
if (referenceStop === undefined && hasUnresolvedReferenceLabel(item)) referenceStop = tailStart;
|
|
2617
2661
|
cursor = position + item.raw.length;
|
|
2618
2662
|
}
|
|
2619
|
-
|
|
2663
|
+
// Items from stableCount on are scanned again next time and may still
|
|
2664
|
+
// resolve their own labels, so only a finished item's label is kept.
|
|
2665
|
+
if (referenceStop !== undefined && referenceStop >= tailStart) referenceStop = undefined;
|
|
2666
|
+
this.#streamingListCache = {
|
|
2667
|
+
text,
|
|
2668
|
+
before: tokens.slice(0, index),
|
|
2669
|
+
start,
|
|
2670
|
+
tailStart,
|
|
2671
|
+
stableCount,
|
|
2672
|
+
referenceStop,
|
|
2673
|
+
list,
|
|
2674
|
+
};
|
|
2675
|
+
// No append can add to an item once a later one has begun, but a later
|
|
2676
|
+
// reference definition can still restyle a bracketed label in it. The
|
|
2677
|
+
// list must also start right at the settled boundary: in front of it may
|
|
2678
|
+
// sit a display-math opener whose closer, arriving after the list, would
|
|
2679
|
+
// turn the whole run, list included, into one math block.
|
|
2680
|
+
const settledEnd = referenceStop ?? tailStart;
|
|
2681
|
+
if (
|
|
2682
|
+
settledEnd > start &&
|
|
2683
|
+
(this.#streamSettledText?.length ?? 0) === start &&
|
|
2684
|
+
settledEnd > (this.#streamListSettledText?.length ?? 0)
|
|
2685
|
+
) {
|
|
2686
|
+
this.#streamListSettledText = text.slice(0, settledEnd);
|
|
2687
|
+
}
|
|
2620
2688
|
}
|
|
2621
2689
|
|
|
2622
2690
|
/**
|
|
@@ -2629,6 +2697,7 @@ export class Markdown implements Component {
|
|
|
2629
2697
|
this.#streamPrefixTokens = undefined;
|
|
2630
2698
|
this.#streamPrefixOpeners = NO_OPENERS;
|
|
2631
2699
|
this.#streamSettledText = undefined;
|
|
2700
|
+
this.#streamListSettledText = undefined;
|
|
2632
2701
|
this.#streamPrefixLineCache = undefined;
|
|
2633
2702
|
this.#tailRowCache = undefined;
|
|
2634
2703
|
this.#listItemRowsCache = undefined;
|
|
@@ -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"));
|
|
@@ -6,6 +6,7 @@ import { SPINNER_ADVANCE_MS, TERMINAL } from "../index";
|
|
|
6
6
|
import {
|
|
7
7
|
formatDuration,
|
|
8
8
|
formatNumber,
|
|
9
|
+
getActiveProfile,
|
|
9
10
|
getProjectDir,
|
|
10
11
|
normalizePathForComparison,
|
|
11
12
|
relativePathWithinNormalizedRoot,
|
|
@@ -215,24 +216,33 @@ const piSegment: StatusLineSegment = {
|
|
|
215
216
|
// Brand fg fades between dim gray (idle) and the accent (working) across
|
|
216
217
|
// turn edges; the component samples the tween into `brandFgAnsi`.
|
|
217
218
|
const fgAnsi = ctx.brandFgAnsi ?? theme.getFgAnsi("dim");
|
|
219
|
+
const profile = getActiveProfile();
|
|
218
220
|
// While a turn runs the brand icon becomes a braille spinner plus a
|
|
219
221
|
// whole-unit turn timer (port of rust omp's status-band active brand).
|
|
220
222
|
// No trailing pad: the group renderer owns inter-segment spacing, so a
|
|
221
223
|
// trailing space here would double the gap at the first separator (#11103).
|
|
222
224
|
const content =
|
|
223
225
|
ctx.turnElapsedMs != null
|
|
224
|
-
? `${brandSpinnerFrame(ctx.now?.getTime())} ${brandTimer(ctx.turnElapsedMs)}`
|
|
225
|
-
: theme.icon.omp
|
|
226
|
-
? theme.icon.omp
|
|
227
|
-
: "";
|
|
226
|
+
? `${brandMark(brandSpinnerFrame(ctx.now?.getTime()), profile)} ${brandTimer(ctx.turnElapsedMs)}`
|
|
227
|
+
: brandMark(theme.icon.omp, profile);
|
|
228
228
|
return { content: `${fgAnsi}${content}\x1b[39m`, visible: true };
|
|
229
229
|
},
|
|
230
230
|
describe(ctx) {
|
|
231
231
|
if (ctx.focusedAgentId) return segView([span(ctx.focusedAgentId, "warning")], "ghost", "warning");
|
|
232
232
|
// The dock's working row owns activity natively: the brand stays still.
|
|
233
|
-
|
|
233
|
+
const profile = getActiveProfile();
|
|
234
|
+
return segView(profile ? [span(profile, "muted")] : [], "omp", "muted");
|
|
234
235
|
},
|
|
235
236
|
};
|
|
237
|
+
/**
|
|
238
|
+
* Brand glyph tagged with the active named profile (`π-work`) so sessions from
|
|
239
|
+
* different profiles are distinguishable at a glance; the bare glyph for the
|
|
240
|
+
* default profile. The tag stays put while the glyph slot animates.
|
|
241
|
+
*/
|
|
242
|
+
function brandMark(glyph: string, profile: string | undefined): string {
|
|
243
|
+
if (!profile) return glyph;
|
|
244
|
+
return glyph ? `${glyph}-${profile}` : profile;
|
|
245
|
+
}
|
|
236
246
|
/** Current braille-spinner glyph on the shared clock, at the Loader's 80ms cadence. */
|
|
237
247
|
function brandSpinnerFrame(nowMs = Date.now()): string {
|
|
238
248
|
const frames = theme.getSpinnerFrames("activity");
|
|
@@ -1310,25 +1310,15 @@ export function renderImage(
|
|
|
1310
1310
|
}
|
|
1311
1311
|
|
|
1312
1312
|
if (TERMINAL.imageProtocol === ImageProtocol.Sixel) {
|
|
1313
|
-
//
|
|
1314
|
-
//
|
|
1315
|
-
//
|
|
1316
|
-
//
|
|
1317
|
-
//
|
|
1318
|
-
|
|
1319
|
-
|
|
1320
|
-
|
|
1321
|
-
|
|
1322
|
-
// the width by the same ratio so resize_exact preserves the aspect
|
|
1323
|
-
// ratio instead of squashing the image vertically.
|
|
1324
|
-
const rawHeightPx = Math.max(1, fit.rows * cellDims.heightPx);
|
|
1325
|
-
const targetHeightPx = Math.max(6, Math.floor(rawHeightPx / 6) * 6);
|
|
1326
|
-
const heightScale = targetHeightPx / rawHeightPx;
|
|
1327
|
-
const targetWidthPx = Math.max(1, Math.round(fit.columns * cellDims.widthPx * heightScale));
|
|
1328
|
-
const rows = Math.max(1, Math.ceil(targetHeightPx / cellDims.heightPx));
|
|
1329
|
-
const sequence = options.sixel
|
|
1330
|
-
? options.sixel(targetWidthPx, targetHeightPx)
|
|
1331
|
-
: encodeSixelNow(base64Data, targetWidthPx, targetHeightPx);
|
|
1313
|
+
// Encode the cell box itself, so a raster drawn at the cell size
|
|
1314
|
+
// (SvgFigure) is placed 1:1 instead of resampled. The encoder drops a
|
|
1315
|
+
// partial last 6px band rather than letting the terminal pad it into
|
|
1316
|
+
// the next row; only a box under one band (sub-6px cells) still takes
|
|
1317
|
+
// the rows of a whole one.
|
|
1318
|
+
const heightPx = fit.rows * cellDims.heightPx;
|
|
1319
|
+
const widthPx = fit.columns * cellDims.widthPx;
|
|
1320
|
+
const rows = heightPx < 6 ? Math.ceil(6 / cellDims.heightPx) : fit.rows;
|
|
1321
|
+
const sequence = options.sixel ? options.sixel(widthPx, heightPx) : encodeSixelNow(base64Data, widthPx, heightPx);
|
|
1332
1322
|
if (sequence === null) return null;
|
|
1333
1323
|
// Undefined while the provider's encode is pending: the rows stay reserved.
|
|
1334
1324
|
return { sequence, rows };
|
package/src/theme/symbols.ts
CHANGED
package/src/theme/theme-class.ts
CHANGED
|
@@ -729,6 +729,7 @@ export class Theme {
|
|
|
729
729
|
jobs: this.#symbols["icon.job"],
|
|
730
730
|
gauge: this.#symbols["icon.throughput"],
|
|
731
731
|
context: this.#symbols["icon.context"],
|
|
732
|
+
ghost: this.#symbols["icon.ghost"],
|
|
732
733
|
agents: this.#symbols["icon.agents"],
|
|
733
734
|
branch: this.#symbols["icon.branch"],
|
|
734
735
|
tree: this.#symbols["icon.worktree"],
|
package/src/tools/task.ts
CHANGED
|
@@ -2204,6 +2204,10 @@ export interface TaskItem {
|
|
|
2204
2204
|
name?: string;
|
|
2205
2205
|
/** Agent type to run this item (e.g. "scout"). Defaults to the spawn policy's default agent. */
|
|
2206
2206
|
agent?: string;
|
|
2207
|
+
/** Historical source session selected by the bundled consult agent. */
|
|
2208
|
+
sourceSession?: string;
|
|
2209
|
+
/** Optional model selector override for the historical source session. */
|
|
2210
|
+
model?: string | string[];
|
|
2207
2211
|
/** The work; required by the schema. */
|
|
2208
2212
|
task?: string;
|
|
2209
2213
|
/** How open-ended the work is; required by the schema and the child's sole `auto` thinking classification input. */
|
|
@@ -2231,6 +2235,10 @@ export interface TaskParams {
|
|
|
2231
2235
|
name?: string;
|
|
2232
2236
|
/** Agent type to spawn (flat form); omitted values resolve from the session spawn policy. */
|
|
2233
2237
|
agent?: string;
|
|
2238
|
+
/** Historical source session selected by the bundled consult agent. */
|
|
2239
|
+
sourceSession?: string;
|
|
2240
|
+
/** Optional model selector override for the historical source session. */
|
|
2241
|
+
model?: string | string[];
|
|
2234
2242
|
/** The work (flat form). */
|
|
2235
2243
|
task?: string;
|
|
2236
2244
|
/** How open-ended the work is (flat form); see {@link TaskItem.solutionSpace}. */
|