@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
@@ -438,6 +438,8 @@ export class ToolExecutionComponent extends Container {
438
438
  #spinnerActive = false;
439
439
  // Todo write completion strikethrough reveal animation
440
440
  #todoStrikeInterval?: NodeJS.Timeout;
441
+ // Once-a-second repaint for renderers that show live elapsed time (`tickElapsed`)
442
+ #elapsedTicker?: NodeJS.Timeout;
441
443
  // Track if args are still being streamed (for edit/write spinner)
442
444
  #argsComplete = false;
443
445
  #executionStarted = false;
@@ -474,6 +476,8 @@ export class ToolExecutionComponent extends Container {
474
476
  argsComplete?: boolean;
475
477
  executionStarted?: boolean;
476
478
  renderContext?: Record<string, unknown>;
479
+ /** Live: read at render time, so a ticking spinner advances it. */
480
+ readonly elapsedMs?: number;
477
481
  } = {
478
482
  expanded: false,
479
483
  isPartial: true,
@@ -491,6 +495,10 @@ export class ToolExecutionComponent extends Container {
491
495
  toolCallId?: string,
492
496
  ) {
493
497
  super();
498
+ Object.defineProperty(this.#renderState, "elapsedMs", {
499
+ get: () => this.#elapsedMs(this.#nativeStatus()),
500
+ enumerable: true,
501
+ });
494
502
  ensureThemeSync();
495
503
  this.#toolCallId = toolCallId;
496
504
  this.#toolName = toolName;
@@ -772,6 +780,32 @@ export class ToolExecutionComponent extends Container {
772
780
  this.#renderState.spinnerFrame = undefined;
773
781
  }
774
782
  }
783
+ this.#updateElapsedTicker(isBackgroundAsyncRunning);
784
+ }
785
+
786
+ /**
787
+ * Repaint once a second while a `tickElapsed` renderer's call executes, so a
788
+ * quiet command's elapsed time advances without the 30fps spinner ticker.
789
+ */
790
+ #updateElapsedTicker(isBackgroundAsyncRunning: boolean): void {
791
+ const needsTicker =
792
+ this.#renderer?.tickElapsed === true &&
793
+ this.#executionStarted &&
794
+ this.#isPartial &&
795
+ !this.#sealed &&
796
+ !isBackgroundAsyncRunning &&
797
+ !isNativeRendering();
798
+ if (needsTicker && !this.#elapsedTicker) {
799
+ this.#elapsedTicker = setInterval(() => this.#ui.requestComponentRender(this), 1000);
800
+ } else if (!needsTicker) {
801
+ this.#stopElapsedTicker();
802
+ }
803
+ }
804
+
805
+ #stopElapsedTicker(): void {
806
+ if (!this.#elapsedTicker) return;
807
+ clearInterval(this.#elapsedTicker);
808
+ this.#elapsedTicker = undefined;
775
809
  }
776
810
 
777
811
  /**
@@ -917,6 +951,7 @@ export class ToolExecutionComponent extends Container {
917
951
  this.#renderState.spinnerFrame = undefined;
918
952
  }
919
953
  this.#stopTodoStrikeAnimation();
954
+ this.#stopElapsedTicker();
920
955
  this.#previewReady?.resolve();
921
956
  }
922
957
 
@@ -5,9 +5,9 @@ import { renderForScrollback } from "../components/image";
5
5
  import { col } from "../native/describe";
6
6
  import type { NativeNode } from "../native/node";
7
7
  import { isNativeSettled, settleNative } from "../native/settle";
8
- import { TERMINAL } from "../terminal-capabilities";
9
8
  import { isUsageRowBlock } from "../overlays/usage-row";
10
9
  import { isToolActivityComponent } from "./tool-activity";
10
+ import { directPlacementLift, ImageProtocol, iterm2ImagePayload, TERMINAL } from "../terminal-capabilities";
11
11
 
12
12
  /** Shared animation time supplied by the constrained transcript root. */
13
13
  export interface AnimationFrame {
@@ -126,6 +126,14 @@ interface RowSplit {
126
126
  readonly width: number;
127
127
  readonly hash: number | bigint;
128
128
  readonly text: string;
129
+ /**
130
+ * Inline images in the committed rows, in order, each with its payload and
131
+ * the length of `text` before it. Their reserved rows carry no content, so
132
+ * relocating by `text` alone would stop above an image and make the
133
+ * archived image live again at another width; payload and offset tell the
134
+ * archived image apart from a replacement or one moved elsewhere.
135
+ */
136
+ readonly images: readonly { readonly payload: string; readonly offset: number }[];
129
137
  /** Seam of the last render it was located in; renders are memoized per frame. */
130
138
  located?: { readonly rendered: readonly string[]; readonly count: number };
131
139
  }
@@ -188,7 +196,33 @@ function contentOf(rows: readonly string[]): string {
188
196
  */
189
197
  function rowSplit(scrollback: readonly string[], rows: number, width: number): RowSplit {
190
198
  const committed = scrollback.slice(0, rows);
191
- return { rows, width, hash: Bun.hash(committed.join("\n")), text: contentOf(committed) };
199
+ const images: { payload: string; offset: number }[] = [];
200
+ let text = "";
201
+ for (const row of committed) {
202
+ if (TERMINAL.isImageLine(row)) images.push({ payload: iterm2ImagePayload(row) ?? row, offset: text.length });
203
+ text += contentOf([row]);
204
+ }
205
+ return { rows, width, hash: Bun.hash(committed.join("\n")), text, images };
206
+ }
207
+
208
+ /**
209
+ * `cut` moved to just below an image it would split, on terminals that cannot
210
+ * draw part of an image (iTerm2's inline images). There the image row draws
211
+ * the whole picture upward over its reserved rows; once some of those rows are
212
+ * in scrollback, the picture has nowhere to go and is dropped, so the image
213
+ * must reach scrollback whole. Kitty and SIXEL draw the visible part instead.
214
+ */
215
+ function cutBelowWholeImage(rows: readonly string[], cut: number): number {
216
+ const protocol = TERMINAL.imageProtocol;
217
+ if (protocol === null || protocol === ImageProtocol.Kitty || protocol === ImageProtocol.Sixel) return cut;
218
+ // Only reserved rows sit between a cut inside an image and its image row,
219
+ // so the first image row at or below the cut decides.
220
+ for (let index = Math.max(0, cut); index < rows.length; index++) {
221
+ const row = rows[index]!;
222
+ if (!TERMINAL.isImageLine(row)) continue;
223
+ return index - directPlacementLift(row) < cut ? index + 1 : cut;
224
+ }
225
+ return cut;
192
226
  }
193
227
 
194
228
  /** Whether `prefix` matches `rows` byte-for-byte from the top. */
@@ -842,13 +876,19 @@ export class TranscriptContainer extends Container {
842
876
  // screen; the rest stays live, so a later shrink of the chrome or the
843
877
  // tail refills the screen from live rows instead of opening a band no
844
878
  // frame can fill (rows still on screen cannot return from scrollback).
879
+ // One exception: on a terminal that cannot draw part of an image, a cut
880
+ // inside an image moves below it (cutBelowWholeImage), so up to that
881
+ // image's height of on-screen rows commits early. A later shrink can then
882
+ // open a band that tall, which beats losing the image.
845
883
  let split: { index: number; rows: readonly string[]; split: RowSplit } | undefined;
846
884
  if (policy === "pressure" && keep > 0 && limit === this.#frontier + keep) {
847
885
  const boundary = live[keep - 1]!;
848
886
  const visible = room - tailRows - (tailRows > 0 ? 1 : 0);
849
887
  if (boundary.entry.state === "settled" && visible > 0) {
850
888
  const offer = this.#splitOffer(boundary.entry, width, visible);
851
- if (offer !== undefined) {
889
+ if (offer === "whole") {
890
+ limit = boundary.index + 1;
891
+ } else if (offer !== undefined) {
852
892
  split = { index: boundary.index, ...offer };
853
893
  limit = boundary.index;
854
894
  }
@@ -891,18 +931,23 @@ export class TranscriptContainer extends Container {
891
931
  * The head rows of settled `entry` beyond its committed prefix that leave
892
932
  * `visible` rows live, or undefined when nothing would remain committed
893
933
  * past that prefix or the scrollback render would not line up row for row.
934
+ * `"whole"` when keeping an image whole moves the cut to the block's end:
935
+ * the block then retires in full, with the blank row that separates it from
936
+ * the next block, which a split never carries.
894
937
  */
895
938
  #splitOffer(
896
939
  entry: TranscriptEntry,
897
940
  width: number,
898
941
  visible: number,
899
- ): { rows: readonly string[]; split: RowSplit } | undefined {
942
+ ): { rows: readonly string[]; split: RowSplit } | "whole" | undefined {
900
943
  const measured = this.#measuredRows(entry, width);
901
944
  const start = this.#emittedRowCount(entry, width);
902
- const total = measured.length - visible;
903
- if (total <= start) return undefined;
945
+ const cut = measured.length - visible;
946
+ if (cut <= start) return undefined;
904
947
  const scrollback = renderForScrollback(() => this.#renderEntry(entry, width));
905
948
  if (scrollback.length !== measured.length) return undefined;
949
+ const total = cutBelowWholeImage(scrollback, cut);
950
+ if (total >= scrollback.length) return "whole";
906
951
  return { rows: scrollback.slice(start, total), split: rowSplit(scrollback, total, width) };
907
952
  }
908
953
 
@@ -1119,13 +1164,40 @@ export class TranscriptContainer extends Container {
1119
1164
  count = split.rows;
1120
1165
  } else {
1121
1166
  let consumed = 0;
1122
- while (count < rendered.length && consumed < split.text.length) {
1123
- const row = contentOf([rendered[count]!]);
1124
- // An empty-content row matches at any offset, so the walk cannot
1125
- // tell it apart from a revealed row history never received.
1126
- if (row.length === 0 || !split.text.startsWith(row, consumed)) break;
1127
- consumed += row.length;
1128
- count++;
1167
+ let image = 0;
1168
+ while (count < rendered.length && (consumed < split.text.length || image < split.images.length)) {
1169
+ const row = rendered[count]!;
1170
+ const content = contentOf([row]);
1171
+ if (content.length > 0) {
1172
+ if (!split.text.startsWith(content, consumed)) break;
1173
+ consumed += content.length;
1174
+ count++;
1175
+ continue;
1176
+ }
1177
+ // An empty-content row matches at any offset, so the walk cannot tell it
1178
+ // apart from a revealed row history never received; only the next
1179
+ // archived image carries the seam on, through its own reserved rows to
1180
+ // its row, whatever its height here.
1181
+ let index = count;
1182
+ while (
1183
+ index < rendered.length &&
1184
+ !TERMINAL.isImageLine(rendered[index]!) &&
1185
+ contentOf([rendered[index]!]).length === 0
1186
+ ) {
1187
+ index++;
1188
+ }
1189
+ const imageRow = rendered[index];
1190
+ if (
1191
+ imageRow === undefined ||
1192
+ !TERMINAL.isImageLine(imageRow) ||
1193
+ (iterm2ImagePayload(imageRow) ?? imageRow) !== split.images[image]?.payload ||
1194
+ consumed !== split.images[image]?.offset ||
1195
+ index - count !== directPlacementLift(imageRow)
1196
+ ) {
1197
+ break;
1198
+ }
1199
+ count = index + 1;
1200
+ image++;
1129
1201
  }
1130
1202
  }
1131
1203
  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;
@@ -1921,6 +1923,7 @@ export class Editor implements Component, Focusable {
1921
1923
  this.onAutocompleteUpdate?.();
1922
1924
  } else {
1923
1925
  this.#autocompleteList.handleInput(data);
1926
+ this.#enterAcceptsSelection = true;
1924
1927
  this.onAutocompleteUpdate?.();
1925
1928
  return;
1926
1929
  }
@@ -1965,8 +1968,11 @@ export class Editor implements Component, Focusable {
1965
1968
  // Check for stale autocomplete state while an async refresh is pending
1966
1969
  const currentLine = this.#state.lines[this.#state.cursorLine] ?? "";
1967
1970
  const currentTextBeforeCursor = currentLine.slice(0, this.#state.cursorCol);
1968
- if (!this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected)) {
1969
- // Autocomplete is stale - cancel and fall through to normal submission
1971
+ if (
1972
+ !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
1973
+ !this.#enterAcceptsSelection
1974
+ ) {
1975
+ // Stale, or a description-only hit the user never selected: submit the typed text.
1970
1976
  this.#cancelAutocomplete();
1971
1977
  } else {
1972
1978
  if (selected && this.#autocompleteProvider) {
@@ -1998,10 +2004,12 @@ export class Editor implements Component, Focusable {
1998
2004
  if (
1999
2005
  !selected ||
2000
2006
  !this.#autocompletePrefixMatchesCursorText(currentTextBeforeCursor, selected) ||
2001
- this.#selectedSlashArgumentIsAlreadyTyped(selected)
2007
+ this.#selectedSlashArgumentIsAlreadyTyped(selected) ||
2008
+ !this.#enterAcceptsSelection
2002
2009
  ) {
2003
- // Stale, or accepting would only append whitespace to an already fully
2004
- // typed slash-command argument (`/mcp list` + Enter): cancel and fall
2010
+ // Stale, accepting would only append whitespace to an already fully
2011
+ // typed slash-command argument (`/mcp list` + Enter), or the row is a
2012
+ // description-only hit the user never selected: cancel and fall
2005
2013
  // through to normal submission instead of swallowing the keypress.
2006
2014
  this.#cancelAutocomplete();
2007
2015
  } else {
@@ -2145,9 +2153,10 @@ export class Editor implements Component, Focusable {
2145
2153
  ) {
2146
2154
  const syncResult = this.#autocompleteProvider.trySyncSlashCompletion(textBeforeCursor);
2147
2155
  // The collapsed `/skill:` namespace row is not a runnable command; Enter submits
2148
- // immediately after applying, so only a real command may be completed here.
2156
+ // immediately after applying, so only a real command may be completed here, and
2157
+ // only one whose name matches: a description-only hit would run an unrelated command.
2149
2158
  const selected = syncResult?.items.find(item => item.value !== SKILL_NAMESPACE);
2150
- if (syncResult && selected) {
2159
+ if (syncResult && selected && !selected.descriptionMatch) {
2151
2160
  // Invalidate any pending async autocomplete so its stale results are discarded
2152
2161
  this.#autocompleteRequestId += 1;
2153
2162
  // Apply the best match and submit the completed command
@@ -4876,6 +4885,7 @@ export class Editor implements Component, Focusable {
4876
4885
  ): void {
4877
4886
  this.#autocompletePrefix = suggestions.prefix;
4878
4887
  this.#autocompleteList = this.#createAutocompleteList(suggestions.prefix, suggestions.items);
4888
+ this.#enterAcceptsSelection = !suggestions.items[0]?.descriptionMatch;
4879
4889
  this.#autocompleteState = state;
4880
4890
  this.onAutocompleteUpdate?.();
4881
4891
  }
@@ -1,5 +1,6 @@
1
1
  import { encodeSixelAsync } from "@linxiraos/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,34 @@
1
+ /**
2
+ * One-row block sparklines (`▁▂▃▅▇█`) for ANSI renderers: the usage history
3
+ * in `zetacode usage`, a job widget's series in the `wait` card and status line.
4
+ */
5
+ /** Block glyphs from lowest to highest. */
6
+ export declare const SPARK_LEVELS: readonly ["▁", "▂", "▃", "▄", "▅", "▆", "▇", "█"];
7
+ /** How {@link renderSparkline} maps values to glyphs and styles them. */
8
+ export interface SparklineOptions {
9
+ /** Domain low end; values at or below it draw the lowest block. Default 0. */
10
+ readonly min?: number;
11
+ /** Domain high end; values at or above it draw the full block. Default the largest value. */
12
+ readonly max?: number;
13
+ /** Styles one glyph; `fraction` is the value's 0–1 position in the domain. */
14
+ readonly style?: (glyph: string, value: number, fraction: number) => string;
15
+ /** What a missing value draws, already styled. Default a space. */
16
+ readonly gap?: string;
17
+ }
18
+ /** The block glyph for a 0–1 fraction of the domain. */
19
+ export declare function sparkGlyph(fraction: number): string;
20
+ /** One cell per value, oldest first; `undefined` (and non-finite) values draw the gap. */
21
+ export declare function renderSparkline(values: readonly (number | undefined)[], options?: SparklineOptions): string;
22
+ /**
23
+ * `values` squeezed into at most `cells` points by equal-count bucket means,
24
+ * newest bucket last; shorter series come back unchanged.
25
+ */
26
+ export declare function fitSparkValues(values: readonly number[], cells: number): readonly number[];
27
+ /**
28
+ * The largest value per time bucket over `[sinceMs, nowMs]`, `cells` buckets
29
+ * wide; buckets without a point are `undefined` (a gap in the sparkline).
30
+ */
31
+ export declare function peakBuckets(points: Iterable<{
32
+ readonly at: number;
33
+ readonly value: number;
34
+ }>, sinceMs: number, nowMs: number, cells: number): (number | undefined)[];
@@ -0,0 +1,89 @@
1
+ /**
2
+ * One-row block sparklines (`▁▂▃▅▇█`) for ANSI renderers: the usage history
3
+ * in `zetacode 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.d.ts CHANGED
@@ -25,6 +25,7 @@ export * from "./components/section";
25
25
  export * from "./components/select-list";
26
26
  export * from "./components/settings-list";
27
27
  export * from "./components/spacer";
28
+ export * from "./components/sparkline";
28
29
  export * from "./components/tab-bar";
29
30
  export * from "./components/table";
30
31
  export * from "./components/text";
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export * from "./components/section";
30
30
  export * from "./components/select-list";
31
31
  export * from "./components/settings-list";
32
32
  export * from "./components/spacer";
33
+ export * from "./components/sparkline";
33
34
  export * from "./components/tab-bar";
34
35
  export * from "./components/table";
35
36
  export * from "./components/text";
@@ -54,6 +54,7 @@ import { getNativeBlob, type NativeBlob } from "./blobs";
54
54
  import { node } from "./describe";
55
55
  import { encodeTspJson, encodeTspMessage, type TspHello, TspReader, splitTspMessage } from "./encode";
56
56
  import type { DescribeContext, NativeChild, NativeNode, NativeSurface, NativeUiEvent } from "./node";
57
+ import { NARROW_SHEET_COLS } from "./overlay";
57
58
  import { nativeComponentId, Reconciler } from "./reconcile";
58
59
  import { setNativeRendering } from "./state";
59
60
 
@@ -244,6 +245,10 @@ class Surface {
244
245
  acked = 0;
245
246
  /** Send times of unacknowledged frames, oldest first. */
246
247
  unacked: number[] = [];
248
+ /** Send time of the oldest frame left unanswered when the current stall began; cleared by the next ack. */
249
+ stalledSince: number | undefined;
250
+ /** Credit resets since {@link stalledSince}. */
251
+ stallResets = 0;
247
252
  focus: string | null = null;
248
253
  dirty = false;
249
254
 
@@ -456,6 +461,8 @@ export class NativeBackend {
456
461
  surface.reconciler.detachLive();
457
462
  surface.unacked = [];
458
463
  surface.acked = surface.seq;
464
+ surface.stalledSince = undefined;
465
+ surface.stallResets = 0;
459
466
  surface.focus = null;
460
467
  surface.dirty = false;
461
468
  this.#write("o", { id: surface.id, mode: "inline", title: "omp", role: SESSION_ROLE, adopt: true });
@@ -612,11 +619,16 @@ export class NativeBackend {
612
619
  if (surface.unacked.length < this.#credits) return true;
613
620
  const oldest = surface.unacked[0]!;
614
621
  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
- });
622
+ // One warning per stall: a terminal that never acks would otherwise log one every `STALLED_ACK_MS`.
623
+ if (surface.stalledSince === undefined) {
624
+ surface.stalledSince = oldest;
625
+ logger.warn("TSP: terminal stopped acknowledging frames; resuming without credits", {
626
+ sf: surface.id,
627
+ s: surface.seq,
628
+ acked: surface.acked,
629
+ });
630
+ }
631
+ surface.stallResets++;
620
632
  surface.unacked = [];
621
633
  surface.acked = surface.seq;
622
634
  return true;
@@ -801,6 +813,15 @@ export class NativeBackend {
801
813
  const newly = Math.min(event.s, surface.seq) - surface.acked;
802
814
  surface.acked = Math.min(event.s, surface.seq);
803
815
  surface.unacked.splice(0, newly);
816
+ if (surface.stalledSince !== undefined) {
817
+ logger.debug("TSP: terminal acknowledging frames again", {
818
+ sf: surface.id,
819
+ stalledMs: this.#scheduler.now() - surface.stalledSince,
820
+ resets: surface.stallResets,
821
+ });
822
+ surface.stalledSince = undefined;
823
+ surface.stallResets = 0;
824
+ }
804
825
  this.#clearStallTimer();
805
826
  if (surface.dirty) this.#host.requestRender();
806
827
  return;
@@ -919,7 +940,12 @@ export class NativeBackend {
919
940
  if (overlay.component.nativeSheet?.(this.#cx)) return overlay.component;
920
941
  const own = overlay.component.nativeOverlay;
921
942
  const anchor = own?.anchor ?? overlayAnchor(overlay.options);
922
- const size = own?.size ?? overlaySize(overlay.options, this.#cx.cols);
943
+ let size = own?.size ?? overlaySize(overlay.options, this.#cx.cols);
944
+ // A centered sheet in a narrow pane fills it instead of floating with margins: `lg`, and a component's own `md`
945
+ // sheet (an undeclared `md` is a generic dialog sized from its options).
946
+ if (anchor === "center" && this.#cx.cols < NARROW_SHEET_COLS && (size === "lg" || own?.size === "md")) {
947
+ size = "full";
948
+ }
923
949
  const modal = overlay.focused || overlay.options?.fullscreen === true;
924
950
  const role = own?.role;
925
951
  const head = own?.head;
@@ -9,7 +9,7 @@
9
9
  * ]);
10
10
  * }
11
11
  */
12
- import type { TspKind, TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
12
+ import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
14
  /** A node of any kind. */
15
15
  export declare function node<K extends TspKind>(k: K, p?: TspProps<K>, c?: readonly NativeChild[], key?: string): NativeNode;
@@ -37,6 +37,12 @@ export declare const EMPTY_NODE: NativeNode;
37
37
  export declare function keyed(described: NativeNode, key: string): NativeNode;
38
38
  /** `described` mounted but not laid out when `hidden` (cheap visibility toggles keep ids and view state). */
39
39
  export declare function withHidden(described: NativeNode, hidden: boolean): NativeNode;
40
+ /**
41
+ * A described tree as the node tree a prop carries (a `hovercard`): plain
42
+ * nodes only, since nothing reconciles inside a prop; component children
43
+ * (which describe themselves per frame) are left out.
44
+ */
45
+ export declare function propNode(described: NativeNode): TspPropNode;
40
46
  /** Stable node key for an arbitrary identity string; keys join into `/` keypaths, so identities are hashed. */
41
47
  export declare function stableKey(identity: string): string;
42
48
  /** Drop absent entries (`undefined`, `null`, `false`) from a child list. */
@@ -9,7 +9,7 @@
9
9
  * ]);
10
10
  * }
11
11
  */
12
- import type { TspKind, TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
12
+ import type { TspKind, TspPropNode, TspProps, TspSpan, TspText } from "@linxiraos/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);
@@ -10,6 +10,8 @@ import type { TspProps, TspSpan, TspText } from "@linxiraos/pi-wire";
10
10
  import { type KeyName } from "../key-hint-format";
11
11
  import { type Keybinding } from "../keybindings";
12
12
  import type { NativeChild, NativeNode } from "./node";
13
+ /** Below this many columns a centered sheet fills the pane instead of floating with margins. */
14
+ export declare const NARROW_SHEET_COLS = 100;
13
15
  /** One footer hint: the keys that trigger it and what it does. */
14
16
  export interface NativeHint {
15
17
  /** Key ids (`"ctrl+o"`, `"escape"`); alternatives render side by side. */
@@ -12,6 +12,9 @@ import { getKeybindings, type Keybinding } from "../keybindings";
12
12
  import { card, kbd, list, node, row, span, text } from "./describe";
13
13
  import type { NativeChild, NativeNode } from "./node";
14
14
 
15
+ /** Below this many columns a centered sheet fills the pane instead of floating with margins. */
16
+ export const NARROW_SHEET_COLS = 100;
17
+
15
18
  /** One footer hint: the keys that trigger it and what it does. */
16
19
  export interface NativeHint {
17
20
  /** Key ids (`"ctrl+o"`, `"escape"`); alternatives render side by side. */