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