@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.
- package/CHANGELOG.md +44 -48
- package/dist/types/autocomplete.d.ts +6 -0
- package/dist/types/chat/skill-message.d.ts +3 -4
- package/dist/types/components/sparkline.d.ts +34 -0
- package/dist/types/index.d.ts +1 -0
- package/dist/types/native/describe.d.ts +7 -1
- package/dist/types/native/tone.d.ts +2 -0
- package/dist/types/overlays/agent-hub.d.ts +3 -3
- package/dist/types/overlays/approval-dialog.d.ts +15 -1
- package/dist/types/overlays/jobs-panel.d.ts +3 -0
- package/dist/types/overlays/model-browser.d.ts +1 -1
- package/dist/types/render/output-block.d.ts +2 -0
- package/dist/types/render/tool-card.d.ts +2 -0
- package/dist/types/sixel.d.ts +28 -0
- package/dist/types/status-line/host.d.ts +3 -0
- package/dist/types/terminal-capabilities.d.ts +5 -0
- package/dist/types/tools/bash.d.ts +2 -0
- package/dist/types/tools/default-renderer.d.ts +3 -0
- package/dist/types/tools/eval.d.ts +9 -8
- package/dist/types/tools/irc.d.ts +6 -0
- package/dist/types/tools/job-widget.d.ts +226 -0
- package/dist/types/tools/json-tree.d.ts +2 -0
- package/dist/types/tools/line-ranges.d.ts +8 -1
- package/dist/types/tools/mcp.d.ts +23 -0
- package/dist/types/tools/output-meta.d.ts +1 -1
- package/dist/types/tools/read.d.ts +8 -2
- package/dist/types/tools/renderer.d.ts +8 -2
- package/dist/types/tools/result-card.d.ts +1 -1
- package/dist/types/tools/streaming-output.d.ts +1 -0
- package/dist/types/tools/task.d.ts +12 -0
- package/dist/types/tools/wait.d.ts +5 -0
- package/package.json +9 -9
- package/src/autocomplete.ts +12 -1
- package/src/chat/skill-message.ts +19 -21
- package/src/chat/tool-execution.ts +35 -0
- package/src/chrome/transcript-container.ts +85 -12
- package/src/components/editor.ts +17 -7
- package/src/components/image.ts +5 -4
- package/src/components/markdown.ts +11 -3
- package/src/components/sparkline.ts +89 -0
- package/src/index.ts +1 -0
- package/src/native/backend.ts +25 -5
- package/src/native/describe.ts +18 -1
- package/src/native/tone.ts +22 -0
- package/src/overlays/agent-hub.ts +14 -10
- package/src/overlays/agent-transcript-viewer.ts +2 -2
- package/src/overlays/approval-dialog.ts +59 -13
- package/src/overlays/jobs-panel.ts +24 -2
- package/src/overlays/model-browser.ts +3 -0
- package/src/prompt/attachment-chips.ts +1 -1
- package/src/render/output-block.ts +25 -12
- package/src/render/tool-card.ts +6 -0
- package/src/sixel.ts +185 -0
- package/src/status-line/component.ts +19 -2
- package/src/status-line/host.ts +3 -0
- package/src/terminal-capabilities.ts +43 -3
- package/src/theme/symbols.ts +5 -3
- package/src/tools/bash.ts +25 -9
- package/src/tools/default-renderer.ts +54 -17
- package/src/tools/eval.ts +32 -14
- package/src/tools/find.ts +4 -5
- package/src/tools/glob.ts +4 -6
- package/src/tools/grep.ts +4 -6
- package/src/tools/irc.ts +6 -0
- package/src/tools/job-widget.ts +1036 -0
- package/src/tools/json-tree.ts +8 -0
- package/src/tools/line-ranges.ts +23 -13
- package/src/tools/mcp.ts +44 -1
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/read.ts +64 -21
- package/src/tools/renderer.ts +8 -2
- package/src/tools/result-card.ts +1 -2
- package/src/tools/streaming-output.ts +11 -9
- package/src/tools/task.ts +25 -1
- package/src/tools/wait.ts +288 -147
- 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
|
-
|
|
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
|
|
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
|
|
880
|
-
if (
|
|
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
|
-
|
|
1100
|
-
|
|
1101
|
-
|
|
1102
|
-
|
|
1103
|
-
if (
|
|
1104
|
-
|
|
1105
|
-
|
|
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 };
|
package/src/components/editor.ts
CHANGED
|
@@ -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 (
|
|
1964
|
-
|
|
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,
|
|
1999
|
-
// typed slash-command argument (`/mcp list` + Enter)
|
|
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
|
}
|
package/src/components/image.ts
CHANGED
|
@@ -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
|
|
849
|
-
//
|
|
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
|
-
|
|
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 =
|
|
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";
|
package/src/native/backend.ts
CHANGED
|
@@ -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
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
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;
|
package/src/native/describe.ts
CHANGED
|
@@ -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);
|
package/src/native/tone.ts
CHANGED
|
@@ -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
|
|
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:
|
|
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
|
-
//
|
|
2502
|
-
//
|
|
2503
|
-
if (ref.kind === "advisor" ||
|
|
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
|
|
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
|
|
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"
|
|
241
|
+
if (!ref || ref.kind === "advisor") return false;
|
|
242
242
|
return Boolean(this.#deps.remote || this.#deps.lifecycle);
|
|
243
243
|
}
|
|
244
244
|
|