@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
@@ -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;
@@ -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,
@@ -404,7 +405,11 @@ export function detectStyledUnderlineSupport(terminalId: TerminalId, env: NodeJS
404
405
  // render. Enable only on a confirmed major.minor >= 3.5; an absent or
405
406
  // unparseable version keeps the flat fallback so only proven terminals
406
407
  // get the colon form.
407
- 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);
408
413
  return version !== null && (version.major > 3 || (version.major === 3 && version.minor >= 5));
409
414
  }
410
415
  default:
@@ -700,6 +705,7 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
700
705
  VSCODE_PID,
701
706
  ALACRITTY_WINDOW_ID,
702
707
  TERM_PROGRAM,
708
+ LC_TERMINAL,
703
709
  TERM,
704
710
  COLORTERM,
705
711
  } = env;
@@ -714,6 +720,22 @@ export function detectTerminalId(env: NodeJS.ProcessEnv = Bun.env): TerminalId {
714
720
  const programId = fromProgram(TERM_PROGRAM);
715
721
  if (programId) return programId;
716
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
+
717
739
  // tmux >= 3.2 replaces the pane's identity with `TERM_PROGRAM=tmux`.
718
740
  // Its server still holds the attached client's terminal-type reply and TERM.
719
741
  for (const clientProgram of resolveTmuxClientTerminalNames(env)) {
@@ -1115,6 +1137,19 @@ export function encodeITerm2(
1115
1137
  return `\x1b]1337;File=${params.join(";")}:${base64Data}\x07`;
1116
1138
  }
1117
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
+
1118
1153
  export function calculateImageRows(
1119
1154
  imageDimensions: ImageDimensions,
1120
1155
  targetWidthCells: number,
@@ -1309,7 +1344,9 @@ export function encodeSixelNow(
1309
1344
  backgroundRgb = getTerminalBackgroundColor(),
1310
1345
  ): string | null {
1311
1346
  try {
1312
- 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
+ );
1313
1350
  } catch {
1314
1351
  return null;
1315
1352
  }
@@ -1382,9 +1419,12 @@ export function renderImage(
1382
1419
  return { sequence, rows };
1383
1420
  }
1384
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.
1385
1425
  const sequence = encodeITerm2(base64Data, {
1386
1426
  width: fit.columns,
1387
- height: "auto",
1427
+ height: fit.rows,
1388
1428
  preserveAspectRatio: options.preserveAspectRatio ?? true,
1389
1429
  });
1390
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
  };
package/src/tools/bash.ts CHANGED
@@ -7,6 +7,7 @@ import { formatOutputPaneLines } from "../render/output-pane";
7
7
  import {
8
8
  capPreviewLines,
9
9
  DEFAULT_TERMINAL_PREVIEW_LINES,
10
+ formatDuration,
10
11
  formatToolWorkingDirectory,
11
12
  previewWindowRows,
12
13
  replaceTabs,
@@ -470,9 +471,14 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
470
471
  },
471
472
  uiTheme,
472
473
  );
474
+ const elapsedMs = options.executionStarted ? options.elapsedMs : undefined;
473
475
  return {
474
476
  header,
475
477
  phase: options.spinnerFrame !== undefined ? "running" : "pending",
478
+ footerMeta:
479
+ elapsedMs === undefined || elapsedMs < 1000
480
+ ? undefined
481
+ : uiTheme.fg("dim", `elapsed ${formatDuration(Math.floor(elapsedMs / 1000) * 1000)}`),
476
482
  sections: [{ content: capPreviewLines(cmdLines, uiTheme, { expanded: options.expanded }) }],
477
483
  };
478
484
  });
@@ -528,6 +534,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
528
534
  let cachedIsPartial: boolean | undefined;
529
535
  let cachedPreviewWindow: number | undefined;
530
536
  let cachedSnapshot: ToolCardSnapshot | undefined;
537
+ let cachedElapsedSecond: number | undefined;
531
538
  const dropSnapshot = () => {
532
539
  cachedSnapshot = undefined;
533
540
  cachedWidth = undefined;
@@ -536,6 +543,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
536
543
  cachedRawOutput = undefined;
537
544
  cachedIsPartial = undefined;
538
545
  cachedPreviewWindow = undefined;
546
+ cachedElapsedSecond = undefined;
539
547
  };
540
548
 
541
549
  return framedToolCard(
@@ -553,6 +561,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
553
561
 
554
562
  const isPartial = options.isPartial === true;
555
563
  const previewWindow = previewWindowRows();
564
+ // A running command shows its live elapsed time in the bottom
565
+ // border; key the cache on whole seconds so it advances once a
566
+ // second instead of rebuilding every spinner frame.
567
+ const liveElapsedMs = isPartial && details?.wallTimeMs === undefined ? options.elapsedMs : undefined;
568
+ const elapsedSecond = liveElapsedMs === undefined ? undefined : Math.floor(liveElapsedMs / 1000);
556
569
 
557
570
  if (
558
571
  cachedSnapshot !== undefined &&
@@ -561,7 +574,8 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
561
574
  cachedExpanded === expanded &&
562
575
  cachedRawOutput === rawOutput &&
563
576
  cachedIsPartial === isPartial &&
564
- cachedPreviewWindow === previewWindow
577
+ cachedPreviewWindow === previewWindow &&
578
+ cachedElapsedSecond === elapsedSecond
565
579
  ) {
566
580
  return cachedSnapshot;
567
581
  }
@@ -576,13 +590,11 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
576
590
  rawOutputArtifact.artifactId,
577
591
  isError,
578
592
  );
579
- const timeoutLine =
580
- statsParts.length > 0
581
- ? uiTheme.fg(
582
- "dim",
583
- `${uiTheme.format.bracketLeft}${statsParts.join(" | ")}${uiTheme.format.bracketRight}`,
584
- )
585
- : undefined;
593
+ if (elapsedSecond !== undefined && elapsedSecond > 0) {
594
+ statsParts.unshift(`elapsed ${formatDuration(elapsedSecond * 1000)}`);
595
+ }
596
+ const footerMeta =
597
+ statsParts.length > 0 ? uiTheme.fg("dim", statsParts.join(uiTheme.sep.dot)) : undefined;
586
598
  let warningLine: string | undefined;
587
599
  if (details?.meta?.artifactError || (details?.meta?.truncation && !showingFullOutput)) {
588
600
  warningLine = formatStyledTruncationWarning(details.meta, uiTheme) ?? undefined;
@@ -610,12 +622,12 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
610
622
  uiTheme,
611
623
  );
612
624
  const outputLines: string[] = [...formatted.lines];
613
- if (timeoutLine) outputLines.push(timeoutLine);
614
625
  if (warningLine) outputLines.push(warningLine);
615
626
 
616
627
  const snapshot: ToolCardSnapshot = {
617
628
  header,
618
629
  phase: isPartial ? "partial" : isError ? (isTimeout ? "warning" : "error") : "success",
630
+ footerMeta,
619
631
  sections: [
620
632
  {
621
633
  // Viewport-sized tail window in every state — streaming and final
@@ -636,6 +648,7 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
636
648
  cachedRawOutput = rawOutput;
637
649
  cachedIsPartial = isPartial;
638
650
  cachedPreviewWindow = previewWindow;
651
+ cachedElapsedSecond = elapsedSecond;
639
652
  cachedSnapshot = snapshot;
640
653
  return snapshot;
641
654
  },
@@ -714,6 +727,9 @@ export function createShellRenderer<TArgs>(config: ShellRendererConfig<TArgs>) {
714
727
  },
715
728
  mergeCallAndResult: true,
716
729
  inline: true,
730
+ // The live elapsed footer reads `options.elapsedMs` at render time; a
731
+ // once-a-second repaint keeps it moving for a quiet running command.
732
+ tickElapsed: true,
717
733
  } satisfies ToolRenderer<TArgs, BashToolDetails>;
718
734
  }
719
735
 
@@ -23,6 +23,9 @@ export declare function formatDefaultToolExecution(input: DefaultToolRenderInput
23
23
  /**
24
24
  * TSP view of the generic fallback card: the tool label with a one-line args
25
25
  * summary as the head target; the result as a JSON tree or raw `ansi` output.
26
+ * The complete output goes over the wire — the card-level disclosure clamp the
27
+ * terminal applies is the only bound, so an expanded card keeps every row. JSON
28
+ * the tree's depth, node or scalar caps would cut keeps the raw path instead.
26
29
  */
27
30
  export declare function describeDefaultToolExecution(input: DefaultToolRenderInput): NativeToolView;
28
31
  /** Render the generic fallback as the state-tinted card used by direct custom tools. */