@oh-my-pi/pi-tui 18.12.2 → 18.12.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.
Files changed (46) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/autocomplete.d.ts +6 -0
  3. package/dist/types/chat/skill-message.d.ts +3 -4
  4. package/dist/types/components/sparkline.d.ts +34 -0
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/native/describe.d.ts +7 -1
  7. package/dist/types/native/tone.d.ts +2 -0
  8. package/dist/types/overlays/agent-hub.d.ts +3 -3
  9. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  10. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  11. package/dist/types/overlays/model-browser.d.ts +1 -1
  12. package/dist/types/sixel.d.ts +28 -0
  13. package/dist/types/status-line/host.d.ts +3 -0
  14. package/dist/types/tools/irc.d.ts +6 -0
  15. package/dist/types/tools/job-widget.d.ts +226 -0
  16. package/dist/types/tools/output-meta.d.ts +1 -1
  17. package/dist/types/tools/streaming-output.d.ts +1 -0
  18. package/dist/types/tools/task.d.ts +12 -0
  19. package/dist/types/tools/wait.d.ts +5 -0
  20. package/package.json +9 -9
  21. package/src/autocomplete.ts +12 -1
  22. package/src/chat/skill-message.ts +19 -21
  23. package/src/components/editor.ts +17 -7
  24. package/src/components/image.ts +5 -4
  25. package/src/components/sparkline.ts +89 -0
  26. package/src/index.ts +1 -0
  27. package/src/native/describe.ts +18 -1
  28. package/src/native/tone.ts +22 -0
  29. package/src/overlays/agent-hub.ts +14 -10
  30. package/src/overlays/agent-transcript-viewer.ts +2 -2
  31. package/src/overlays/approval-dialog.ts +59 -13
  32. package/src/overlays/jobs-panel.ts +10 -0
  33. package/src/overlays/model-browser.ts +3 -0
  34. package/src/prompt/attachment-chips.ts +1 -1
  35. package/src/sixel.ts +185 -0
  36. package/src/status-line/component.ts +19 -2
  37. package/src/status-line/host.ts +3 -0
  38. package/src/terminal-capabilities.ts +4 -1
  39. package/src/theme/symbols.ts +5 -3
  40. package/src/tools/irc.ts +6 -0
  41. package/src/tools/job-widget.ts +1036 -0
  42. package/src/tools/output-meta.ts +1 -1
  43. package/src/tools/streaming-output.ts +11 -9
  44. package/src/tools/task.ts +25 -1
  45. package/src/tools/wait.ts +141 -85
  46. package/src/tui.ts +45 -5
@@ -13,8 +13,8 @@ import { fileHyperlink } from "../render";
13
13
  import { collapseSkillTokens, skillChipLabel, skillChipStyle, skillToken } from "../prompt/composer-attachments";
14
14
  import { type UserBubbleOptions, UserMessageComponent, userBubbleColor } from "./user-message";
15
15
  import type { TspSpan } from "@oh-my-pi/pi-wire";
16
- import { card, md, node, span, text } from "../native/describe";
17
- import { type NativeChild, type NativeNode, type NativeUiEvent, rootToggleExpanded } from "../native/node";
16
+ import { card, md, node, span } from "../native/describe";
17
+ import { type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
18
18
  import { Memo } from "../native/memo";
19
19
 
20
20
  /**
@@ -63,15 +63,13 @@ export class SkillMessageComponent extends Container {
63
63
  }
64
64
 
65
65
  handleNativeEvent(event: NativeUiEvent): void {
66
- const expanded = rootToggleExpanded(event);
67
- if (expanded !== undefined) this.setExpanded(expanded);
66
+ if (event.type === "toggle" && event.key === "prompt") this.setExpanded(!event.collapsed);
68
67
  }
69
68
 
70
69
  /**
71
- * A user-toned card (role `omp.skill`): the skill chip (linked to its
72
- * SKILL.md) and prompt size in the head, the user's draft as markdown, and
73
- * the loaded skill prompt, described only while expanded because it can
74
- * be large.
70
+ * A user-toned card (role `omp.skill`): the skill chip and prompt size in
71
+ * the head, the user's draft always visible, and only the loaded skill
72
+ * prompt behind a disclosure.
75
73
  */
76
74
  override describe(): NativeNode {
77
75
  return this.#native.get([this.#expanded], () => {
@@ -100,20 +98,20 @@ export class SkillMessageComponent extends Container {
100
98
  const children: NativeChild[] = [];
101
99
  if (draft) children.push(md(draft));
102
100
  const promptText = this.#expanded ? this.#extractText() : "";
103
- if (promptText) {
104
- children.push(
105
- node(
106
- "section",
107
- { head: [span("prompt", "muted")], role: "omp.skill.prompt" },
108
- [md(promptText)],
109
- "prompt",
110
- ),
111
- );
112
- }
113
- return card(
114
- { role: "omp.skill", tone: "user", head, collapsible: true, collapsed: !this.#expanded },
115
- children.length > 0 ? children : [text([span(label, "muted")])],
101
+ children.push(
102
+ node(
103
+ "section",
104
+ {
105
+ head: [span("prompt", "muted")],
106
+ role: "omp.skill.prompt",
107
+ collapsible: true,
108
+ collapsed: !this.#expanded,
109
+ },
110
+ promptText ? [md(promptText)] : [],
111
+ "prompt",
112
+ ),
116
113
  );
114
+ return card({ role: "omp.skill", tone: "user", head }, children);
117
115
  });
118
116
  }
119
117
 
@@ -704,6 +704,8 @@ export class Editor implements Component, Focusable {
704
704
  | { line: number; startCol: number; endCol: number; original: string; cursorOffset: number }
705
705
  | undefined;
706
706
  #autocompletePrefix: string = "";
707
+ /** False while the popup's untouched top row is a description-only slash hit ({@link AutocompleteItem.descriptionMatch}): Enter then submits the typed text. */
708
+ #enterAcceptsSelection = true;
707
709
  #autocompleteRequestId: number = 0;
708
710
  #autocompletePendingRequest: AutocompleteRequest | undefined;
709
711
  #autocompleteRequestRunning = false;
@@ -1916,6 +1918,7 @@ export class Editor implements Component, Focusable {
1916
1918
  this.onAutocompleteUpdate?.();
1917
1919
  } else {
1918
1920
  this.#autocompleteList.handleInput(data);
1921
+ this.#enterAcceptsSelection = true;
1919
1922
  this.onAutocompleteUpdate?.();
1920
1923
  return;
1921
1924
  }
@@ -1960,8 +1963,11 @@ export class Editor implements Component, Focusable {
1960
1963
  // Check for stale autocomplete state while an async refresh is pending
1961
1964
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1962
1965
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1963
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1964
- // Autocomplete is stale - cancel and fall through to normal submission
1966
+ if (
1967
+ !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1968
+ !this.#enterAcceptsSelection
1969
+ ) {
1970
+ // Stale, or a description-only hit the user never selected: submit the typed text.
1965
1971
  this.#cancelAutocomplete();
1966
1972
  } else {
1967
1973
  if (selected && this.#autocompleteProvider) {
@@ -1993,10 +1999,12 @@ export class Editor implements Component, Focusable {
1993
1999
  if (
1994
2000
  !selected ||
1995
2001
  !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1996
- this.#selectedSlashArgumentIsAlreadyTyped(selected)
2002
+ this.#selectedSlashArgumentIsAlreadyTyped(selected) ||
2003
+ !this.#enterAcceptsSelection
1997
2004
  ) {
1998
- // Stale, or accepting would only append whitespace to an already fully
1999
- // typed slash-command argument (`/mcp list` + Enter): cancel and fall
2005
+ // Stale, accepting would only append whitespace to an already fully
2006
+ // typed slash-command argument (`/mcp list` + Enter), or the row is a
2007
+ // description-only hit the user never selected: cancel and fall
2000
2008
  // through to normal submission instead of swallowing the keypress.
2001
2009
  this.#cancelAutocomplete();
2002
2010
  } else {
@@ -2140,9 +2148,10 @@ export class Editor implements Component, Focusable {
2140
2148
  ) {
2141
2149
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
2142
2150
  // The collapsed `/skill:` namespace row is not a runnable command; Enter submits
2143
- // immediately after applying, so only a real command may be completed here.
2151
+ // immediately after applying, so only a real command may be completed here, and
2152
+ // only one whose name matches: a description-only hit would run an unrelated command.
2144
2153
  const selected = syncResult?.items.find(item => item.value !== SKILL_NAMESPACE);
2145
- if (syncResult && selected) {
2154
+ if (syncResult && selected && !selected.descriptionMatch) {
2146
2155
  // Invalidate any pending async autocomplete so its stale results are discarded
2147
2156
  this.#autocompleteRequestId += 1;
2148
2157
  // Apply the best match and submit the completed command
@@ -4871,6 +4880,7 @@ export class Editor implements Component, Focusable {
4871
4880
  ): void {
4872
4881
  this.#autocompletePrefix = suggestions.prefix;
4873
4882
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
4883
+ this.#enterAcceptsSelection = !suggestions.items[0]?.descriptionMatch;
4874
4884
  this.#autocompleteState = state;
4875
4885
  this.onAutocompleteUpdate?.();
4876
4886
  }
@@ -1,5 +1,6 @@
1
1
  import { encodeSixelAsync } from "@oh-my-pi/pi-natives";
2
2
  import { getKittyGraphics } from "../kitty-graphics";
3
+ import { ensureSixelRasterWidth } from "../sixel";
3
4
  import {
4
5
  encodeSixelNow,
5
6
  getCellDimensions,
@@ -736,7 +737,7 @@ export class Image implements Component {
736
737
  const request: SixelEncode = { widthPx, heightPx, backgroundRgb };
737
738
  this.#sixel = request;
738
739
  const settle = (sequence: string | null): void => {
739
- request.sequence = sequence;
740
+ request.sequence = sequence === null ? null : ensureSixelRasterWidth(sequence);
740
741
  if (this.#sixel !== request || getTerminalBackgroundColor() !== backgroundRgb) return;
741
742
  this.invalidate();
742
743
  if (this.#options.requestRender) this.#options.requestRender();
@@ -845,9 +846,9 @@ export class Image implements Component {
845
846
  // height. First (rows-1) lines are empty (TUI clears them); the last
846
847
  // saves the final-row cursor, moves up to the image origin, emits the
847
848
  // image sequence, then restores the final-row cursor. When the block
848
- // straddles the viewport top, the renderer rewrites this line to the
849
- // visible slice (encodeKittyPlacementLine) from the geometry
850
- // registered below.
849
+ // straddles the viewport top, the renderer crops the source raster
850
+ // for SIXEL or rewrites the Kitty placement's visible source slice
851
+ // from the geometry registered below.
851
852
  if (this.#imageId != null && this.#budget !== undefined) {
852
853
  this.#budget.registerPlacementGeometry(
853
854
  this.#imageId,
@@ -0,0 +1,89 @@
1
+ /**
2
+ * One-row block sparklines (`▁▂▃▅▇█`) for ANSI renderers: the usage history
3
+ * in `omp usage`, a job widget's series in the `wait` card and status line.
4
+ */
5
+
6
+ /** Block glyphs from lowest to highest. */
7
+ export const SPARK_LEVELS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
8
+
9
+ /** How {@link renderSparkline} maps values to glyphs and styles them. */
10
+ export interface SparklineOptions {
11
+ /** Domain low end; values at or below it draw the lowest block. Default 0. */
12
+ readonly min?: number;
13
+ /** Domain high end; values at or above it draw the full block. Default the largest value. */
14
+ readonly max?: number;
15
+ /** Styles one glyph; `fraction` is the value's 0–1 position in the domain. */
16
+ readonly style?: (glyph: string, value: number, fraction: number) => string;
17
+ /** What a missing value draws, already styled. Default a space. */
18
+ readonly gap?: string;
19
+ }
20
+
21
+ /** The block glyph for a 0–1 fraction of the domain. */
22
+ export function sparkGlyph(fraction: number): string {
23
+ const clamped = Number.isFinite(fraction) ? Math.min(Math.max(fraction, 0), 1) : 0;
24
+ return SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.floor(clamped * SPARK_LEVELS.length))]!;
25
+ }
26
+
27
+ /** One cell per value, oldest first; `undefined` (and non-finite) values draw the gap. */
28
+ export function renderSparkline(values: readonly (number | undefined)[], options: SparklineOptions = {}): string {
29
+ const min = options.min ?? 0;
30
+ let max = options.max;
31
+ if (max === undefined) {
32
+ max = min;
33
+ for (const value of values) if (value !== undefined && Number.isFinite(value) && value > max) max = value;
34
+ }
35
+ const span = max - min;
36
+ const gap = options.gap ?? " ";
37
+ let out = "";
38
+ for (const value of values) {
39
+ if (value === undefined || !Number.isFinite(value)) {
40
+ out += gap;
41
+ continue;
42
+ }
43
+ const fraction = span > 0 ? (value - min) / span : value > min ? 1 : 0;
44
+ const glyph = sparkGlyph(fraction);
45
+ out += options.style ? options.style(glyph, value, Math.min(Math.max(fraction, 0), 1)) : glyph;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * `values` squeezed into at most `cells` points by equal-count bucket means,
52
+ * newest bucket last; shorter series come back unchanged.
53
+ */
54
+ export function fitSparkValues(values: readonly number[], cells: number): readonly number[] {
55
+ const count = Math.max(0, Math.floor(cells));
56
+ if (values.length <= count) return values;
57
+ const out: number[] = [];
58
+ for (let bucket = 0; bucket < count; bucket++) {
59
+ const from = Math.floor((bucket * values.length) / count);
60
+ const to = Math.max(from + 1, Math.floor(((bucket + 1) * values.length) / count));
61
+ let sum = 0;
62
+ for (let i = from; i < to; i++) sum += values[i]!;
63
+ out.push(sum / (to - from));
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * The largest value per time bucket over `[sinceMs, nowMs]`, `cells` buckets
70
+ * wide; buckets without a point are `undefined` (a gap in the sparkline).
71
+ */
72
+ export function peakBuckets(
73
+ points: Iterable<{ readonly at: number; readonly value: number }>,
74
+ sinceMs: number,
75
+ nowMs: number,
76
+ cells: number,
77
+ ): (number | undefined)[] {
78
+ const span = Math.max(1, nowMs - sinceMs);
79
+ const count = Math.max(0, Math.floor(cells));
80
+ const buckets: (number | undefined)[] = Array.from({ length: count }, () => undefined);
81
+ if (count === 0) return buckets;
82
+ for (const point of points) {
83
+ const offset = Math.floor(((point.at - sinceMs) / span) * count);
84
+ const index = Math.min(count - 1, Math.max(0, offset));
85
+ const prev = buckets[index];
86
+ buckets[index] = prev === undefined ? point.value : Math.max(prev, point.value);
87
+ }
88
+ return buckets;
89
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export * from "./components/section";
28
28
  export * from "./components/select-list";
29
29
  export * from "./components/settings-list";
30
30
  export * from "./components/spacer";
31
+ export * from "./components/sparkline";
31
32
  export * from "./components/tab-bar";
32
33
  export * from "./components/table";
33
34
  export * from "./components/text";
@@ -9,7 +9,7 @@
9
9
  * ]);
10
10
  * }
11
11
  */
12
- import type { TspKind, TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
12
+ import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
14
 
15
15
  /** A node of any kind. */
@@ -76,6 +76,23 @@ export function withHidden(described: NativeNode, hidden: boolean): NativeNode {
76
76
  return node(described.k, { ...described.p, hidden: true } as TspProps, described.c, described.key);
77
77
  }
78
78
 
79
+ /**
80
+ * A described tree as the node tree a prop carries (a `hovercard`): plain
81
+ * nodes only, since nothing reconciles inside a prop; component children
82
+ * (which describe themselves per frame) are left out.
83
+ */
84
+ export function propNode(described: NativeNode): TspPropNode {
85
+ const out: { k: TspKind; p?: TspProps; c?: TspPropNode[]; key?: string } = { k: described.k };
86
+ if (described.p !== undefined) out.p = described.p;
87
+ if (described.c !== undefined) {
88
+ const children: TspPropNode[] = [];
89
+ for (const child of described.c) if ("k" in child) children.push(propNode(child));
90
+ out.c = children;
91
+ }
92
+ if (described.key !== undefined) out.key = described.key;
93
+ return out as TspPropNode;
94
+ }
95
+
79
96
  /** Stable node key for an arbitrary identity string; keys join into `/` keypaths, so identities are hashed. */
80
97
  export function stableKey(identity: string): string {
81
98
  return Bun.hash(identity).toString(36);
@@ -50,6 +50,28 @@ export function backgroundChrome(bg: ThemeBg | undefined): BackgroundChrome {
50
50
  }
51
51
  }
52
52
 
53
+ /** The omp theme colour an ANSI renderer draws a TSP tone with (the inverse of {@link colorTone}). */
54
+ export function toneColor(tone: TspTone): ThemeColor {
55
+ switch (tone) {
56
+ case "neutral":
57
+ return "text";
58
+ case "accent":
59
+ case "info":
60
+ return "accent";
61
+ case "success":
62
+ return "success";
63
+ case "warning":
64
+ case "pending":
65
+ return "warning";
66
+ case "error":
67
+ return "error";
68
+ case "muted":
69
+ return "muted";
70
+ case "user":
71
+ return "customMessageLabel";
72
+ }
73
+ }
74
+
53
75
  /**
54
76
  * Tone of an omp colour used as a severity or border colour; undefined for
55
77
  * colours that carry no tone (callers pick their own fallback).
@@ -5,11 +5,11 @@
5
5
  * - Table view: every registered agent except Main (Main IS the ambient
6
6
  * chat), live from the global AgentHubRegistry — status, unread irc count,
7
7
  * current/last task, last activity. Navigate with keys, wheel, hover, and
8
- * click; `r` revives a parked agent, `x` aborts + releases one.
8
+ * click; `r` revives a parked or killed agent, `x` aborts + releases one.
9
9
  * - Chat view: per-agent transcript (incremental session-file tail, absorbed
10
10
  * from the old session observer overlay) plus an input line. Submitting
11
- * revives a parked agent, then prompts/steers it; the message lands in the
12
- * agent's persisted history via the normal prompt path.
11
+ * revives a parked or killed agent, then prompts/steers it; the message
12
+ * lands in the agent's persisted history via the normal prompt path.
13
13
  *
14
14
  * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
15
15
  */
@@ -894,7 +894,11 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
894
894
  id: "revive",
895
895
  label: "Revive",
896
896
  keys: ["r"],
897
- disabled: readOnly ?? (selected?.status === "parked" ? undefined : "Only parked agents can be revived"),
897
+ disabled:
898
+ readOnly ??
899
+ (selected?.status === "parked" || selected?.status === "aborted"
900
+ ? undefined
901
+ : "Only parked or killed agents can be revived"),
898
902
  },
899
903
  { id: "kill", label: "Kill", keys: ["x"], danger: true, disabled: readOnly ?? (selected ? undefined : true) },
900
904
  { id: "view", label: "By parent", keys: ["t"], on: this.#viewMode === "tree" },
@@ -2498,15 +2502,15 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
2498
2502
  #activateAgent(ref: TRecord): void {
2499
2503
  this.#notice = undefined;
2500
2504
  const focusAgent = this.#focusAgent;
2501
- // Aborted agents and advisor refs are read-only transcripts with no
2502
- // revivable session; open the in-hub viewer instead of failing ensureLive.
2503
- if (ref.kind === "advisor" || ref.status === "aborted" || this.#remote || !focusAgent) {
2505
+ // Advisor refs are read-only transcripts with no revivable session; open
2506
+ // the in-hub viewer instead of failing ensureLive.
2507
+ if (ref.kind === "advisor" || this.#remote || !focusAgent) {
2504
2508
  this.openChat(ref.id);
2505
2509
  return;
2506
2510
  }
2507
2511
  void (async () => {
2508
2512
  try {
2509
- await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files
2513
+ await focusAgent(ref.id); // ensureLive inside revives parked and killed agents
2510
2514
  this.#onDone();
2511
2515
  } catch (error) {
2512
2516
  this.#notice = error instanceof Error ? error.message : String(error);
@@ -2523,8 +2527,8 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
2523
2527
  this.#requestRender();
2524
2528
  return;
2525
2529
  }
2526
- if (ref.status !== "parked") {
2527
- this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
2530
+ if (ref.status !== "parked" && ref.status !== "aborted") {
2531
+ this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked or killed agents can be revived.`;
2528
2532
  this.#requestRender();
2529
2533
  return;
2530
2534
  }
@@ -235,10 +235,10 @@ export class AgentTranscriptViewer implements Component {
235
235
  this.#pollTimer.unref?.();
236
236
  }
237
237
 
238
- /** Advisor and aborted-agent transcripts are read-only. */
238
+ /** Advisor transcripts are read-only; every other agent revives on send. */
239
239
  get #sendable(): boolean {
240
240
  const ref = this.#deps.registry.get(this.#deps.agentId);
241
- if (!ref || ref.kind === "advisor" || ref.status === "aborted") return false;
241
+ if (!ref || ref.kind === "advisor") return false;
242
242
  return Boolean(this.#deps.remote || this.#deps.lifecycle);
243
243
  }
244
244
 
@@ -1,7 +1,8 @@
1
1
  /**
2
2
  * Tool-approval prompt in the composer's place, presented like the ask
3
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
4
+ * optional grants (led by a toggle that keeps them across sessions), Approve,
5
+ * an optional "approve for this session", and Deny — and an action
5
6
  * bar. The arrows move between the choices and Enter takes the highlighted
6
7
  * one; `app.approval.approve` (Ctrl+Enter by default) approves this call
7
8
  * outright and Esc denies. Enter and the approve chord only arm
@@ -45,7 +46,8 @@ export const APPROVE_ARM_MS = 500;
45
46
  /** The Deny row while a composer draft holds Enter: sending it denies the call and steers the turn. */
46
47
  const DENY_AND_SEND_LABEL = "Deny and send message";
47
48
 
48
- type ApprovalAnswer = "once" | "session" | "deny";
49
+ /** `grant<N>` takes the Nth of {@link ApprovalDialogOptions.grants}; `remember` toggles their lifetime. */
50
+ type ApprovalAnswer = "once" | "session" | "deny" | "remember" | `grant${number}`;
49
51
 
50
52
  interface ApprovalChoice {
51
53
  readonly label: string;
@@ -57,6 +59,14 @@ interface ApprovalDialogCallbacks {
57
59
  onDeny(): void;
58
60
  }
59
61
 
62
+ /** A choice that approves the call and remembers more than it: a folder, or the exact target. */
63
+ export interface ApprovalDialogGrant {
64
+ /** Label while the grant is remembered for this session. */
65
+ readonly label: string;
66
+ /** Label once the remember toggle keeps it across sessions. */
67
+ readonly alwaysLabel: string;
68
+ }
69
+
60
70
  /** The composer mounted beneath the dialog: it receives every key the dialog does not own. */
61
71
  export interface ApprovalDialogDraft {
62
72
  /** True while the composer holds a draft — text, attachments, or a paste still landing: keys then edit and send it. */
@@ -80,6 +90,12 @@ interface ApprovalDialogOptions {
80
90
  draft?: ApprovalDialogDraft;
81
91
  /** Offer to approve this and later like calls for the rest of the session, as the row between Approve and Deny. */
82
92
  session?: { label: string; onApprove(): void };
93
+ /**
94
+ * Grants to offer above Approve, led by a toggle that keeps them across sessions; Approve then
95
+ * reads "Approve Once". `onGrant` gets the chosen grant and whether the toggle was on.
96
+ */
97
+ grants?: readonly ApprovalDialogGrant[];
98
+ onGrant?(grant: ApprovalDialogGrant, always: boolean): void;
83
99
  }
84
100
 
85
101
  /** A prompt's detail rows: every line after the title, trailing blanks dropped. */
@@ -100,9 +116,11 @@ export class ApprovalDialogComponent implements Component {
100
116
  readonly #footer = new PanelRows();
101
117
  #scrollOffset = 0;
102
118
  #bodyRows = MAX_DETAIL_ROWS;
103
- /** In list order; the first (Approve) is highlighted on mount and Deny is last. */
104
- readonly #choices: readonly ApprovalChoice[];
119
+ /** In list order: the remember toggle and grants (when offered), Approve (highlighted on mount), the session row, Deny last. */
120
+ #choices: readonly ApprovalChoice[];
105
121
  #choice = 0;
122
+ /** The remember toggle: grants are kept across sessions while it is on. */
123
+ #rememberAlways = false;
106
124
  #closed = false;
107
125
  #native: NativeNode | undefined;
108
126
  /** Whether a draft held the keys when {@link #native} was described: it moves the highlight. */
@@ -122,11 +140,8 @@ export class ApprovalDialogComponent implements Component {
122
140
  this.#summary = promptDetails(lines);
123
141
  this.#full =
124
142
  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
- ];
143
+ this.#choices = this.#buildChoices();
144
+ this.#choice = this.#choices.findIndex(choice => choice.answer === "once");
130
145
  this.#panel = new OverlayPanel(title, "omp.overlay.approval");
131
146
  this.#footer.setHeight(1);
132
147
  this.#panel.addChild(this.#body);
@@ -149,6 +164,33 @@ export class ApprovalDialogComponent implements Component {
149
164
  }
150
165
  }
151
166
 
167
+ #buildChoices(): ApprovalChoice[] {
168
+ const grants = this.options.grants ?? [];
169
+ const glyph = this.#rememberAlways ? theme.checkbox.checked : theme.checkbox.unchecked;
170
+ return [
171
+ ...(grants.length > 0 ? [{ label: `${glyph} Remember across sessions`, answer: "remember" as const }] : []),
172
+ ...grants.map((grant, index) => ({
173
+ label: this.#rememberAlways ? grant.alwaysLabel : grant.label,
174
+ answer: `grant${index}` as const,
175
+ })),
176
+ { label: grants.length > 0 ? "Approve Once" : "Approve", answer: "once" },
177
+ ...(this.options.session ? [{ label: this.options.session.label, answer: "session" as const }] : []),
178
+ { label: "Deny", answer: "deny" },
179
+ ];
180
+ }
181
+
182
+ /** Run a choice: the remember toggle flips the grants' lifetime in place, every other choice answers. */
183
+ #take(answer: ApprovalAnswer): void {
184
+ if (answer !== "remember") {
185
+ this.#finish(answer);
186
+ return;
187
+ }
188
+ this.#rememberAlways = !this.#rememberAlways;
189
+ this.#choices = this.#buildChoices();
190
+ this.#native = undefined;
191
+ this.options.tui?.requestRender();
192
+ }
193
+
152
194
  invalidate(): void {
153
195
  this.#native = undefined;
154
196
  this.#panel.invalidate();
@@ -191,7 +233,7 @@ export class ApprovalDialogComponent implements Component {
191
233
  else if (matchesSelectPageUp(keyData)) this.#scroll(-page);
192
234
  else if (matchesSelectPageDown(keyData)) this.#scroll(page);
193
235
  else if (getKeybindings().matches(keyData, "tui.select.confirm")) {
194
- if (performance.now() >= this.#armedAt) this.#finish(this.#choices[this.#choice]!.answer);
236
+ if (performance.now() >= this.#armedAt) this.#take(this.#choices[this.#choice]!.answer);
195
237
  }
196
238
  // Enter never reaches an empty composer: it would submit nothing, or interrupt the run.
197
239
  else if (draft && !getKeybindings().matches(keyData, "tui.input.submit")) this.#editDraft(draft, keyData);
@@ -295,7 +337,7 @@ export class ApprovalDialogComponent implements Component {
295
337
  tone: "error",
296
338
  title: `Deny this call${cancelKey ? ` ${formatTooltipKey(cancelKey)}` : ""}`,
297
339
  });
298
- const approve = actionButton("Approve", "approve", {
340
+ const approve = actionButton(this.options.grants?.length ? "Approve Once" : "Approve", "approve", {
299
341
  ...(approveKey ? { keys: approveKey } : {}),
300
342
  tone: "accent",
301
343
  title: `Approve this call${approveKey ? ` ${formatTooltipKey(approveKey)}` : ""}`,
@@ -350,7 +392,7 @@ export class ApprovalDialogComponent implements Component {
350
392
  }
351
393
  if ((event.type !== "select" && event.type !== "activate") || leafKey(event.key) !== "choices") return;
352
394
  const choice = this.#choices.find(candidate => candidate.answer === event.item);
353
- if (choice) this.#finish(choice.answer);
395
+ if (choice) this.#take(choice.answer);
354
396
  }
355
397
 
356
398
  /** Detail rows at `width`: one clipped row per detail, or wrapped rows once expanded. */
@@ -384,6 +426,10 @@ export class ApprovalDialogComponent implements Component {
384
426
  this.#countdown?.dispose();
385
427
  if (answer === "once") this.callbacks.onApprove();
386
428
  else if (answer === "session") this.options.session?.onApprove();
387
- else this.callbacks.onDeny();
429
+ else if (answer.startsWith("grant")) {
430
+ const grant = this.options.grants?.[Number(answer.slice("grant".length))];
431
+ if (grant) this.options.onGrant?.(grant, this.#rememberAlways);
432
+ else this.callbacks.onDeny();
433
+ } else this.callbacks.onDeny();
388
434
  }
389
435
  }
@@ -17,6 +17,7 @@ import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "..
17
17
  import { actionBar, actionButton, selectList } from "../native/overlay";
18
18
  import { theme } from "../theme/theme";
19
19
  import { buildJobTree, type JobTreeAgent, type JobTreeRow, jobTreeGuide } from "./job-tree";
20
+ import { describeJobWidget, type JobWidgetView, renderJobWidgetLines } from "../tools/job-widget";
20
21
 
21
22
  /** One async job as the session snapshots it. */
22
23
  export interface JobsPanelJob {
@@ -54,6 +55,8 @@ export interface JobsPanelDetail {
54
55
  readonly output?: string;
55
56
  /** Artifact holding the full output when `output` is cut. */
56
57
  readonly artifactId?: string;
58
+ /** The job's live widget (frozen once it settles), drawn above the output. */
59
+ readonly widget?: JobWidgetView;
57
60
  }
58
61
 
59
62
  /** The session behind a {@link JobsSheet}. */
@@ -149,6 +152,7 @@ export class JobsSheet implements Component {
149
152
  detail?.pids.join(" "),
150
153
  detail?.exitCode,
151
154
  detail?.artifactId,
155
+ detail?.widget ? `${detail.widget.updatedAt}:${detail.widget.state}` : "",
152
156
  jobs.some(job => job.status === "running") ? Math.floor(now / 1000) : "",
153
157
  ].join("|");
154
158
  const output = detail?.output;
@@ -205,6 +209,11 @@ export class JobsSheet implements Component {
205
209
  detail.artifactId && `artifact://${detail.artifactId}`,
206
210
  ]);
207
211
  if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
212
+ if (detail.widget) {
213
+ for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
214
+ lines.push(` ${line}`);
215
+ }
216
+ }
208
217
  for (const line of this.#tailLines(selected.id, detail.output ?? "")) {
209
218
  if (line) lines.push(truncateToWidth(` ${line}`, width));
210
219
  }
@@ -413,6 +422,7 @@ function describeDetail(
413
422
  ? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "omp.jobs.command" })
414
423
  : text(job.label, { role: "omp.jobs.title", wrap: "word" }),
415
424
  facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
425
+ detail?.widget && node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
416
426
  node(
417
427
  "col",
418
428
  { role: "omp.jobs.out", gap: "sm" },
@@ -63,6 +63,7 @@ export type ModelRole =
63
63
  | "memory"
64
64
  | "task"
65
65
  | "advisor"
66
+ | "widget"
66
67
  | "image"
67
68
  | "web"
68
69
  | "speech"
@@ -79,6 +80,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
79
80
  "memory",
80
81
  "task",
81
82
  "advisor",
83
+ "widget",
82
84
  "image",
83
85
  "web",
84
86
  "speech",
@@ -96,6 +98,7 @@ export const CHAT_MODEL_ROLE_IDS: ModelRole[] = [
96
98
  "memory",
97
99
  "task",
98
100
  "advisor",
101
+ "widget",
99
102
  ];
100
103
  export const KIND_ROLE_IDS: ModelRole[] = ["image", "web", "speech", "dictation", "judge"];
101
104
 
@@ -93,7 +93,7 @@ export class AttachmentChipsBand implements Component {
93
93
  nativeImageNode(Buffer.from(image.data, "base64"), image.mimeType);
94
94
  images.set(image, native);
95
95
  content = node("image", {
96
- ...native.p,
96
+ ...(native.k === "image" ? native.p : undefined),
97
97
  alt: title,
98
98
  w: dims?.width,
99
99
  h: dims?.height,