@linxiraos/pi-tui 1.3.2 → 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 (116) hide show
  1. package/CHANGELOG.md +181 -181
  2. package/dist/types/apps/session-picker.d.ts +13 -1
  3. package/dist/types/autocomplete.d.ts +6 -0
  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/overlay.d.ts +2 -0
  8. package/dist/types/native/tone.d.ts +2 -0
  9. package/dist/types/overlays/agent-hub.d.ts +3 -3
  10. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  11. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  12. package/dist/types/overlays/model-browser.d.ts +1 -1
  13. package/dist/types/overlays/resume-picker.d.ts +293 -0
  14. package/dist/types/overlays/resume-tree.d.ts +353 -0
  15. package/dist/types/overlays/session-selector.d.ts +22 -4
  16. package/dist/types/render/output-block.d.ts +2 -0
  17. package/dist/types/render/tool-card.d.ts +2 -0
  18. package/dist/types/sixel.d.ts +28 -0
  19. package/dist/types/status-line/host.d.ts +3 -0
  20. package/dist/types/terminal-capabilities.d.ts +6 -1
  21. package/dist/types/tools/bash.d.ts +2 -0
  22. package/dist/types/tools/default-renderer.d.ts +3 -0
  23. package/dist/types/tools/eval.d.ts +9 -8
  24. package/dist/types/tools/hub.d.ts +3 -1
  25. package/dist/types/tools/irc.d.ts +11 -1
  26. package/dist/types/tools/job-widget.d.ts +226 -0
  27. package/dist/types/tools/json-tree.d.ts +2 -0
  28. package/dist/types/tools/line-ranges.d.ts +8 -1
  29. package/dist/types/tools/mcp.d.ts +23 -0
  30. package/dist/types/tools/output-meta.d.ts +1 -1
  31. package/dist/types/tools/read.d.ts +8 -2
  32. package/dist/types/tools/renderer.d.ts +8 -2
  33. package/dist/types/tools/result-card.d.ts +1 -1
  34. package/dist/types/tools/streaming-output.d.ts +1 -0
  35. package/dist/types/tools/task.d.ts +12 -0
  36. package/dist/types/tools/wait.d.ts +11 -0
  37. package/package.json +9 -9
  38. package/src/apps/session-picker.ts +20 -2
  39. package/src/autocomplete.d.ts +6 -0
  40. package/src/autocomplete.ts +21 -6
  41. package/src/chat/skill-message.ts +16 -17
  42. package/src/chat/tool-execution.ts +35 -0
  43. package/src/chrome/transcript-container.ts +85 -13
  44. package/src/components/editor.ts +17 -7
  45. package/src/components/image.ts +5 -4
  46. package/src/components/markdown.ts +11 -3
  47. package/src/components/sparkline.d.ts +34 -0
  48. package/src/components/sparkline.ts +89 -0
  49. package/src/index.d.ts +1 -0
  50. package/src/index.ts +1 -0
  51. package/src/native/backend.ts +32 -6
  52. package/src/native/describe.d.ts +7 -1
  53. package/src/native/describe.ts +18 -1
  54. package/src/native/overlay.d.ts +2 -0
  55. package/src/native/overlay.ts +3 -0
  56. package/src/native/tone.d.ts +2 -0
  57. package/src/native/tone.ts +22 -0
  58. package/src/overlays/agent-hub.ts +14 -10
  59. package/src/overlays/agent-transcript-viewer.ts +2 -2
  60. package/src/overlays/agents-hub.ts +2 -1
  61. package/src/overlays/approval-dialog.ts +59 -13
  62. package/src/overlays/hub-frame.ts +2 -1
  63. package/src/overlays/jobs-panel.ts +24 -2
  64. package/src/overlays/model-browser.ts +3 -0
  65. package/src/overlays/model-hub.ts +2 -1
  66. package/src/overlays/resume-picker.ts +706 -0
  67. package/src/overlays/resume-tree.ts +1901 -0
  68. package/src/overlays/session-selector.ts +579 -39
  69. package/src/prompt/attachment-chips.ts +1 -1
  70. package/src/render/output-block.d.ts +2 -0
  71. package/src/render/output-block.ts +25 -12
  72. package/src/render/tool-card.d.ts +2 -0
  73. package/src/render/tool-card.ts +6 -0
  74. package/src/sixel.d.ts +28 -0
  75. package/src/sixel.ts +185 -0
  76. package/src/status-line/component.ts +19 -2
  77. package/src/status-line/host.ts +3 -0
  78. package/src/terminal-capabilities.d.ts +6 -1
  79. package/src/terminal-capabilities.ts +54 -3
  80. package/src/theme/symbols.ts +5 -3
  81. package/src/tools/bash.d.ts +2 -0
  82. package/src/tools/bash.ts +25 -9
  83. package/src/tools/default-renderer.d.ts +3 -0
  84. package/src/tools/default-renderer.ts +54 -17
  85. package/src/tools/eval.d.ts +9 -8
  86. package/src/tools/eval.ts +32 -14
  87. package/src/tools/find.ts +4 -5
  88. package/src/tools/glob.ts +4 -6
  89. package/src/tools/grep.ts +2 -4
  90. package/src/tools/hub.ts +4 -1
  91. package/src/tools/irc.d.ts +11 -1
  92. package/src/tools/irc.ts +11 -1
  93. package/src/tools/job-widget.d.ts +226 -0
  94. package/src/tools/job-widget.ts +1036 -0
  95. package/src/tools/json-tree.d.ts +2 -0
  96. package/src/tools/json-tree.ts +8 -0
  97. package/src/tools/line-ranges.d.ts +8 -1
  98. package/src/tools/line-ranges.ts +23 -13
  99. package/src/tools/mcp.d.ts +23 -0
  100. package/src/tools/mcp.ts +44 -1
  101. package/src/tools/output-meta.d.ts +1 -1
  102. package/src/tools/output-meta.ts +1 -1
  103. package/src/tools/proc-render.ts +1 -0
  104. package/src/tools/read.d.ts +8 -2
  105. package/src/tools/read.ts +64 -21
  106. package/src/tools/renderer.d.ts +8 -2
  107. package/src/tools/renderer.ts +8 -2
  108. package/src/tools/result-card.d.ts +1 -1
  109. package/src/tools/result-card.ts +1 -2
  110. package/src/tools/streaming-output.d.ts +1 -0
  111. package/src/tools/streaming-output.ts +11 -9
  112. package/src/tools/task.d.ts +12 -0
  113. package/src/tools/task.ts +25 -1
  114. package/src/tools/wait.d.ts +11 -0
  115. package/src/tools/wait.ts +296 -147
  116. package/src/tui.ts +45 -5
@@ -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[];
@@ -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.d.ts ADDED
@@ -0,0 +1,28 @@
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 declare function ensureSixelRasterWidth(sequence: string): string;
8
+ /**
9
+ * Crop integer pixel rows from a raw (not tmux-wrapped) SIXEL DCS image.
10
+ * Runs are shifted between adjacent six-pixel bands, never decoded to pixels.
11
+ * Palette selections, overdraw order, transparent gaps and DCS background mode
12
+ * are retained. A nonpositive crop returns the original sequence unchanged.
13
+ *
14
+ * The supported grammar is the native icy_sixel encoder's: one leading raster
15
+ * declaration with positive dimensions and square pixels, prefix RGB/HLS palette
16
+ * definitions, register selections, sixel characters, repeats, `$` and `-`.
17
+ * ESC DCS/ST framing only, as the TUI emits it. Palette definitions after
18
+ * drawing, additional raster declarations, other controls and malformed input
19
+ * throw; forwarding an unsupported image uncropped would reintroduce raster
20
+ * overflow.
21
+ *
22
+ * Without maxHeightPixels the crop is exact, including a partial final band with
23
+ * zero padding bits and the exact declared raster height. Some terminals still
24
+ * allocate all six rows of that final band. The TUI supplies its available pixel
25
+ * height to cap the result at a whole-band boundary, matching the native encoder's
26
+ * policy of dropping bottom pixels rather than overwriting the following row.
27
+ */
28
+ export declare function cropSixelTop(sequence: string, pixels: number, maxHeightPixels?: number): string;
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 zeta to open. */
63
67
  export type StatusLineNativeAction = "status.model" | "status.context" | "status.git" | "status.cost" | "status.path";
64
68
 
@@ -454,6 +458,8 @@ class StatusLineExternalInputs {
454
458
  isFastModeActive = false;
455
459
  anthropicSlowModeLabel: string | undefined = undefined;
456
460
  compactionSpeculation: unknown = undefined;
461
+ /** The freshest running job widget's view (stable identity while its data is unchanged). */
462
+ jobWidgetRef: unknown = undefined;
457
463
  }
458
464
 
459
465
  interface CachedStatusLine {
@@ -2818,6 +2824,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2818
2824
  ? this.session.getAnthropicSlowModeLabel()
2819
2825
  : undefined;
2820
2826
  target.compactionSpeculation = this.session.compactionSpeculation;
2827
+ target.jobWidgetRef = this.#latestJobWidget()?.view;
2828
+ }
2829
+
2830
+ /** The running job widget that saw data last; the ANSI jobs segment appends its glance. */
2831
+ #latestJobWidget(): JobWidgetEntry | undefined {
2832
+ const entries = this.session.getRunningJobWidgets?.();
2833
+ return entries ? latestJobWidget(entries) : undefined;
2821
2834
  }
2822
2835
 
2823
2836
  #sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
@@ -2871,7 +2884,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
2871
2884
  left.isAutoThinking === right.isAutoThinking &&
2872
2885
  left.isFastModeActive === right.isFastModeActive &&
2873
2886
  left.anthropicSlowModeLabel === right.anthropicSlowModeLabel &&
2874
- left.compactionSpeculation === right.compactionSpeculation
2887
+ left.compactionSpeculation === right.compactionSpeculation &&
2888
+ left.jobWidgetRef === right.jobWidgetRef
2875
2889
  );
2876
2890
  }
2877
2891
 
@@ -3080,7 +3094,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
3080
3094
  if (layout !== "plain-left") {
3081
3095
  const runningBackgroundJobs = this.runningBackgroundJobCount();
3082
3096
  if (runningBackgroundJobs > 0) {
3083
- rightParts.unshift(theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`));
3097
+ const widget = this.#latestJobWidget();
3098
+ const glance = widget ? formatJobWidgetGlance(widget.view, theme, JOBS_SEGMENT_SPARK_CELLS) : "";
3099
+ const count = theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`);
3100
+ rightParts.unshift(glance ? `${count} ${glance}` : count);
3084
3101
  }
3085
3102
  if (subagentBadge) rightParts.unshift(subagentBadge);
3086
3103
  }
@@ -1,6 +1,7 @@
1
1
  import type { AgentMessage, ThinkingLevel } from "@linxiraos/pi-agent-core";
2
2
  import type { Tool, UsageLimit, UsageReport } from "@linxiraos/pi-ai";
3
3
  import type { Model } from "@linxiraos/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;
@@ -18,7 +18,7 @@ export declare enum NotifyProtocol {
18
18
  Osc99 = "\u001B]99;;",
19
19
  Osc9 = "\u001B]9;"
20
20
  }
21
- export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "otty" | "rio" | "tern" | "monstar" | "base" | "trueColor";
21
+ export type TerminalId = "kitty" | "ghostty" | "wezterm" | "iterm2" | "vscode" | "alacritty" | "warp" | "orca" | "otty" | "rio" | "tern" | "monstar" | "zetawork" | "base" | "trueColor";
22
22
  /** Terminal capability details used for rendering and protocol selection. */
23
23
  export declare class TerminalInfo {
24
24
  readonly id: TerminalId;
@@ -401,6 +401,11 @@ export declare function encodeITerm2(base64Data: string, options?: {
401
401
  preserveAspectRatio?: boolean;
402
402
  inline?: boolean;
403
403
  }): string;
404
+ /**
405
+ * The encoded image `encodeITerm2` wrote into `line`, without the size
406
+ * parameters a rewrap changes; undefined for a line without one.
407
+ */
408
+ export declare function iterm2ImagePayload(line: string): string | undefined;
404
409
  export declare function calculateImageRows(imageDimensions: ImageDimensions, targetWidthCells: number, cellDimensions?: CellDimensions): number;
405
410
  export declare function getPngDimensions(base64Data: string): ImageDimensions | null;
406
411
  export declare function getJpegDimensions(base64Data: string): ImageDimensions | null;
@@ -10,6 +10,7 @@ import {
10
10
  renderKittyPlaceholderLines,
11
11
  setKittyGraphics,
12
12
  } from "./kitty-graphics";
13
+ import { ensureSixelRasterWidth } from "./sixel";
13
14
  import {
14
15
  isInsideTerminalMultiplexer,
15
16
  isTerminalBackgroundQueryTrusted,
@@ -66,6 +67,7 @@ export type TerminalId =
66
67
  | "rio"
67
68
  | "tern"
68
69
  | "monstar"
70
+ | "zetawork"
69
71
  | "base"
70
72
  | "trueColor";
71
73
 
@@ -403,7 +405,11 @@ export function detectStyledUnderlineSupport(terminalId: TerminalId, env: NodeJS
403
405
  // render. Enable only on a confirmed major.minor >= 3.5; an absent or
404
406
  // unparseable version keeps the flat fallback so only proven terminals
405
407
  // get the colon form.
406
- const version = parseMajorMinorVersion(env.TERM_PROGRAM_VERSION);
408
+ //
409
+ // `LC_TERMINAL_VERSION` carries the same version over SSH, where
410
+ // `TERM_PROGRAM_VERSION` is stripped, so it is the fallback source.
411
+ const version =
412
+ parseMajorMinorVersion(env.TERM_PROGRAM_VERSION) ?? parseMajorMinorVersion(env.LC_TERMINAL_VERSION);
407
413
  return version !== null && (version.major > 3 || (version.major === 3 && version.minor >= 5));
408
414
  }
409
415
  default:
@@ -656,6 +662,15 @@ const KNOWN_TERMINALS = Object.freeze({
656
662
  // activating it focuses the Monstar window. The BEL path uses omp's own
657
663
  // `notify-send` fallback instead, which cannot focus a window.
658
664
  monstar: new TerminalInfo("monstar", ImageProtocol.Kitty, true, true, NotifyProtocol.Osc9, false, false, false, 2),
665
+ // zetawork is the workbench GUI's own terminal pane (`termide`
666
+ // `panel-terminal`): it sets TERM_PROGRAM=zetawork and renders Kitty
667
+ // graphics itself — direct placement only, no Unicode placeholders, which
668
+ // is why the id is not in the placeholder allowlist. It answers the cell
669
+ // size query (CSI 16 t) with its real device-pixel cell, so images are
670
+ // rasterized at the density the surface paints at. Its emulator renders
671
+ // OSC 8 links itself; notifications stay on BEL because the GUI has no
672
+ // notifier.
673
+ zetawork: new TerminalInfo("zetawork", ImageProtocol.Kitty, true, true),
659
674
  });
660
675
 
661
676
  /** Resolve terminal identity from environment markers used by common emulators. */
@@ -678,6 +693,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
678
693
  if (caseEq(program, "rio")) return "rio";
679
694
  if (caseEq(program, "tern")) return "tern";
680
695
  if (caseEq(program, "monstar")) return "monstar";
696
+ if (caseEq(program, "zetawork")) return "zetawork";
681
697
  return null;
682
698
  }
683
699
 
@@ -689,6 +705,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
689
705
  VSCODE_PID,
690
706
  ALACRITTY_WINDOW_ID,
691
707
  TERM_PROGRAM,
708
+ LC_TERMINAL,
692
709
  TERM,
693
710
  COLORTERM,
694
711
  } = env;
@@ -703,6 +720,22 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
703
720
  const programId = fromProgram(TERM_PROGRAM);
704
721
  if (programId) return programId;
705
722
 
723
+ // iTerm2 sets `LC_TERMINAL=iTerm2` (plus `LC_TERMINAL_VERSION`) in the local
724
+ // session precisely so it survives SSH: openssh/mosh forward `LC_*` to the
725
+ // remote host, while `ITERM_SESSION_ID` and `TERM_PROGRAM` are stripped. Its
726
+ // own shell integration says the same: "TERM_PROGRAM is set by the local
727
+ // terminal but not forwarded over SSH. LC_TERMINAL is set by iTerm2 and may
728
+ // be forwarded over SSH." Without this branch the remote session falls to
729
+ // the true-color profile and loses the iTerm2 inline-image protocol, OSC 8
730
+ // hyperlinks and OSC 9 notifications.
731
+ //
732
+ // Gated on the absence of a multiplexer: iTerm2's OSC 1337 encoder is the
733
+ // only image encoder that does not wrap its payload in a tmux passthrough
734
+ // envelope, so inside a multiplexer the sequence would be swallowed by the
735
+ // multiplexer instead of reaching the outer terminal. The multiplexer path
736
+ // resolves the outer client on its own.
737
+ if (LC_TERMINAL && caseEq(LC_TERMINAL, "iTerm2") && !isInsideTerminalMultiplexer(env)) return "iterm2";
738
+
706
739
  // tmux >= 3.2 replaces the pane's identity with `TERM_PROGRAM=tmux`.
707
740
  // Its server still holds the attached client's terminal-type reply and TERM.
708
741
  for (const clientProgram of resolveTmuxClientTerminalNames(env)) {
@@ -1104,6 +1137,19 @@ export function encodeITerm2(
1104
1137
  return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
1105
1138
  }
1106
1139
 
1140
+ /**
1141
+ * The encoded image `encodeITerm2` wrote into `line`, without the size
1142
+ * parameters a rewrap changes; undefined for a line without one.
1143
+ */
1144
+ export function iterm2ImagePayload(line: string): string | undefined {
1145
+ const start = line.indexOf(ImageProtocol.Iterm2);
1146
+ if (start === -1) return undefined;
1147
+ const colon = line.indexOf(":", start + ImageProtocol.Iterm2.length);
1148
+ if (colon === -1) return undefined;
1149
+ const end = line.indexOf("\x07", colon + 1);
1150
+ return line.slice(colon + 1, end === -1 ? undefined : end);
1151
+ }
1152
+
1107
1153
  export function calculateImageRows(
1108
1154
  imageDimensions: ImageDimensions,
1109
1155
  targetWidthCells: number,
@@ -1298,7 +1344,9 @@ export function encodeSixelNow(
1298
1344
  backgroundRgb = getTerminalBackgroundColor(),
1299
1345
  ): string | null {
1300
1346
  try {
1301
- return encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb);
1347
+ return ensureSixelRasterWidth(
1348
+ encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb),
1349
+ );
1302
1350
  } catch {
1303
1351
  return null;
1304
1352
  }
@@ -1371,9 +1419,12 @@ export function renderImage(
1371
1419
  return { sequence, rows };
1372
1420
  }
1373
1421
  if (TERMINAL.imageProtocol === ImageProtocol.Iterm2) {
1422
+ // The height is the rows reserved for the image, not `auto`: iTerm2
1423
+ // resolves `auto` only for a session on screen, so an image written to a
1424
+ // background tab is dropped, and its rows stay blank in scrollback too.
1374
1425
  const sequence = encodeITerm2(base64Data, {
1375
1426
  width: fit.columns,
1376
- height: "auto",
1427
+ height: fit.rows,
1377
1428
  preserveAspectRatio: options.preserveAspectRatio ?? true,
1378
1429
  });
1379
1430
  return { sequence, rows: fit.rows };
@@ -628,9 +628,11 @@ const UNICODE_SYMBOLS: SymbolMap = {
628
628
  "lang.pdf": "📕",
629
629
  "lang.archive": "🗜",
630
630
  "lang.binary": "⚙",
631
- // Composer attachment chips
632
- "chip.image": "🖼",
633
- "chip.video": "🎞",
631
+ // Composer attachment chips. These sit inside the editable buffer, so the caret column
632
+ // depends on their width: use Emoji_Presentation glyphs, which every terminal draws 2 cells
633
+ // wide. Text-presentation pictographs (🖼, 🎞) measure 1 cell but many terminals draw them 2.
634
+ "chip.image": "📷",
635
+ "chip.video": "🎬",
634
636
  "chip.paste": "📄",
635
637
  // Settings tabs
636
638
  "tab.appearance": "🎨",
@@ -117,6 +117,7 @@ export declare function createShellRenderer<TArgs>(config: ShellRendererConfig<T
117
117
  }, args?: TArgs): NativeToolView;
118
118
  mergeCallAndResult: true;
119
119
  inline: true;
120
+ tickElapsed: true;
120
121
  };
121
122
  /** Renders bash command previews and output. */
122
123
  export declare const bashToolRenderer: {
@@ -146,4 +147,5 @@ export declare const bashToolRenderer: {
146
147
  }, args?: BashRenderArgs | undefined): NativeToolView;
147
148
  mergeCallAndResult: true;
148
149
  inline: true;
150
+ tickElapsed: true;
149
151
  };