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

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (46) hide show
  1. package/CHANGELOG.md +17 -0
  2. package/dist/types/autocomplete.d.ts +6 -0
  3. package/dist/types/chat/skill-message.d.ts +3 -4
  4. package/dist/types/components/sparkline.d.ts +34 -0
  5. package/dist/types/index.d.ts +1 -0
  6. package/dist/types/native/describe.d.ts +7 -1
  7. package/dist/types/native/tone.d.ts +2 -0
  8. package/dist/types/overlays/agent-hub.d.ts +3 -3
  9. package/dist/types/overlays/approval-dialog.d.ts +15 -1
  10. package/dist/types/overlays/jobs-panel.d.ts +3 -0
  11. package/dist/types/overlays/model-browser.d.ts +1 -1
  12. package/dist/types/sixel.d.ts +28 -0
  13. package/dist/types/status-line/host.d.ts +3 -0
  14. package/dist/types/tools/irc.d.ts +6 -0
  15. package/dist/types/tools/job-widget.d.ts +226 -0
  16. package/dist/types/tools/output-meta.d.ts +1 -1
  17. package/dist/types/tools/streaming-output.d.ts +1 -0
  18. package/dist/types/tools/task.d.ts +12 -0
  19. package/dist/types/tools/wait.d.ts +5 -0
  20. package/package.json +9 -9
  21. package/src/autocomplete.ts +12 -1
  22. package/src/chat/skill-message.ts +19 -21
  23. package/src/components/editor.ts +17 -7
  24. package/src/components/image.ts +5 -4
  25. package/src/components/sparkline.ts +89 -0
  26. package/src/index.ts +1 -0
  27. package/src/native/describe.ts +18 -1
  28. package/src/native/tone.ts +22 -0
  29. package/src/overlays/agent-hub.ts +14 -10
  30. package/src/overlays/agent-transcript-viewer.ts +2 -2
  31. package/src/overlays/approval-dialog.ts +59 -13
  32. package/src/overlays/jobs-panel.ts +10 -0
  33. package/src/overlays/model-browser.ts +3 -0
  34. package/src/prompt/attachment-chips.ts +1 -1
  35. package/src/sixel.ts +185 -0
  36. package/src/status-line/component.ts +19 -2
  37. package/src/status-line/host.ts +3 -0
  38. package/src/terminal-capabilities.ts +4 -1
  39. package/src/theme/symbols.ts +5 -3
  40. package/src/tools/irc.ts +6 -0
  41. package/src/tools/job-widget.ts +1036 -0
  42. package/src/tools/output-meta.ts +1 -1
  43. package/src/tools/streaming-output.ts +11 -9
  44. package/src/tools/task.ts +25 -1
  45. package/src/tools/wait.ts +141 -85
  46. package/src/tui.ts +45 -5
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;
@@ -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,
@@ -1281,7 +1282,9 @@ export function encodeSixelNow(
1281
1282
  backgroundRgb = getTerminalBackgroundColor(),
1282
1283
  ): string | null {
1283
1284
  try {
1284
- return encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb);
1285
+ return ensureSixelRasterWidth(
1286
+ encodeSixel(new Uint8Array(Buffer.from(base64Data, "base64")), widthPx, heightPx, backgroundRgb),
1287
+ );
1285
1288
  } catch {
1286
1289
  return null;
1287
1290
  }
@@ -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": "🎨",
package/src/tools/irc.ts CHANGED
@@ -21,6 +21,12 @@ export interface IrcMessage {
21
21
  * ping-pong forever.
22
22
  */
23
23
  wakeRelay?: boolean;
24
+ /**
25
+ * One leg of an `all` broadcast. Like relays, broadcasts never draw an
26
+ * automatic wake-turn reply: every woken idle peer would otherwise answer
27
+ * the broadcaster, mostly to say the message was not for it.
28
+ */
29
+ broadcast?: boolean;
24
30
  }
25
31
 
26
32
  /** Delivery outcome for one peer recipient. */