@linxiraos/pi-tui 1.4.0 → 1.4.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +178 -182
- package/dist/types/autocomplete.d.ts +6 -0
- 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/overlay.d.ts +2 -0
- 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.d.ts +6 -0
- package/src/autocomplete.ts +21 -6
- package/src/chat/skill-message.ts +16 -17
- package/src/chat/tool-execution.ts +35 -0
- package/src/chrome/transcript-container.ts +85 -13
- 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.d.ts +34 -0
- package/src/components/sparkline.ts +89 -0
- package/src/index.d.ts +1 -0
- package/src/index.ts +1 -0
- package/src/native/backend.ts +32 -6
- package/src/native/describe.d.ts +7 -1
- package/src/native/describe.ts +18 -1
- package/src/native/overlay.d.ts +2 -0
- package/src/native/overlay.ts +3 -0
- package/src/native/tone.d.ts +2 -0
- 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/agents-hub.ts +2 -1
- package/src/overlays/approval-dialog.ts +59 -13
- package/src/overlays/hub-frame.ts +2 -1
- package/src/overlays/jobs-panel.ts +24 -2
- package/src/overlays/model-browser.ts +3 -0
- package/src/overlays/model-hub.ts +2 -1
- package/src/prompt/attachment-chips.ts +1 -1
- package/src/render/output-block.d.ts +2 -0
- package/src/render/output-block.ts +25 -12
- package/src/render/tool-card.d.ts +2 -0
- package/src/render/tool-card.ts +6 -0
- package/src/sixel.d.ts +28 -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.d.ts +5 -0
- package/src/terminal-capabilities.ts +43 -3
- package/src/theme/symbols.ts +5 -3
- package/src/tools/bash.d.ts +2 -0
- package/src/tools/bash.ts +25 -9
- package/src/tools/default-renderer.d.ts +3 -0
- package/src/tools/default-renderer.ts +54 -17
- package/src/tools/eval.d.ts +9 -8
- 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 +2 -4
- package/src/tools/irc.d.ts +6 -0
- package/src/tools/irc.ts +6 -0
- package/src/tools/job-widget.d.ts +226 -0
- package/src/tools/job-widget.ts +1036 -0
- package/src/tools/json-tree.d.ts +2 -0
- package/src/tools/json-tree.ts +8 -0
- package/src/tools/line-ranges.d.ts +8 -1
- package/src/tools/line-ranges.ts +23 -13
- package/src/tools/mcp.d.ts +23 -0
- package/src/tools/mcp.ts +44 -1
- package/src/tools/output-meta.d.ts +1 -1
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/read.d.ts +8 -2
- package/src/tools/read.ts +64 -21
- package/src/tools/renderer.d.ts +8 -2
- package/src/tools/renderer.ts +8 -2
- package/src/tools/result-card.d.ts +1 -1
- package/src/tools/result-card.ts +1 -2
- package/src/tools/streaming-output.d.ts +1 -0
- package/src/tools/streaming-output.ts +11 -9
- package/src/tools/task.d.ts +12 -0
- package/src/tools/task.ts +25 -1
- package/src/tools/wait.d.ts +5 -0
- package/src/tools/wait.ts +290 -147
- package/src/tui.ts +45 -5
package/src/autocomplete.d.ts
CHANGED
|
@@ -39,6 +39,12 @@ export interface AutocompleteItem {
|
|
|
39
39
|
state?: string;
|
|
40
40
|
/** Dim hint text shown inline after cursor when this item is selected */
|
|
41
41
|
hint?: string;
|
|
42
|
+
/**
|
|
43
|
+
* Slash command matched only through its description, not its name or an alias:
|
|
44
|
+
* a browse hit, so Enter submits the typed text instead of running it unless the
|
|
45
|
+
* user moved the selection onto it.
|
|
46
|
+
*/
|
|
47
|
+
descriptionMatch?: boolean;
|
|
42
48
|
}
|
|
43
49
|
type Awaitable<T> = T | Promise<T>;
|
|
44
50
|
export interface SlashCommand {
|
package/src/autocomplete.ts
CHANGED
|
@@ -226,6 +226,12 @@ export interface AutocompleteItem {
|
|
|
226
226
|
state?: string;
|
|
227
227
|
/** Dim hint text shown inline after cursor when this item is selected */
|
|
228
228
|
hint?: string;
|
|
229
|
+
/**
|
|
230
|
+
* Slash command matched only through its description, not its name or an alias:
|
|
231
|
+
* a browse hit, so Enter submits the typed text instead of running it unless the
|
|
232
|
+
* user moved the selection onto it.
|
|
233
|
+
*/
|
|
234
|
+
descriptionMatch?: boolean;
|
|
229
235
|
}
|
|
230
236
|
|
|
231
237
|
type Awaitable<T> = T | Promise<T>;
|
|
@@ -332,14 +338,18 @@ function getCommandAliases(cmd: CommandEntry): string[] {
|
|
|
332
338
|
}
|
|
333
339
|
|
|
334
340
|
function getStaticCommandDescription(cmd: CommandEntry): string {
|
|
335
|
-
|
|
341
|
+
const raw: unknown = cmd.description;
|
|
342
|
+
const description = typeof raw === "function" ? (raw as () => string)() : raw;
|
|
343
|
+
return typeof description === "string" ? description : "";
|
|
336
344
|
}
|
|
337
345
|
|
|
338
346
|
function getAutocompleteCommandDescription(cmd: CommandEntry): string {
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
347
|
+
const candidate: unknown =
|
|
348
|
+
"getAutocompleteDescription" in cmd && typeof cmd.getAutocompleteDescription === "function"
|
|
349
|
+
? cmd.getAutocompleteDescription()
|
|
350
|
+
: cmd.description;
|
|
351
|
+
const description = typeof candidate === "function" ? (candidate as () => string)() : candidate;
|
|
352
|
+
return typeof description === "string" && description.length > 0 ? description : getStaticCommandDescription(cmd);
|
|
343
353
|
}
|
|
344
354
|
|
|
345
355
|
/**
|
|
@@ -437,6 +447,7 @@ function buildSlashCommandCompletions(
|
|
|
437
447
|
...(cmd.iconName && { iconName: cmd.iconName }),
|
|
438
448
|
...(fullDesc && { description: fullDesc }),
|
|
439
449
|
...nativeTextMemo,
|
|
450
|
+
...(nameScore === 0 && { descriptionMatch: true }),
|
|
440
451
|
};
|
|
441
452
|
}
|
|
442
453
|
|
|
@@ -444,7 +455,11 @@ function buildSlashCommandCompletions(
|
|
|
444
455
|
for (const alias of getCommandAliases(cmd)) {
|
|
445
456
|
if (alias === name) continue;
|
|
446
457
|
const aliasScore = scoreCommandTextMatch(lowerPrefix, alias.toLowerCase());
|
|
447
|
-
if (aliasScore === 0
|
|
458
|
+
if (aliasScore === 0) continue;
|
|
459
|
+
// An alias can match even when the description outranks it; Enter
|
|
460
|
+
// must still accept the command rather than submit the raw query.
|
|
461
|
+
if (best?.descriptionMatch) delete best.descriptionMatch;
|
|
462
|
+
if (best && aliasScore <= best.score) continue;
|
|
448
463
|
const fullDesc = resolveFullDesc();
|
|
449
464
|
best = {
|
|
450
465
|
value: alias,
|
|
@@ -14,8 +14,8 @@ import { collapseSkillTokens, skillChipLabel, skillChipStyle, skillToken } from
|
|
|
14
14
|
import { tuiText, tuiTextFmt } from "../i18n";
|
|
15
15
|
import { type UserBubbleOptions, UserMessageComponent, userBubbleColor } from "./user-message";
|
|
16
16
|
import type { TspSpan } from "@linxiraos/pi-wire";
|
|
17
|
-
import { card, md, node, span
|
|
18
|
-
import { type NativeChild, type NativeNode, type NativeUiEvent
|
|
17
|
+
import { card, md, node, span } from "../native/describe";
|
|
18
|
+
import { type NativeChild, type NativeNode, type NativeUiEvent } from "../native/node";
|
|
19
19
|
import { Memo } from "../native/memo";
|
|
20
20
|
|
|
21
21
|
/**
|
|
@@ -64,8 +64,7 @@ export class SkillMessageComponent extends Container {
|
|
|
64
64
|
}
|
|
65
65
|
|
|
66
66
|
handleNativeEvent(event: NativeUiEvent): void {
|
|
67
|
-
|
|
68
|
-
if (expanded !== undefined) this.setExpanded(expanded);
|
|
67
|
+
if (event.type === "toggle" && event.key === "prompt") this.setExpanded(!event.collapsed);
|
|
69
68
|
}
|
|
70
69
|
|
|
71
70
|
/**
|
|
@@ -101,20 +100,20 @@ export class SkillMessageComponent extends Container {
|
|
|
101
100
|
const children: NativeChild[] = [];
|
|
102
101
|
if (draft) children.push(md(draft));
|
|
103
102
|
const promptText = this.#expanded ? this.#extractText() : "";
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
children.length > 0 ? children : [text([span(label, "muted")])],
|
|
103
|
+
children.push(
|
|
104
|
+
node(
|
|
105
|
+
"section",
|
|
106
|
+
{
|
|
107
|
+
head: [span("prompt", "muted")],
|
|
108
|
+
role: "zeta.skill.prompt",
|
|
109
|
+
collapsible: true,
|
|
110
|
+
collapsed: !this.#expanded,
|
|
111
|
+
},
|
|
112
|
+
promptText ? [md(promptText)] : [],
|
|
113
|
+
"prompt",
|
|
114
|
+
),
|
|
117
115
|
);
|
|
116
|
+
return card({ role: "zeta.skill", tone: "user", head }, children);
|
|
118
117
|
});
|
|
119
118
|
}
|
|
120
119
|
|
|
@@ -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
|
-
|
|
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
|
|
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
|
|
903
|
-
if (
|
|
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
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
if (
|
|
1127
|
-
|
|
1128
|
-
|
|
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 };
|
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;
|
|
@@ -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 (
|
|
1969
|
-
|
|
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,
|
|
2004
|
-
// typed slash-command argument (`/mcp list` + Enter)
|
|
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
|
}
|
package/src/components/image.ts
CHANGED
|
@@ -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
|
|
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,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";
|