@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
@@ -7,6 +7,7 @@ import type { NativeNode } from "../native/node";
7
7
  import { isNativeSettled, settleNative } from "../native/settle";
8
8
  import { isUsageRowBlock } from "../overlays/usage-row";
9
9
  import { isToolActivityComponent } from "./tool-activity";
10
+ import { directPlacementLift, ImageProtocol, iterm2ImagePayload, TERMINAL } from "../terminal-capabilities";
10
11
 
11
12
  /** Shared animation time supplied by the constrained transcript root. */
12
13
  export interface AnimationFrame {
@@ -118,6 +119,14 @@ interface RowSplit {
118
119
  readonly width: number;
119
120
  readonly hash: number | bigint;
120
121
  readonly text: string;
122
+ /**
123
+ * Inline images in the committed rows, in order, each with its payload and
124
+ * the length of `text` before it. Their reserved rows carry no content, so
125
+ * relocating by `text` alone would stop above an image and make the
126
+ * archived image live again at another width; payload and offset tell the
127
+ * archived image apart from a replacement or one moved elsewhere.
128
+ */
129
+ readonly images: readonly { readonly payload: string; readonly offset: number }[];
121
130
  /** Seam of the last render it was located in; renders are memoized per frame. */
122
131
  located?: { readonly rendered: readonly string[]; readonly count: number };
123
132
  }
@@ -180,7 +189,33 @@ function contentOf(rows: readonly string[]): string {
180
189
  */
181
190
  function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
182
191
  const committed = scrollback.slice(0, rows);
183
- return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
192
+ const images: { payload: string; offset: number }[] = [];
193
+ let text = "";
194
+ for (const row of committed) {
195
+ if (TERMINAL.isImageLine(row)) images.push({ payload: iterm2ImagePayload(row) ?? row, offset: text.length });
196
+ text += contentOf([row]);
197
+ }
198
+ return { rows, width, hash: Bun.hash(committed.join("\n")), text, images };
199
+ }
200
+
201
+ /**
202
+ * `cut` moved to just below an image it would split, on terminals that cannot
203
+ * draw part of an image (iTerm2's inline images). There the image row draws
204
+ * the whole picture upward over its reserved rows; once some of those rows are
205
+ * in scrollback, the picture has nowhere to go and is dropped, so the image
206
+ * must reach scrollback whole. Kitty and SIXEL draw the visible part instead.
207
+ */
208
+ function cutBelowWholeImage(rows: readonly string[], cut: number): number {
209
+ const protocol = TERMINAL.imageProtocol;
210
+ if (protocol === null || protocol === ImageProtocol.Kitty || protocol === ImageProtocol.Sixel) return cut;
211
+ // Only reserved rows sit between a cut inside an image and its image row,
212
+ // so the first image row at or below the cut decides.
213
+ for (let index = Math.max(0, cut); index < rows.length; index++) {
214
+ const row = rows[index]!;
215
+ if (!TERMINAL.isImageLine(row)) continue;
216
+ return index - directPlacementLift(row) < cut ? index + 1 : cut;
217
+ }
218
+ return cut;
184
219
  }
185
220
 
186
221
  /** Whether `prefix` matches `rows` byte-for-byte from the top. */
@@ -819,13 +854,19 @@ export class TranscriptContainer extends Container {
819
854
  // screen; the rest stays live, so a later shrink of the chrome or the
820
855
  // tail refills the screen from live rows instead of opening a band no
821
856
  // frame can fill (rows still on screen cannot return from scrollback).
857
+ // One exception: on a terminal that cannot draw part of an image, a cut
858
+ // inside an image moves below it (cutBelowWholeImage), so up to that
859
+ // image's height of on-screen rows commits early. A later shrink can then
860
+ // open a band that tall, which beats losing the image.
822
861
  let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
823
862
  if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
824
863
  const boundary = live[keep - 1]!;
825
864
  const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
826
865
  if (boundary.entry.state === "settled" && visible > 0) {
827
866
  const offer = this.#splitOffer(boundary.entry, width, visible);
828
- if (offer !== undefined) {
867
+ if (offer === "whole") {
868
+ limit = boundary.index + 1;
869
+ } else if (offer !== undefined) {
829
870
  split = { index: boundary.index, ...offer };
830
871
  limit = boundary.index;
831
872
  }
@@ -868,18 +909,23 @@ export class TranscriptContainer extends Container {
868
909
  * The head rows of settled `entry` beyond its committed prefix that leave
869
910
  * `visible` rows live, or undefined when nothing would remain committed
870
911
  * past that prefix or the scrollback render would not line up row for row.
912
+ * `"whole"` when keeping an image whole moves the cut to the block's end:
913
+ * the block then retires in full, with the blank row that separates it from
914
+ * the next block, which a split never carries.
871
915
  */
872
916
  #splitOffer(
873
917
  entry: TranscriptEntry,
874
918
  width: number,
875
919
  visible: number,
876
- ): { rows: readonly string[]; split: RowSplit } | undefined {
920
+ ): { rows: readonly string[]; split: RowSplit } | "whole" | undefined {
877
921
  const measured = this.#measuredRows(entry, width);
878
922
  const start = this.#emittedRowCount(entry, width);
879
- const total = measured.length - visible;
880
- if (total <= start) return undefined;
923
+ const cut = measured.length - visible;
924
+ if (cut <= start) return undefined;
881
925
  const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
882
926
  if (scrollback.length !== measured.length) return undefined;
927
+ const total = cutBelowWholeImage(scrollback, cut);
928
+ if (total >= scrollback.length) return "whole";
883
929
  return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
884
930
  }
885
931
 
@@ -1096,13 +1142,40 @@ export class TranscriptContainer extends Container {
1096
1142
  count = split.rows;
1097
1143
  } else {
1098
1144
  let consumed = 0;
1099
- while (count < rendered.length && consumed < split.text.length) {
1100
- const row = contentOf([rendered[count]!]);
1101
- // An empty-content row matches at any offset, so the walk cannot
1102
- // tell it apart from a revealed row history never received.
1103
- if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
1104
- consumed += row.length;
1105
- count++;
1145
+ let image = 0;
1146
+ while (count < rendered.length && (consumed < split.text.length || image < split.images.length)) {
1147
+ const row = rendered[count]!;
1148
+ const content = contentOf([row]);
1149
+ if (content.length > 0) {
1150
+ if (!split.text.startsWith(content, consumed)) break;
1151
+ consumed += content.length;
1152
+ count++;
1153
+ continue;
1154
+ }
1155
+ // An empty-content row matches at any offset, so the walk cannot tell it
1156
+ // apart from a revealed row history never received; only the next
1157
+ // archived image carries the seam on, through its own reserved rows to
1158
+ // its row, whatever its height here.
1159
+ let index = count;
1160
+ while (
1161
+ index < rendered.length &&
1162
+ !TERMINAL.isImageLine(rendered[index]!) &&
1163
+ contentOf([rendered[index]!]).length === 0
1164
+ ) {
1165
+ index++;
1166
+ }
1167
+ const imageRow = rendered[index];
1168
+ if (
1169
+ imageRow === undefined ||
1170
+ !TERMINAL.isImageLine(imageRow) ||
1171
+ (iterm2ImagePayload(imageRow) ?? imageRow) !== split.images[image]?.payload ||
1172
+ consumed !== split.images[image]?.offset ||
1173
+ index - count !== directPlacementLift(imageRow)
1174
+ ) {
1175
+ break;
1176
+ }
1177
+ count = index + 1;
1178
+ image++;
1106
1179
  }
1107
1180
  }
1108
1181
  split.located = { rendered, count };
@@ -704,6 +704,8 @@ export class Editor implements Component, Focusable {
704
704
  | { line: number; startCol: number; endCol: number; original: string; cursorOffset: number }
705
705
  | undefined;
706
706
  #autocompletePrefix: string = "";
707
+ /** False while the popup's untouched top row is a description-only slash hit ({@link AutocompleteItem.descriptionMatch}): Enter then submits the typed text. */
708
+ #enterAcceptsSelection = true;
707
709
  #autocompleteRequestId: number = 0;
708
710
  #autocompletePendingRequest: AutocompleteRequest | undefined;
709
711
  #autocompleteRequestRunning = false;
@@ -1916,6 +1918,7 @@ export class Editor implements Component, Focusable {
1916
1918
  this.onAutocompleteUpdate?.();
1917
1919
  } else {
1918
1920
  this.#autocompleteList.handleInput(data);
1921
+ this.#enterAcceptsSelection = true;
1919
1922
  this.onAutocompleteUpdate?.();
1920
1923
  return;
1921
1924
  }
@@ -1960,8 +1963,11 @@ export class Editor implements Component, Focusable {
1960
1963
  // Check for stale autocomplete state while an async refresh is pending
1961
1964
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1962
1965
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1963
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1964
- // Autocomplete is stale - cancel and fall through to normal submission
1966
+ if (
1967
+ !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1968
+ !this.#enterAcceptsSelection
1969
+ ) {
1970
+ // Stale, or a description-only hit the user never selected: submit the typed text.
1965
1971
  this.#cancelAutocomplete();
1966
1972
  } else {
1967
1973
  if (selected && this.#autocompleteProvider) {
@@ -1993,10 +1999,12 @@ export class Editor implements Component, Focusable {
1993
1999
  if (
1994
2000
  !selected ||
1995
2001
  !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1996
- this.#selectedSlashArgumentIsAlreadyTyped(selected)
2002
+ this.#selectedSlashArgumentIsAlreadyTyped(selected) ||
2003
+ !this.#enterAcceptsSelection
1997
2004
  ) {
1998
- // Stale, or accepting would only append whitespace to an already fully
1999
- // typed slash-command argument (`/mcp list` + Enter): cancel and fall
2005
+ // Stale, accepting would only append whitespace to an already fully
2006
+ // typed slash-command argument (`/mcp list` + Enter), or the row is a
2007
+ // description-only hit the user never selected: cancel and fall
2000
2008
  // through to normal submission instead of swallowing the keypress.
2001
2009
  this.#cancelAutocomplete();
2002
2010
  } else {
@@ -2140,9 +2148,10 @@ export class Editor implements Component, Focusable {
2140
2148
  ) {
2141
2149
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
2142
2150
  // The collapsed `/skill:` namespace row is not a runnable command; Enter submits
2143
- // immediately after applying, so only a real command may be completed here.
2151
+ // immediately after applying, so only a real command may be completed here, and
2152
+ // only one whose name matches: a description-only hit would run an unrelated command.
2144
2153
  const selected = syncResult?.items.find(item => item.value !== SKILL_NAMESPACE);
2145
- if (syncResult && selected) {
2154
+ if (syncResult && selected && !selected.descriptionMatch) {
2146
2155
  // Invalidate any pending async autocomplete so its stale results are discarded
2147
2156
  this.#autocompleteRequestId += 1;
2148
2157
  // Apply the best match and submit the completed command
@@ -4871,6 +4880,7 @@ export class Editor implements Component, Focusable {
4871
4880
  ): void {
4872
4881
  this.#autocompletePrefix = suggestions.prefix;
4873
4882
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
4883
+ this.#enterAcceptsSelection = !suggestions.items[0]?.descriptionMatch;
4874
4884
  this.#autocompleteState = state;
4875
4885
  this.onAutocompleteUpdate?.();
4876
4886
  }
@@ -1,5 +1,6 @@
1
1
  import { encodeSixelAsync } from "@oh-my-pi/pi-natives";
2
2
  import { getKittyGraphics } from "../kitty-graphics";
3
+ import { ensureSixelRasterWidth } from "../sixel";
3
4
  import {
4
5
  encodeSixelNow,
5
6
  getCellDimensions,
@@ -736,7 +737,7 @@ export class Image implements Component {
736
737
  const request: SixelEncode = { widthPx, heightPx, backgroundRgb };
737
738
  this.#sixel = request;
738
739
  const settle = (sequence: string | null): void => {
739
- request.sequence = sequence;
740
+ request.sequence = sequence === null ? null : ensureSixelRasterWidth(sequence);
740
741
  if (this.#sixel !== request || getTerminalBackgroundColor() !== backgroundRgb) return;
741
742
  this.invalidate();
742
743
  if (this.#options.requestRender) this.#options.requestRender();
@@ -845,9 +846,9 @@ export class Image implements Component {
845
846
  // height. First (rows-1) lines are empty (TUI clears them); the last
846
847
  // saves the final-row cursor, moves up to the image origin, emits the
847
848
  // image sequence, then restores the final-row cursor. When the block
848
- // straddles the viewport top, the renderer rewrites this line to the
849
- // visible slice (encodeKittyPlacementLine) from the geometry
850
- // registered below.
849
+ // straddles the viewport top, the renderer crops the source raster
850
+ // for SIXEL or rewrites the Kitty placement's visible source slice
851
+ // from the geometry registered below.
851
852
  if (this.#imageId != null && this.#budget !== undefined) {
852
853
  this.#budget.registerPlacementGeometry(
853
854
  this.#imageId,
@@ -4420,7 +4420,7 @@ export class Markdown implements Component {
4420
4420
  * Delegates to wrapTextWithAnsi() so ANSI codes + long tokens are handled
4421
4421
  * consistently with the rest of the renderer.
4422
4422
  */
4423
- #wrapCellText(text: string, maxWidth: number): string[] {
4423
+ #wrapCellText(text: string, maxWidth: number, clampOversized = true): string[] {
4424
4424
  const cellWidth = Math.max(1, maxWidth);
4425
4425
  // Wrap the whole cell in one call so wrapTextWithAnsi() balances OSC 8
4426
4426
  // hyperlink state across explicit newlines (e.g. `<br>` rendered as \n);
@@ -4435,7 +4435,14 @@ export class Markdown implements Component {
4435
4435
  // (e.g. mdCode) would bleed into the "│" and the following cells.
4436
4436
  // Terminate each line at default fg, clearing bold/italic but keeping
4437
4437
  // any ambient background (message-bg rendering) intact.
4438
- return wrapped.map(line => `${line}\x1b[22m\x1b[23m\x1b[39m`);
4438
+ // A wide glyph can exceed a one-cell column without wrapping (a 2-cell
4439
+ // emoji in a column squeezed to 1), so clamp here before the row is
4440
+ // spliced between borders; otherwise the overwide row is re-wrapped and
4441
+ // the trailing border lands on its own line.
4442
+ return wrapped.map(line => {
4443
+ const fitted = clampOversized && visibleWidth(line) > cellWidth ? truncateToWidth(line, cellWidth) : line;
4444
+ return `${fitted}\x1b[22m\x1b[23m\x1b[39m`;
4445
+ });
4439
4446
  }
4440
4447
 
4441
4448
  /**
@@ -4626,7 +4633,8 @@ export class Markdown implements Component {
4626
4633
  if (rowCellLines[openCell].length > settledLineCount) {
4627
4634
  const width = columnWidths[openCell];
4628
4635
  const kept = rowCellLines[openCell].slice(0, settledLineCount);
4629
- const last = width > 1 ? (this.#wrapCellText(kept[settledLineCount - 1], width - 1)[0] ?? "") : "";
4636
+ const last =
4637
+ width > 1 ? (this.#wrapCellText(kept[settledLineCount - 1], width - 1, false)[0] ?? "") : "";
4630
4638
  kept[settledLineCount - 1] = `${visibleWidth(last) < width ? last : ""}…`;
4631
4639
  rowCellLines[openCell] = kept;
4632
4640
  }
@@ -0,0 +1,89 @@
1
+ /**
2
+ * One-row block sparklines (`▁▂▃▅▇█`) for ANSI renderers: the usage history
3
+ * in `omp usage`, a job widget's series in the `wait` card and status line.
4
+ */
5
+
6
+ /** Block glyphs from lowest to highest. */
7
+ export const SPARK_LEVELS = ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"] as const;
8
+
9
+ /** How {@link renderSparkline} maps values to glyphs and styles them. */
10
+ export interface SparklineOptions {
11
+ /** Domain low end; values at or below it draw the lowest block. Default 0. */
12
+ readonly min?: number;
13
+ /** Domain high end; values at or above it draw the full block. Default the largest value. */
14
+ readonly max?: number;
15
+ /** Styles one glyph; `fraction` is the value's 0–1 position in the domain. */
16
+ readonly style?: (glyph: string, value: number, fraction: number) => string;
17
+ /** What a missing value draws, already styled. Default a space. */
18
+ readonly gap?: string;
19
+ }
20
+
21
+ /** The block glyph for a 0–1 fraction of the domain. */
22
+ export function sparkGlyph(fraction: number): string {
23
+ const clamped = Number.isFinite(fraction) ? Math.min(Math.max(fraction, 0), 1) : 0;
24
+ return SPARK_LEVELS[Math.min(SPARK_LEVELS.length - 1, Math.floor(clamped * SPARK_LEVELS.length))]!;
25
+ }
26
+
27
+ /** One cell per value, oldest first; `undefined` (and non-finite) values draw the gap. */
28
+ export function renderSparkline(values: readonly (number | undefined)[], options: SparklineOptions = {}): string {
29
+ const min = options.min ?? 0;
30
+ let max = options.max;
31
+ if (max === undefined) {
32
+ max = min;
33
+ for (const value of values) if (value !== undefined && Number.isFinite(value) && value > max) max = value;
34
+ }
35
+ const span = max - min;
36
+ const gap = options.gap ?? " ";
37
+ let out = "";
38
+ for (const value of values) {
39
+ if (value === undefined || !Number.isFinite(value)) {
40
+ out += gap;
41
+ continue;
42
+ }
43
+ const fraction = span > 0 ? (value - min) / span : value > min ? 1 : 0;
44
+ const glyph = sparkGlyph(fraction);
45
+ out += options.style ? options.style(glyph, value, Math.min(Math.max(fraction, 0), 1)) : glyph;
46
+ }
47
+ return out;
48
+ }
49
+
50
+ /**
51
+ * `values` squeezed into at most `cells` points by equal-count bucket means,
52
+ * newest bucket last; shorter series come back unchanged.
53
+ */
54
+ export function fitSparkValues(values: readonly number[], cells: number): readonly number[] {
55
+ const count = Math.max(0, Math.floor(cells));
56
+ if (values.length <= count) return values;
57
+ const out: number[] = [];
58
+ for (let bucket = 0; bucket < count; bucket++) {
59
+ const from = Math.floor((bucket * values.length) / count);
60
+ const to = Math.max(from + 1, Math.floor(((bucket + 1) * values.length) / count));
61
+ let sum = 0;
62
+ for (let i = from; i < to; i++) sum += values[i]!;
63
+ out.push(sum / (to - from));
64
+ }
65
+ return out;
66
+ }
67
+
68
+ /**
69
+ * The largest value per time bucket over `[sinceMs, nowMs]`, `cells` buckets
70
+ * wide; buckets without a point are `undefined` (a gap in the sparkline).
71
+ */
72
+ export function peakBuckets(
73
+ points: Iterable<{ readonly at: number; readonly value: number }>,
74
+ sinceMs: number,
75
+ nowMs: number,
76
+ cells: number,
77
+ ): (number | undefined)[] {
78
+ const span = Math.max(1, nowMs - sinceMs);
79
+ const count = Math.max(0, Math.floor(cells));
80
+ const buckets: (number | undefined)[] = Array.from({ length: count }, () => undefined);
81
+ if (count === 0) return buckets;
82
+ for (const point of points) {
83
+ const offset = Math.floor(((point.at - sinceMs) / span) * count);
84
+ const index = Math.min(count - 1, Math.max(0, offset));
85
+ const prev = buckets[index];
86
+ buckets[index] = prev === undefined ? point.value : Math.max(prev, point.value);
87
+ }
88
+ return buckets;
89
+ }
package/src/index.ts CHANGED
@@ -28,6 +28,7 @@ export * from "./components/section";
28
28
  export * from "./components/select-list";
29
29
  export * from "./components/settings-list";
30
30
  export * from "./components/spacer";
31
+ export * from "./components/sparkline";
31
32
  export * from "./components/tab-bar";
32
33
  export * from "./components/table";
33
34
  export * from "./components/text";
@@ -244,6 +244,10 @@ class Surface {
244
244
  acked = 0;
245
245
  /** Send times of unacknowledged frames, oldest first. */
246
246
  unacked: number[] = [];
247
+ /** Send time of the oldest frame left unanswered when the current stall began; cleared by the next ack. */
248
+ stalledSince: number | undefined;
249
+ /** Credit resets since {@link stalledSince}. */
250
+ stallResets = 0;
247
251
  focus: string | null = null;
248
252
  dirty = false;
249
253
 
@@ -456,6 +460,8 @@ export class NativeBackend {
456
460
  surface.reconciler.detachLive();
457
461
  surface.unacked = [];
458
462
  surface.acked = surface.seq;
463
+ surface.stalledSince = undefined;
464
+ surface.stallResets = 0;
459
465
  surface.focus = null;
460
466
  surface.dirty = false;
461
467
  this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: SESSION_ROLE, adopt: true });
@@ -612,11 +618,16 @@ export class NativeBackend {
612
618
  if (surface.unacked.length < this.#credits) return true;
613
619
  const oldest = surface.unacked[0]!;
614
620
  if (this.#scheduler.now() - oldest < STALLED_ACK_MS) return false;
615
- logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
616
- sf: surface.id,
617
- s: surface.seq,
618
- acked: surface.acked,
619
- });
621
+ // One warning per stall: a terminal that never acks would otherwise log one every `STALLED_ACK_MS`.
622
+ if (surface.stalledSince === undefined) {
623
+ surface.stalledSince = oldest;
624
+ logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
625
+ sf: surface.id,
626
+ s: surface.seq,
627
+ acked: surface.acked,
628
+ });
629
+ }
630
+ surface.stallResets++;
620
631
  surface.unacked = [];
621
632
  surface.acked = surface.seq;
622
633
  return true;
@@ -801,6 +812,15 @@ export class NativeBackend {
801
812
  const newly = Math.min(event.s, surface.seq) - surface.acked;
802
813
  surface.acked = Math.min(event.s, surface.seq);
803
814
  surface.unacked.splice(0, newly);
815
+ if (surface.stalledSince !== undefined) {
816
+ logger.debug("TSP: terminal acknowledging frames again", {
817
+ sf: surface.id,
818
+ stalledMs: this.#scheduler.now() - surface.stalledSince,
819
+ resets: surface.stallResets,
820
+ });
821
+ surface.stalledSince = undefined;
822
+ surface.stallResets = 0;
823
+ }
804
824
  this.#clearStallTimer();
805
825
  if (surface.dirty) this.#host.requestRender();
806
826
  return;
@@ -9,7 +9,7 @@
9
9
  * ]);
10
10
  * }
11
11
  */
12
- import type { TspKind, TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
12
+ import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@oh-my-pi/pi-wire";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
14
 
15
15
  /** A node of any kind. */
@@ -76,6 +76,23 @@ export function withHidden(described: NativeNode, hidden: boolean): NativeNode {
76
76
  return node(described.k, { ...described.p, hidden: true } as TspProps, described.c, described.key);
77
77
  }
78
78
 
79
+ /**
80
+ * A described tree as the node tree a prop carries (a `hovercard`): plain
81
+ * nodes only, since nothing reconciles inside a prop; component children
82
+ * (which describe themselves per frame) are left out.
83
+ */
84
+ export function propNode(described: NativeNode): TspPropNode {
85
+ const out: { k: TspKind; p?: TspProps; c?: TspPropNode[]; key?: string } = { k: described.k };
86
+ if (described.p !== undefined) out.p = described.p;
87
+ if (described.c !== undefined) {
88
+ const children: TspPropNode[] = [];
89
+ for (const child of described.c) if ("k" in child) children.push(propNode(child));
90
+ out.c = children;
91
+ }
92
+ if (described.key !== undefined) out.key = described.key;
93
+ return out as TspPropNode;
94
+ }
95
+
79
96
  /** Stable node key for an arbitrary identity string; keys join into `/` keypaths, so identities are hashed. */
80
97
  export function stableKey(identity: string): string {
81
98
  return Bun.hash(identity).toString(36);
@@ -50,6 +50,28 @@ export function backgroundChrome(bg: ThemeBg | undefined): BackgroundChrome {
50
50
  }
51
51
  }
52
52
 
53
+ /** The omp theme colour an ANSI renderer draws a TSP tone with (the inverse of {@link colorTone}). */
54
+ export function toneColor(tone: TspTone): ThemeColor {
55
+ switch (tone) {
56
+ case "neutral":
57
+ return "text";
58
+ case "accent":
59
+ case "info":
60
+ return "accent";
61
+ case "success":
62
+ return "success";
63
+ case "warning":
64
+ case "pending":
65
+ return "warning";
66
+ case "error":
67
+ return "error";
68
+ case "muted":
69
+ return "muted";
70
+ case "user":
71
+ return "customMessageLabel";
72
+ }
73
+ }
74
+
53
75
  /**
54
76
  * Tone of an omp colour used as a severity or border colour; undefined for
55
77
  * colours that carry no tone (callers pick their own fallback).
@@ -5,11 +5,11 @@
5
5
  * - Table view: every registered agent except Main (Main IS the ambient
6
6
  * chat), live from the global AgentHubRegistry — status, unread irc count,
7
7
  * current/last task, last activity. Navigate with keys, wheel, hover, and
8
- * click; `r` revives a parked agent, `x` aborts + releases one.
8
+ * click; `r` revives a parked or killed agent, `x` aborts + releases one.
9
9
  * - Chat view: per-agent transcript (incremental session-file tail, absorbed
10
10
  * from the old session observer overlay) plus an input line. Submitting
11
- * revives a parked agent, then prompts/steers it; the message lands in the
12
- * agent's persisted history via the normal prompt path.
11
+ * revives a parked or killed agent, then prompts/steers it; the message
12
+ * lands in the agent's persisted history via the normal prompt path.
13
13
  *
14
14
  * Replaces the old SessionObserverOverlayComponent (ctrl+s observer).
15
15
  */
@@ -894,7 +894,11 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
894
894
  id: "revive",
895
895
  label: "Revive",
896
896
  keys: ["r"],
897
- disabled: readOnly ?? (selected?.status === "parked" ? undefined : "Only parked agents can be revived"),
897
+ disabled:
898
+ readOnly ??
899
+ (selected?.status === "parked" || selected?.status === "aborted"
900
+ ? undefined
901
+ : "Only parked or killed agents can be revived"),
898
902
  },
899
903
  { id: "kill", label: "Kill", keys: ["x"], danger: true, disabled: readOnly ?? (selected ? undefined : true) },
900
904
  { id: "view", label: "By parent", keys: ["t"], on: this.#viewMode === "tree" },
@@ -2498,15 +2502,15 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
2498
2502
  #activateAgent(ref: TRecord): void {
2499
2503
  this.#notice = undefined;
2500
2504
  const focusAgent = this.#focusAgent;
2501
- // Aborted agents and advisor refs are read-only transcripts with no
2502
- // revivable session; open the in-hub viewer instead of failing ensureLive.
2503
- if (ref.kind === "advisor" || ref.status === "aborted" || this.#remote || !focusAgent) {
2505
+ // Advisor refs are read-only transcripts with no revivable session; open
2506
+ // the in-hub viewer instead of failing ensureLive.
2507
+ if (ref.kind === "advisor" || this.#remote || !focusAgent) {
2504
2508
  this.openChat(ref.id);
2505
2509
  return;
2506
2510
  }
2507
2511
  void (async () => {
2508
2512
  try {
2509
- await focusAgent(ref.id); // ensureLive inside revives parked agents; no parking, no session files
2513
+ await focusAgent(ref.id); // ensureLive inside revives parked and killed agents
2510
2514
  this.#onDone();
2511
2515
  } catch (error) {
2512
2516
  this.#notice = error instanceof Error ? error.message : String(error);
@@ -2523,8 +2527,8 @@ export class AgentHubOverlayComponent<TRecord extends AgentRecordLike = AgentRec
2523
2527
  this.#requestRender();
2524
2528
  return;
2525
2529
  }
2526
- if (ref.status !== "parked") {
2527
- this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked agents can be revived.`;
2530
+ if (ref.status !== "parked" && ref.status !== "aborted") {
2531
+ this.#notice = `Agent "${ref.id}" is ${ref.status} — only parked or killed agents can be revived.`;
2528
2532
  this.#requestRender();
2529
2533
  return;
2530
2534
  }
@@ -235,10 +235,10 @@ export class AgentTranscriptViewer implements Component {
235
235
  this.#pollTimer.unref?.();
236
236
  }
237
237
 
238
- /** Advisor and aborted-agent transcripts are read-only. */
238
+ /** Advisor transcripts are read-only; every other agent revives on send. */
239
239
  get #sendable(): boolean {
240
240
  const ref = this.#deps.registry.get(this.#deps.agentId);
241
- if (!ref || ref.kind === "advisor" || ref.status === "aborted") return false;
241
+ if (!ref || ref.kind === "advisor") return false;
242
242
  return Boolean(this.#deps.remote || this.#deps.lifecycle);
243
243
  }
244
244