@oh-my-pi/pi-tui 18.12.2 → 18.12.4

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 (76) hide show
  1. package/CHANGELOG.md +44 -48
  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/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.ts +12 -1
  34. package/src/chat/skill-message.ts +19 -21
  35. package/src/chat/tool-execution.ts +35 -0
  36. package/src/chrome/transcript-container.ts +85 -12
  37. package/src/components/editor.ts +17 -7
  38. package/src/components/image.ts +5 -4
  39. package/src/components/markdown.ts +11 -3
  40. package/src/components/sparkline.ts +89 -0
  41. package/src/index.ts +1 -0
  42. package/src/native/backend.ts +25 -5
  43. package/src/native/describe.ts +18 -1
  44. package/src/native/tone.ts +22 -0
  45. package/src/overlays/agent-hub.ts +14 -10
  46. package/src/overlays/agent-transcript-viewer.ts +2 -2
  47. package/src/overlays/approval-dialog.ts +59 -13
  48. package/src/overlays/jobs-panel.ts +24 -2
  49. package/src/overlays/model-browser.ts +3 -0
  50. package/src/prompt/attachment-chips.ts +1 -1
  51. package/src/render/output-block.ts +25 -12
  52. package/src/render/tool-card.ts +6 -0
  53. package/src/sixel.ts +185 -0
  54. package/src/status-line/component.ts +19 -2
  55. package/src/status-line/host.ts +3 -0
  56. package/src/terminal-capabilities.ts +43 -3
  57. package/src/theme/symbols.ts +5 -3
  58. package/src/tools/bash.ts +25 -9
  59. package/src/tools/default-renderer.ts +54 -17
  60. package/src/tools/eval.ts +32 -14
  61. package/src/tools/find.ts +4 -5
  62. package/src/tools/glob.ts +4 -6
  63. package/src/tools/grep.ts +4 -6
  64. package/src/tools/irc.ts +6 -0
  65. package/src/tools/job-widget.ts +1036 -0
  66. package/src/tools/json-tree.ts +8 -0
  67. package/src/tools/line-ranges.ts +23 -13
  68. package/src/tools/mcp.ts +44 -1
  69. package/src/tools/output-meta.ts +1 -1
  70. package/src/tools/read.ts +64 -21
  71. package/src/tools/renderer.ts +8 -2
  72. package/src/tools/result-card.ts +1 -2
  73. package/src/tools/streaming-output.ts +11 -9
  74. package/src/tools/task.ts +25 -1
  75. package/src/tools/wait.ts +288 -147
  76. package/src/tui.ts +45 -5
@@ -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
  }
@@ -6,17 +6,19 @@
6
6
  * (cwd, live pids, exit code, artifact) and live output.
7
7
  */
8
8
  import type { TspSpan, TspTone } from "@oh-my-pi/pi-wire";
9
- import { formatDuration } from "@oh-my-pi/pi-utils";
9
+ import { formatDuration, sanitizeText } from "@oh-my-pi/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: "omp.jobs.command" })
414
435
  : text(job.label, { role: "omp.jobs.title", wrap: "word" }),
415
436
  facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
437
+ detail?.widget && node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
416
438
  node(
417
439
  "col",
418
440
  { 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,
@@ -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);
@@ -43,6 +43,8 @@ export interface ToolCardSnapshot {
43
43
  /** Structured status header, rendered by the shared status-line primitive. */
44
44
  status?: StatusLineOptions;
45
45
  headerMeta?: string;
46
+ /** Short facts drawn inside the frame's bottom border (framed); a trailing row otherwise. */
47
+ footerMeta?: string;
46
48
  phase?: ToolCardPhase;
47
49
  body?: ToolCardContent;
48
50
  sections?: readonly ToolCardSection[];
@@ -219,6 +221,7 @@ export class ToolCard implements Component {
219
221
  previousOptions?.width === width &&
220
222
  previousOptions.header === header &&
221
223
  previousOptions.headerMeta === snapshot.headerMeta &&
224
+ previousOptions.footerMeta === snapshot.footerMeta &&
222
225
  previousOptions.state === state &&
223
226
  previousOptions.applyBg === snapshot.applyBg &&
224
227
  previousOptions.contentPaddingLeft === snapshot.contentPaddingLeft &&
@@ -230,6 +233,7 @@ export class ToolCard implements Component {
230
233
  const blockOptions: OutputBlockOptions = reusableOptions ?? {
231
234
  header,
232
235
  headerMeta: snapshot.headerMeta,
236
+ footerMeta: snapshot.footerMeta,
233
237
  state,
234
238
  sections,
235
239
  width,
@@ -251,6 +255,7 @@ export class ToolCard implements Component {
251
255
  if (section.label) lines.push(section.label);
252
256
  lines.push(...section.lines);
253
257
  }
258
+ if (snapshot.footerMeta) lines.push(snapshot.footerMeta);
254
259
  const text = lines.join("\n");
255
260
  const key = `${width}:${text.length}:${Bun.hash(text).toString(36)}:${state ?? "info"}:${snapshot.applyBg ?? true}`;
256
261
  if (key !== this.#plainKey) {
@@ -279,6 +284,7 @@ export class ToolCard implements Component {
279
284
  }
280
285
  if (snapshot.footer)
281
286
  slotInputs.push({ label: undefined, separator: slotInputs.length > 0, content: snapshot.footer });
287
+ if (snapshot.footerMeta) slotInputs.push({ label: undefined, separator: false, content: [snapshot.footerMeta] });
282
288
 
283
289
  const nextChildren: Component[] = [];
284
290
  const slots = slotInputs.map((input, index) => {
package/src/sixel.ts ADDED
@@ -0,0 +1,185 @@
1
+ /**
2
+ * Give an empty first band an explicit transparent width. Zellij derives the
3
+ * decoded image width from its first pixel row, ignoring the raster minimum
4
+ * for transparent images; an omitted band otherwise makes the image invisible.
5
+ * Palette definitions stay before drawing so cropping can still parse them.
6
+ */
7
+ export function ensureSixelRasterWidth(sequence: string): string {
8
+ const prefix = /^(\x1bP[0-9;]*q"\d+;\d+;(\d+);\d+(?:#\d+(?:;[12];\d+;\d+;\d+)?|\$)*)(?=-)/.exec(sequence);
9
+ if (!prefix) return sequence;
10
+ return `${prefix[1]}!${prefix[2]}?$${sequence.slice(prefix[1]!.length)}`;
11
+ }
12
+
13
+ interface SixelRun {
14
+ bits: number;
15
+ count: number;
16
+ }
17
+
18
+ interface SixelBand {
19
+ /** The selected register is inherited across graphics newlines. */
20
+ color: number;
21
+ tokens: (SixelRun | string)[];
22
+ }
23
+
24
+ function invalidSixel(reason: string): never {
25
+ throw new Error(`Cannot crop SIXEL: ${reason}`);
26
+ }
27
+
28
+ function sixelInteger(value: string): number {
29
+ const number = Number(value);
30
+ if (!Number.isSafeInteger(number)) invalidSixel("integer exceeds the safe range");
31
+ return number;
32
+ }
33
+
34
+ function appendRun(output: string[], bits: number, count: number): void {
35
+ const character = String.fromCharCode(63 + bits);
36
+ // Keep repeats compressed, including long transparent gaps.
37
+ output.push(count > 3 ? `!${count}${character}` : character.repeat(count));
38
+ }
39
+
40
+ function appendBand(output: string[], band: SixelBand | undefined, shift: number, mask: number): void {
41
+ if (!band) return;
42
+ output.push(`#${band.color}`);
43
+ for (const token of band.tokens) {
44
+ if (typeof token === "string") {
45
+ output.push(token);
46
+ } else {
47
+ const bits = shift >= 0 ? token.bits >>> shift : token.bits << -shift;
48
+ appendRun(output, bits & mask, token.count);
49
+ }
50
+ }
51
+ }
52
+
53
+ /**
54
+ * Crop integer pixel rows from a raw (not tmux-wrapped) SIXEL DCS image.
55
+ * Runs are shifted between adjacent six-pixel bands, never decoded to pixels.
56
+ * Palette selections, overdraw order, transparent gaps and DCS background mode
57
+ * are retained. A nonpositive crop returns the original sequence unchanged.
58
+ *
59
+ * The supported grammar is the native icy_sixel encoder's: one leading raster
60
+ * declaration with positive dimensions and square pixels, prefix RGB/HLS palette
61
+ * definitions, register selections, sixel characters, repeats, `$` and `-`.
62
+ * ESC DCS/ST framing only, as the TUI emits it. Palette definitions after
63
+ * drawing, additional raster declarations, other controls and malformed input
64
+ * throw; forwarding an unsupported image uncropped would reintroduce raster
65
+ * overflow.
66
+ *
67
+ * Without maxHeightPixels the crop is exact, including a partial final band with
68
+ * zero padding bits and the exact declared raster height. Some terminals still
69
+ * allocate all six rows of that final band. The TUI supplies its available pixel
70
+ * height to cap the result at a whole-band boundary, matching the native encoder's
71
+ * policy of dropping bottom pixels rather than overwriting the following row.
72
+ */
73
+ export function cropSixelTop(sequence: string, pixels: number, maxHeightPixels?: number): string {
74
+ if (pixels <= 0) return sequence;
75
+ if (!Number.isSafeInteger(pixels)) throw new RangeError("SIXEL crop must be an integer pixel count");
76
+ if (maxHeightPixels !== undefined && (!Number.isFinite(maxHeightPixels) || maxHeightPixels < 0)) {
77
+ throw new RangeError("SIXEL height limit must be a nonnegative finite pixel count");
78
+ }
79
+
80
+ const header = /^\x1bP[0-9;]*q/.exec(sequence);
81
+ if (!header) invalidSixel("missing DCS introducer");
82
+ if (!sequence.endsWith("\x1b\\")) invalidSixel("missing string terminator");
83
+ const end = sequence.length - 2;
84
+ const tokenPattern = /[?-~]|!\d+[?-~]|#\d+(?:;\d+)*|"\d+(?:;\d+)*|[-$]/y;
85
+ const palette: string[] = [];
86
+ const bands: SixelBand[] = [];
87
+ let band: SixelBand = { color: 0, tokens: [] };
88
+ let color = 0;
89
+ let drawing = false;
90
+ let raster: string[] | undefined;
91
+ let width = 0;
92
+ let height = 0;
93
+ let x = 0;
94
+ let y = 0;
95
+ let dataWidth = 0;
96
+ let paintedHeight = 0;
97
+ let cursor = header[0].length;
98
+
99
+ while (cursor < end) {
100
+ tokenPattern.lastIndex = cursor;
101
+ const match = tokenPattern.exec(sequence);
102
+ if (!match || tokenPattern.lastIndex > end) invalidSixel(`unsupported token at offset ${cursor}`);
103
+ const token = match[0];
104
+ cursor = tokenPattern.lastIndex;
105
+ switch (token[0]) {
106
+ case '"': {
107
+ if (raster || drawing) invalidSixel("raster attributes must occur once before drawing");
108
+ raster = token.slice(1).split(";");
109
+ if (raster.length !== 4) invalidSixel("expected four raster attributes");
110
+ const pan = sixelInteger(raster[0]!);
111
+ const pad = sixelInteger(raster[1]!);
112
+ width = sixelInteger(raster[2]!);
113
+ height = sixelInteger(raster[3]!);
114
+ if (pan === 0 || pan !== pad) invalidSixel("only square pixels are supported");
115
+ if (width === 0 || height === 0) invalidSixel("raster dimensions must be positive");
116
+ break;
117
+ }
118
+ case "#": {
119
+ const parameters = token.slice(1).split(";");
120
+ color = sixelInteger(parameters[0]!);
121
+ if (parameters.length > 1) {
122
+ if (drawing) invalidSixel("palette definitions must precede drawing");
123
+ if (parameters.length !== 5 || (parameters[1] !== "1" && parameters[1] !== "2")) {
124
+ invalidSixel("expected an RGB or HLS palette definition");
125
+ }
126
+ for (let index = 2; index < parameters.length; index++) sixelInteger(parameters[index]!);
127
+ palette.push(token);
128
+ }
129
+ band.tokens.push(`#${color}`);
130
+ break;
131
+ }
132
+ case "$":
133
+ drawing = true;
134
+ x = 0;
135
+ band.tokens.push(token);
136
+ break;
137
+ case "-":
138
+ drawing = true;
139
+ bands.push(band);
140
+ band = { color, tokens: [] };
141
+ x = 0;
142
+ y += 6;
143
+ break;
144
+ default: {
145
+ drawing = true;
146
+ const count = token[0] === "!" ? sixelInteger(token.slice(1, -1)) : 1;
147
+ if (count === 0) invalidSixel("repeat count must be positive");
148
+ const bits = token.charCodeAt(token.length - 1) - 63;
149
+ x += count;
150
+ if (!Number.isSafeInteger(x)) invalidSixel("run width exceeds the safe range");
151
+ dataWidth = Math.max(dataWidth, x);
152
+ if (bits !== 0) paintedHeight = Math.max(paintedHeight, y + 32 - Math.clz32(bits));
153
+ band.tokens.push({ bits, count });
154
+ break;
155
+ }
156
+ }
157
+ }
158
+ bands.push(band);
159
+ if (!raster) invalidSixel("missing raster dimensions");
160
+ // Raster attributes specify a minimum, not a clip rectangle. Account for
161
+ // drawing beyond them rather than silently discarding those source pixels.
162
+ width = Math.max(width, dataWidth);
163
+ height = Math.max(height, paintedHeight);
164
+ let croppedHeight = Math.max(0, height - pixels);
165
+ if (maxHeightPixels !== undefined) {
166
+ croppedHeight = Math.min(croppedHeight, Math.floor(maxHeightPixels / 6) * 6);
167
+ }
168
+ if (croppedHeight === 0) return "";
169
+
170
+ const output = [header[0], `"${raster[0]};${raster[1]};${width};${croppedHeight}`, ...palette];
171
+ const firstBand = Math.floor(pixels / 6);
172
+ const shift = pixels % 6;
173
+ const bandCount = Math.ceil(croppedHeight / 6);
174
+ for (let index = 0; index < bandCount; index++) {
175
+ if (index > 0) output.push("-");
176
+ const mask = (1 << Math.min(6, croppedHeight - index * 6)) - 1;
177
+ appendBand(output, bands[firstBand + index], shift, mask);
178
+ if (shift !== 0) {
179
+ output.push("$");
180
+ appendBand(output, bands[firstBand + index + 1], shift - 6, mask);
181
+ }
182
+ }
183
+ output.push("\x1b\\");
184
+ return ensureSixelRasterWidth(output.join(""));
185
+ }
@@ -45,6 +45,7 @@ import { col, node, span } from "../native/describe";
45
45
  import { getContextMeterThresholds } from "../chrome/context-thresholds";
46
46
  import { isNativeRendering, onNativeRenderingChange } from "../native/state";
47
47
  import { getSeparator } from "./separators";
48
+ import { formatJobWidgetGlance, type JobWidgetEntry, latestJobWidget } from "../tools/job-widget";
48
49
  import type {
49
50
  CollabStatus,
50
51
  ComposerFacts,
@@ -59,6 +60,9 @@ import type {
59
60
  StatusResetExpiry,
60
61
  } from "./types";
61
62
 
63
+ /** Sparkline cells the ANSI jobs segment draws after the freshest widget's headline. */
64
+ const JOBS_SEGMENT_SPARK_CELLS = 8;
65
+
62
66
  /** What a click on a native status segment asks omp to open. */
63
67
  export type StatusLineNativeAction = "status.model" | "status.context" | "status.git" | "status.cost" | "status.path";
64
68
 
@@ -446,6 +450,8 @@ class StatusLineExternalInputs {
446
450
  isFastModeActive = false;
447
451
  anthropicSlowModeLabel: string | undefined = undefined;
448
452
  compactionSpeculation: unknown = undefined;
453
+ /** The freshest running job widget's view (stable identity while its data is unchanged). */
454
+ jobWidgetRef: unknown = undefined;
449
455
  }
450
456
 
451
457
  interface CachedStatusLine {
@@ -2796,6 +2802,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2796
2802
  ? this.session.getAnthropicSlowModeLabel()
2797
2803
  : undefined;
2798
2804
  target.compactionSpeculation = this.session.compactionSpeculation;
2805
+ target.jobWidgetRef = this.#latestJobWidget()?.view;
2806
+ }
2807
+
2808
+ /** The running job widget that saw data last; the ANSI jobs segment appends its glance. */
2809
+ #latestJobWidget(): JobWidgetEntry | undefined {
2810
+ const entries = this.session.getRunningJobWidgets?.();
2811
+ return entries ? latestJobWidget(entries) : undefined;
2799
2812
  }
2800
2813
 
2801
2814
  #sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
@@ -2849,7 +2862,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2849
2862
  left.isAutoThinking === right.isAutoThinking &&
2850
2863
  left.isFastModeActive === right.isFastModeActive &&
2851
2864
  left.anthropicSlowModeLabel === right.anthropicSlowModeLabel &&
2852
- left.compactionSpeculation === right.compactionSpeculation
2865
+ left.compactionSpeculation === right.compactionSpeculation &&
2866
+ left.jobWidgetRef === right.jobWidgetRef
2853
2867
  );
2854
2868
  }
2855
2869
 
@@ -3058,7 +3072,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
3058
3072
  if (layout !== "plain-left") {
3059
3073
  const runningBackgroundJobs = this.runningBackgroundJobCount();
3060
3074
  if (runningBackgroundJobs > 0) {
3061
- rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
3075
+ const widget = this.#latestJobWidget();
3076
+ const glance = widget ? formatJobWidgetGlance(widget.view, theme, JOBS_SEGMENT_SPARK_CELLS) : "";
3077
+ const count = theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`);
3078
+ rightParts.unshift(glance ? `${count} ${glance}` : count);
3062
3079
  }
3063
3080
  if (subagentBadge) rightParts.unshift(subagentBadge);
3064
3081
  }
@@ -1,6 +1,7 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
2
2
  import type { Tool, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
3
3
  import type { Model } from "@oh-my-pi/pi-catalog/types";
4
+ import type { JobWidgetEntry } from "../tools/job-widget";
4
5
  import type { CompactionBoundaries } from "./context-usage";
5
6
  import type { StatusLineSettings, StatusResetExpiry } from "./types";
6
7
 
@@ -59,6 +60,8 @@ export interface StatusLineSession {
59
60
  getAnthropicSlowModeLabel?(): string | undefined;
60
61
  getPrewalkState?(): unknown;
61
62
  getAsyncJobSnapshot(): { running: readonly { type: string; agentId?: string }[] } | null | undefined;
63
+ /** Widgets of the running background jobs, in job order; the jobs segment appends the freshest one's glance. */
64
+ getRunningJobWidgets?(): readonly JobWidgetEntry[];
62
65
  getGoalModeState(): { goal?: { status: string; tokensUsed: number; tokenBudget?: number } } | undefined;
63
66
  getAdvisorStatusOverview?(): { configured: boolean; advisors: readonly { status: string; yielded: boolean }[] };
64
67
  getAdvisorCost?(): number;