@linxiraos/pi-tui 1.4.0 → 1.4.1

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 (105) hide show
  1. package/CHANGELOG.md +178 -182
  2. package/dist/types/autocomplete.d.ts +6 -0
  3. package/dist/types/components/sparkline.d.ts +34 -0
  4. package/dist/types/index.d.ts +1 -0
  5. package/dist/types/native/describe.d.ts +7 -1
  6. package/dist/types/native/overlay.d.ts +2 -0
  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/render/output-block.d.ts +2 -0
  13. package/dist/types/render/tool-card.d.ts +2 -0
  14. package/dist/types/sixel.d.ts +28 -0
  15. package/dist/types/status-line/host.d.ts +3 -0
  16. package/dist/types/terminal-capabilities.d.ts +5 -0
  17. package/dist/types/tools/bash.d.ts +2 -0
  18. package/dist/types/tools/default-renderer.d.ts +3 -0
  19. package/dist/types/tools/eval.d.ts +9 -8
  20. package/dist/types/tools/irc.d.ts +6 -0
  21. package/dist/types/tools/job-widget.d.ts +226 -0
  22. package/dist/types/tools/json-tree.d.ts +2 -0
  23. package/dist/types/tools/line-ranges.d.ts +8 -1
  24. package/dist/types/tools/mcp.d.ts +23 -0
  25. package/dist/types/tools/output-meta.d.ts +1 -1
  26. package/dist/types/tools/read.d.ts +8 -2
  27. package/dist/types/tools/renderer.d.ts +8 -2
  28. package/dist/types/tools/result-card.d.ts +1 -1
  29. package/dist/types/tools/streaming-output.d.ts +1 -0
  30. package/dist/types/tools/task.d.ts +12 -0
  31. package/dist/types/tools/wait.d.ts +5 -0
  32. package/package.json +9 -9
  33. package/src/autocomplete.d.ts +6 -0
  34. package/src/autocomplete.ts +21 -6
  35. package/src/chat/skill-message.ts +16 -17
  36. package/src/chat/tool-execution.ts +35 -0
  37. package/src/chrome/transcript-container.ts +85 -13
  38. package/src/components/editor.ts +17 -7
  39. package/src/components/image.ts +5 -4
  40. package/src/components/markdown.ts +11 -3
  41. package/src/components/sparkline.d.ts +34 -0
  42. package/src/components/sparkline.ts +89 -0
  43. package/src/index.d.ts +1 -0
  44. package/src/index.ts +1 -0
  45. package/src/native/backend.ts +32 -6
  46. package/src/native/describe.d.ts +7 -1
  47. package/src/native/describe.ts +18 -1
  48. package/src/native/overlay.d.ts +2 -0
  49. package/src/native/overlay.ts +3 -0
  50. package/src/native/tone.d.ts +2 -0
  51. package/src/native/tone.ts +22 -0
  52. package/src/overlays/agent-hub.ts +14 -10
  53. package/src/overlays/agent-transcript-viewer.ts +2 -2
  54. package/src/overlays/agents-hub.ts +2 -1
  55. package/src/overlays/approval-dialog.ts +59 -13
  56. package/src/overlays/hub-frame.ts +2 -1
  57. package/src/overlays/jobs-panel.ts +24 -2
  58. package/src/overlays/model-browser.ts +3 -0
  59. package/src/overlays/model-hub.ts +2 -1
  60. package/src/prompt/attachment-chips.ts +1 -1
  61. package/src/render/output-block.d.ts +2 -0
  62. package/src/render/output-block.ts +25 -12
  63. package/src/render/tool-card.d.ts +2 -0
  64. package/src/render/tool-card.ts +6 -0
  65. package/src/sixel.d.ts +28 -0
  66. package/src/sixel.ts +185 -0
  67. package/src/status-line/component.ts +19 -2
  68. package/src/status-line/host.ts +3 -0
  69. package/src/terminal-capabilities.d.ts +5 -0
  70. package/src/terminal-capabilities.ts +43 -3
  71. package/src/theme/symbols.ts +5 -3
  72. package/src/tools/bash.d.ts +2 -0
  73. package/src/tools/bash.ts +25 -9
  74. package/src/tools/default-renderer.d.ts +3 -0
  75. package/src/tools/default-renderer.ts +54 -17
  76. package/src/tools/eval.d.ts +9 -8
  77. package/src/tools/eval.ts +32 -14
  78. package/src/tools/find.ts +4 -5
  79. package/src/tools/glob.ts +4 -6
  80. package/src/tools/grep.ts +2 -4
  81. package/src/tools/irc.d.ts +6 -0
  82. package/src/tools/irc.ts +6 -0
  83. package/src/tools/job-widget.d.ts +226 -0
  84. package/src/tools/job-widget.ts +1036 -0
  85. package/src/tools/json-tree.d.ts +2 -0
  86. package/src/tools/json-tree.ts +8 -0
  87. package/src/tools/line-ranges.d.ts +8 -1
  88. package/src/tools/line-ranges.ts +23 -13
  89. package/src/tools/mcp.d.ts +23 -0
  90. package/src/tools/mcp.ts +44 -1
  91. package/src/tools/output-meta.d.ts +1 -1
  92. package/src/tools/output-meta.ts +1 -1
  93. package/src/tools/read.d.ts +8 -2
  94. package/src/tools/read.ts +64 -21
  95. package/src/tools/renderer.d.ts +8 -2
  96. package/src/tools/renderer.ts +8 -2
  97. package/src/tools/result-card.d.ts +1 -1
  98. package/src/tools/result-card.ts +1 -2
  99. package/src/tools/streaming-output.d.ts +1 -0
  100. package/src/tools/streaming-output.ts +11 -9
  101. package/src/tools/task.d.ts +12 -0
  102. package/src/tools/task.ts +25 -1
  103. package/src/tools/wait.d.ts +5 -0
  104. package/src/tools/wait.ts +290 -147
  105. package/src/tui.ts +45 -5
@@ -54,6 +54,7 @@ import { getNativeBlob, type NativeBlob } from "./blobs";
54
54
  import { node } from "./describe";
55
55
  import { encodeTspJson, encodeTspMessage, type TspHello, TspReader, splitTspMessage } from "./encode";
56
56
  import type { DescribeContext, NativeChild, NativeNode, NativeSurface, NativeUiEvent } from "./node";
57
+ import { NARROW_SHEET_COLS } from "./overlay";
57
58
  import { nativeComponentId, Reconciler } from "./reconcile";
58
59
  import { setNativeRendering } from "./state";
59
60
 
@@ -244,6 +245,10 @@ class Surface {
244
245
  acked = 0;
245
246
  /** Send times of unacknowledged frames, oldest first. */
246
247
  unacked: number[] = [];
248
+ /** Send time of the oldest frame left unanswered when the current stall began; cleared by the next ack. */
249
+ stalledSince: number | undefined;
250
+ /** Credit resets since {@link stalledSince}. */
251
+ stallResets = 0;
247
252
  focus: string | null = null;
248
253
  dirty = false;
249
254
 
@@ -456,6 +461,8 @@ export class NativeBackend {
456
461
  surface.reconciler.detachLive();
457
462
  surface.unacked = [];
458
463
  surface.acked = surface.seq;
464
+ surface.stalledSince = undefined;
465
+ surface.stallResets = 0;
459
466
  surface.focus = null;
460
467
  surface.dirty = false;
461
468
  this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: SESSION_ROLE, adopt: true });
@@ -612,11 +619,16 @@ export class NativeBackend {
612
619
  if (surface.unacked.length < this.#credits) return true;
613
620
  const oldest = surface.unacked[0]!;
614
621
  if (this.#scheduler.now() - oldest < STALLED_ACK_MS) return false;
615
- logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
616
- sf: surface.id,
617
- s: surface.seq,
618
- acked: surface.acked,
619
- });
622
+ // One warning per stall: a terminal that never acks would otherwise log one every `STALLED_ACK_MS`.
623
+ if (surface.stalledSince === undefined) {
624
+ surface.stalledSince = oldest;
625
+ logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
626
+ sf: surface.id,
627
+ s: surface.seq,
628
+ acked: surface.acked,
629
+ });
630
+ }
631
+ surface.stallResets++;
620
632
  surface.unacked = [];
621
633
  surface.acked = surface.seq;
622
634
  return true;
@@ -801,6 +813,15 @@ export class NativeBackend {
801
813
  const newly = Math.min(event.s, surface.seq) - surface.acked;
802
814
  surface.acked = Math.min(event.s, surface.seq);
803
815
  surface.unacked.splice(0, newly);
816
+ if (surface.stalledSince !== undefined) {
817
+ logger.debug("TSP: terminal acknowledging frames again", {
818
+ sf: surface.id,
819
+ stalledMs: this.#scheduler.now() - surface.stalledSince,
820
+ resets: surface.stallResets,
821
+ });
822
+ surface.stalledSince = undefined;
823
+ surface.stallResets = 0;
824
+ }
804
825
  this.#clearStallTimer();
805
826
  if (surface.dirty) this.#host.requestRender();
806
827
  return;
@@ -919,7 +940,12 @@ export class NativeBackend {
919
940
  if (overlay.component.nativeSheet?.(this.#cx)) return overlay.component;
920
941
  const own = overlay.component.nativeOverlay;
921
942
  const anchor = own?.anchor ?? overlayAnchor(overlay.options);
922
- const size = own?.size ?? overlaySize(overlay.options, this.#cx.cols);
943
+ let size = own?.size ?? overlaySize(overlay.options, this.#cx.cols);
944
+ // A centered sheet in a narrow pane fills it instead of floating with margins: `lg`, and a component's own `md`
945
+ // sheet (an undeclared `md` is a generic dialog sized from its options).
946
+ if (anchor === "center" && this.#cx.cols < NARROW_SHEET_COLS && (size === "lg" || own?.size === "md")) {
947
+ size = "full";
948
+ }
923
949
  const modal = overlay.focused || overlay.options?.fullscreen === true;
924
950
  const role = own?.role;
925
951
  const head = own?.head;
@@ -9,7 +9,7 @@
9
9
  * ]);
10
10
  * }
11
11
  */
12
- import type { TspKind, TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
12
+ import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
14
  /** A node of any kind. */
15
15
  export declare function node<K extends TspKind>(k: K, p?: TspProps<K>, c?: readonly NativeChild[], key?: string): NativeNode;
@@ -37,6 +37,12 @@ export declare const EMPTY_NODE: NativeNode;
37
37
  export declare function keyed(described: NativeNode, key: string): NativeNode;
38
38
  /** `described` mounted but not laid out when `hidden` (cheap visibility toggles keep ids and view state). */
39
39
  export declare function withHidden(described: NativeNode, hidden: boolean): NativeNode;
40
+ /**
41
+ * A described tree as the node tree a prop carries (a `hovercard`): plain
42
+ * nodes only, since nothing reconciles inside a prop; component children
43
+ * (which describe themselves per frame) are left out.
44
+ */
45
+ export declare function propNode(described: NativeNode): TspPropNode;
40
46
  /** Stable node key for an arbitrary identity string; keys join into `/` keypaths, so identities are hashed. */
41
47
  export declare function stableKey(identity: string): string;
42
48
  /** Drop absent entries (`undefined`, `null`, `false`) from a child list. */
@@ -9,7 +9,7 @@
9
9
  * ]);
10
10
  * }
11
11
  */
12
- import type { TspKind, TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
12
+ import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@linxiraos/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);
@@ -10,6 +10,8 @@ import type { TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
10
10
  import { type KeyName } from "../key-hint-format";
11
11
  import { type Keybinding } from "../keybindings";
12
12
  import type { NativeChild, NativeNode } from "./node";
13
+ /** Below this many columns a centered sheet fills the pane instead of floating with margins. */
14
+ export declare const NARROW_SHEET_COLS = 100;
13
15
  /** One footer hint: the keys that trigger it and what it does. */
14
16
  export interface NativeHint {
15
17
  /** Key ids (`"ctrl+o"`, `"escape"`); alternatives render side by side. */
@@ -12,6 +12,9 @@ import { getKeybindings, type Keybinding } from "../keybindings";
12
12
  import { card, kbd, list, node, row, span, text } from "./describe";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
14
 
15
+ /** Below this many columns a centered sheet fills the pane instead of floating with margins. */
16
+ export const NARROW_SHEET_COLS = 100;
17
+
15
18
  /** One footer hint: the keys that trigger it and what it does. */
16
19
  export interface NativeHint {
17
20
  /** Key ids (`"ctrl+o"`, `"escape"`); alternatives render side by side. */
@@ -17,6 +17,8 @@ export interface BackgroundChrome {
17
17
  }
18
18
  /** Map an omp background token to card tone/role. */
19
19
  export declare function backgroundChrome(bg: ThemeBg | undefined): BackgroundChrome;
20
+ /** The omp theme colour an ANSI renderer draws a TSP tone with (the inverse of {@link colorTone}). */
21
+ export declare function toneColor(tone: TspTone): ThemeColor;
20
22
  /**
21
23
  * Tone of an omp colour used as a severity or border colour; undefined for
22
24
  * colours that carry no tone (callers pick their own fallback).
@@ -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
 
@@ -1583,7 +1583,8 @@ export class AgentsHubComponent implements Component {
1583
1583
  } else {
1584
1584
  children.push(...this.#describeList());
1585
1585
  }
1586
- return node("col", { gap: "sm", grow: 1 }, children, "body");
1586
+ // Zero basis: the body takes the space left of the sidebar instead of sizing to its widest line.
1587
+ return node("col", { gap: "sm", grow: 1, basis: 0, min: { w: 0 } }, children, "body");
1587
1588
  }
1588
1589
 
1589
1590
  #statusSpans(): TspSpan[] {
@@ -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, "zeta.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
  }
@@ -96,7 +96,8 @@ export function describeHubSidebar<TEntry extends SidebarEntry<string>>(
96
96
  ),
97
97
  );
98
98
  }
99
- return node("list", { selected: selectedId }, items, key);
99
+ // The sidebar keeps its width: a body line wider than the pane must not squeeze it.
100
+ return node("list", { selected: selectedId, shrink: 0 }, items, key);
100
101
  }
101
102
 
102
103
  /**
@@ -6,17 +6,19 @@
6
6
  * (cwd, live pids, exit code, artifact) and live output.
7
7
  */
8
8
  import type { TspSpan, TspTone } from "@linxiraos/pi-wire";
9
- import { formatDuration } from "@linxiraos/pi-utils";
9
+ import { formatDuration, sanitizeText } from "@linxiraos/pi-utils";
10
10
  import type { Component } from "../tui";
11
11
  import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
12
12
  import { matchesKey } from "../keys";
13
- import { truncateToWidth } from "../utils";
13
+ import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../utils";
14
14
  import { collapseCommand } from "../apps/ps-data";
15
+ import { PREVIEW_LIMITS, shortenEmbeddedPaths } from "../render/render-utils";
15
16
  import { ansi, code, col, compact, item, node, row, span, stableKey, text } from "../native/describe";
16
17
  import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
17
18
  import { actionBar, actionButton, selectList } from "../native/overlay";
18
19
  import { theme } from "../theme/theme";
19
20
  import { buildJobTree, type JobTreeAgent, type JobTreeRow, jobTreeGuide } from "./job-tree";
21
+ import { describeJobWidget, type JobWidgetView, renderJobWidgetLines } from "../tools/job-widget";
20
22
 
21
23
  /** One async job as the session snapshots it. */
22
24
  export interface JobsPanelJob {
@@ -54,6 +56,8 @@ export interface JobsPanelDetail {
54
56
  readonly output?: string;
55
57
  /** Artifact holding the full output when `output` is cut. */
56
58
  readonly artifactId?: string;
59
+ /** The job's live widget (frozen once it settles), drawn above the output. */
60
+ readonly widget?: JobWidgetView;
57
61
  }
58
62
 
59
63
  /** The session behind a {@link JobsSheet}. */
@@ -149,6 +153,7 @@ export class JobsSheet implements Component {
149
153
  detail?.pids.join(" "),
150
154
  detail?.exitCode,
151
155
  detail?.artifactId,
156
+ detail?.widget ? `${detail.widget.updatedAt}:${detail.widget.state}` : "",
152
157
  jobs.some(job => job.status === "running") ? Math.floor(now / 1000) : "",
153
158
  ].join("|");
154
159
  const output = detail?.output;
@@ -198,6 +203,17 @@ export class JobsSheet implements Component {
198
203
  }
199
204
  const detail = selected && this.#source.inspect(selected.id);
200
205
  if (detail) {
206
+ // Rows show the cut label; the selected job's command wraps here,
207
+ // bounded so a long script never pushes the facts and output off the sheet.
208
+ if (detail.command) {
209
+ const commandRows = wrapTextWithAnsi(
210
+ shortenEmbeddedPaths(replaceTabs(sanitizeText(detail.command))),
211
+ Math.max(1, width - 3),
212
+ );
213
+ for (const line of commandRows.slice(0, PREVIEW_LIMITS.EXPANDED_LINES)) lines.push(` ${line}`);
214
+ const hidden = commandRows.length - PREVIEW_LIMITS.EXPANDED_LINES;
215
+ if (hidden > 0) lines.push(truncateToWidth(` … ${hidden} more command lines`, width));
216
+ }
201
217
  const facts = compact([
202
218
  detail.cwd && `cwd ${detail.cwd}`,
203
219
  detail.pids.length > 0 && `pid ${detail.pids.join(", ")}`,
@@ -205,6 +221,11 @@ export class JobsSheet implements Component {
205
221
  detail.artifactId && `artifact://${detail.artifactId}`,
206
222
  ]);
207
223
  if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
224
+ if (detail.widget) {
225
+ for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
226
+ lines.push(` ${line}`);
227
+ }
228
+ }
208
229
  for (const line of this.#tailLines(selected.id, detail.output ?? "")) {
209
230
  if (line) lines.push(truncateToWidth(` ${line}`, width));
210
231
  }
@@ -413,6 +434,7 @@ function describeDetail(
413
434
  ? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "zeta.jobs.command" })
414
435
  : text(job.label, { role: "zeta.jobs.title", wrap: "word" }),
415
436
  facts.length > 0 && node("kv", { items: facts, role: "zeta.jobs.facts" }),
437
+ detail?.widget && node("col", { role: "zeta.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
416
438
  node(
417
439
  "col",
418
440
  { role: "zeta.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
 
@@ -3046,7 +3046,8 @@ export class ModelHubComponent implements Component {
3046
3046
  this.#browser,
3047
3047
  );
3048
3048
  }
3049
- return node("col", { gap: "sm", grow: 1 }, children, "body");
3049
+ // Zero basis: the body takes the space left of the sidebar instead of sizing to its widest line.
3050
+ return node("col", { gap: "sm", grow: 1, basis: 0, min: { w: 0 } }, children, "body");
3050
3051
  }
3051
3052
 
3052
3053
  #describeStatus(): NativeNode {
@@ -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,
@@ -10,6 +10,8 @@ import type { State } from "./types";
10
10
  export interface OutputBlockOptions {
11
11
  header?: string;
12
12
  headerMeta?: string;
13
+ /** Facts drawn inside the bottom border (`╰── Wall 0.16s · Timeout 300s ──╯`). */
14
+ footerMeta?: string;
13
15
  state?: State;
14
16
  sections?: Array<{
15
17
  label?: string;
@@ -17,6 +17,8 @@ import { getStateBgColor, Hasher, padToWidth } from "./utils";
17
17
  export interface OutputBlockOptions {
18
18
  header?: string;
19
19
  headerMeta?: string;
20
+ /** Facts drawn inside the bottom border (`╰── Wall 0.16s · Timeout 300s ──╯`). */
21
+ footerMeta?: string;
20
22
  state?: State;
21
23
  sections?: Array<{ label?: string; lines: readonly string[]; separator?: boolean }>;
22
24
  width: number;
@@ -46,7 +48,7 @@ export function isFramedBlockComponent(component: Component): boolean {
46
48
 
47
49
  type BlockRow =
48
50
  | { kind: "bar"; leftChar: string; rightChar: string; label?: string; meta?: string }
49
- | { kind: "bottom"; leftChar: string; rightChar: string }
51
+ | { kind: "bottom"; leftChar: string; rightChar: string; meta?: string }
50
52
  | { kind: "content"; inner: string }
51
53
  | { kind: "sixel"; raw: string };
52
54
 
@@ -157,7 +159,26 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
157
159
  }
158
160
  }
159
161
 
160
- rows.push({ kind: "bottom", leftChar: theme.boxRound.bottomLeft, rightChar: theme.boxRound.bottomRight });
162
+ // Footer facts ride the bottom border only when they fit whole; a clipped
163
+ // border would silently drop trailing facts (exit code, artifact id), so an
164
+ // overflowing footer wraps into body rows above a plain border instead.
165
+ const footerMeta = options.footerMeta;
166
+ const footerFits =
167
+ footerMeta === undefined ||
168
+ visibleWidth(` ${footerMeta} `) <=
169
+ lineWidth - visibleWidth(`${theme.boxRound.bottomLeft}${cap}`) - visibleWidth(theme.boxRound.bottomRight);
170
+ if (footerMeta !== undefined && !footerFits) {
171
+ for (const wrappedLine of wrapTextWithAnsi(footerMeta, contentWidth)) {
172
+ const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
173
+ rows.push({ kind: "content", inner: `${wrappedLine}${innerPadding}` });
174
+ }
175
+ }
176
+ rows.push({
177
+ kind: "bottom",
178
+ leftChar: theme.boxRound.bottomLeft,
179
+ rightChar: theme.boxRound.bottomRight,
180
+ meta: footerFits ? footerMeta : undefined,
181
+ });
161
182
 
162
183
  const H = rows.length;
163
184
 
@@ -182,14 +203,6 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
182
203
  return `${border(leftGlyphs)}${trimmedLabel}${border(fillGlyphs)}${border(rightGlyph)}`;
183
204
  };
184
205
 
185
- const renderBottom = (row: { leftChar: string; rightChar: string }): string => {
186
- const leftGlyphs = `${row.leftChar}${cap}`;
187
- const rightGlyph = row.rightChar;
188
- const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
189
- const fillGlyphs = h.repeat(fillCount);
190
- return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
191
- };
192
-
193
206
  const renderContent = (inner: string): string =>
194
207
  `${border(v)}${contentLeftPadding}${inner}${contentRightPadding}${border(v)}`;
195
208
 
@@ -201,8 +214,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
201
214
  lines.push(row.raw);
202
215
  continue;
203
216
  }
204
- const line =
205
- row.kind === "bar" ? renderBar(row) : row.kind === "bottom" ? renderBottom(row) : renderContent(row.inner);
217
+ const line = row.kind === "bar" || row.kind === "bottom" ? renderBar(row) : renderContent(row.inner);
206
218
  lines.push(padToWidth(clipFrame ? truncateToWidth(line, lineWidth, Ellipsis.Omit) : line, lineWidth, bgFn));
207
219
  }
208
220
 
@@ -360,6 +372,7 @@ export class CachedOutputBlock {
360
372
  );
361
373
  h.optional(options.header);
362
374
  h.optional(options.headerMeta);
375
+ h.optional(options.footerMeta);
363
376
  h.optional(options.state);
364
377
  h.optional(options.borderColor);
365
378
  h.bool(options.applyBg ?? true);
@@ -22,6 +22,8 @@ export interface ToolCardSnapshot {
22
22
  /** Structured status header, rendered by the shared status-line primitive. */
23
23
  status?: StatusLineOptions;
24
24
  headerMeta?: string;
25
+ /** Short facts drawn inside the frame's bottom border (framed); a trailing row otherwise. */
26
+ footerMeta?: string;
25
27
  phase?: ToolCardPhase;
26
28
  body?: ToolCardContent;
27
29
  sections?: readonly ToolCardSection[];