@oh-my-pi/pi-tui 18.12.2 → 18.12.3
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +17 -0
- 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/sixel.d.ts +28 -0
- package/dist/types/status-line/host.d.ts +3 -0
- package/dist/types/tools/irc.d.ts +6 -0
- package/dist/types/tools/job-widget.d.ts +226 -0
- package/dist/types/tools/output-meta.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/components/editor.ts +17 -7
- package/src/components/image.ts +5 -4
- package/src/components/sparkline.ts +89 -0
- package/src/index.ts +1 -0
- 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 +10 -0
- package/src/overlays/model-browser.ts +3 -0
- package/src/prompt/attachment-chips.ts +1 -1
- 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 +4 -1
- package/src/theme/symbols.ts +5 -3
- package/src/tools/irc.ts +6 -0
- package/src/tools/job-widget.ts +1036 -0
- package/src/tools/output-meta.ts +1 -1
- package/src/tools/streaming-output.ts +11 -9
- package/src/tools/task.ts +25 -1
- package/src/tools/wait.ts +141 -85
- package/src/tui.ts +45 -5
package/src/tools/output-meta.ts
CHANGED
|
@@ -87,7 +87,7 @@ export interface OutputMeta {
|
|
|
87
87
|
/** Capture failure of this output itself; aggregate reports keep source failures on their entries. */
|
|
88
88
|
artifactError?: OutputArtifactError;
|
|
89
89
|
source?: SourceMeta;
|
|
90
|
-
/** The output is a bounded page of session artifact
|
|
90
|
+
/** The output is a bounded page of a file or session artifact its `source` re-reads with line selectors; spill-to-artifact skips it. */
|
|
91
91
|
pagedSource?: true;
|
|
92
92
|
diagnostics?: DiagnosticMeta;
|
|
93
93
|
limits?: LimitsMeta;
|
|
@@ -1432,6 +1432,7 @@ export class OutputSink {
|
|
|
1432
1432
|
* Replace the in-memory buffer with the given text. Used when an upstream
|
|
1433
1433
|
* minimizer rewrites the captured output after the raw bytes have already
|
|
1434
1434
|
* been streamed.
|
|
1435
|
+
* The replacement is held to the per-line column cap like streamed output.
|
|
1435
1436
|
*
|
|
1436
1437
|
* After this call the buffer is authoritative: streaming counters realign
|
|
1437
1438
|
* to the replacement, the retained head window is cleared, and head
|
|
@@ -1442,23 +1443,24 @@ export class OutputSink {
|
|
|
1442
1443
|
*/
|
|
1443
1444
|
replace(text: string): void {
|
|
1444
1445
|
this.#clearPendingChunkTimer();
|
|
1445
|
-
this.#
|
|
1446
|
-
this.#
|
|
1446
|
+
this.#currentLineBytes = 0;
|
|
1447
|
+
this.#columnEllipsisAdded = false;
|
|
1448
|
+
this.#columnDroppedBytes = 0;
|
|
1449
|
+
this.#columnTruncatedLines = 0;
|
|
1450
|
+
// The artifact keeps the replaced stream, so later line numbers no longer match it.
|
|
1451
|
+
this.#columnCutLinesUnmapped = true;
|
|
1452
|
+
const capped = this.#maxColumns > 0 ? this.#applyColumnCap(text, 0) : text;
|
|
1453
|
+
this.#buffer = capped;
|
|
1454
|
+
this.#bufferBytes = Buffer.byteLength(capped, "utf-8");
|
|
1447
1455
|
this.#tailUntrimmed = false;
|
|
1448
1456
|
this.#head = "";
|
|
1449
1457
|
this.#headBytes = 0;
|
|
1450
1458
|
this.#headLines = 0;
|
|
1451
1459
|
this.#headRetentionDisabled = true;
|
|
1452
|
-
this.#totalBytes =
|
|
1460
|
+
this.#totalBytes = Buffer.byteLength(text, "utf-8");
|
|
1453
1461
|
this.#totalLines = countNewlines(text);
|
|
1454
1462
|
this.#sawData = text.length > 0;
|
|
1455
1463
|
this.#truncated = false;
|
|
1456
|
-
this.#currentLineBytes = 0;
|
|
1457
|
-
this.#columnEllipsisAdded = false;
|
|
1458
|
-
this.#columnDroppedBytes = 0;
|
|
1459
|
-
this.#columnTruncatedLines = 0;
|
|
1460
|
-
// The artifact keeps the replaced stream, so later line numbers no longer match it.
|
|
1461
|
-
this.#columnCutLinesUnmapped = true;
|
|
1462
1464
|
this.#pendingChunk = "";
|
|
1463
1465
|
this.#pendingCarriageReturn = false;
|
|
1464
1466
|
}
|
package/src/tools/task.ts
CHANGED
|
@@ -764,6 +764,15 @@ function renderAgentProgress(
|
|
|
764
764
|
|
|
765
765
|
// Current tool (if running) or most recent completed tool
|
|
766
766
|
if (progress.status === "running") {
|
|
767
|
+
if (progress.stage) {
|
|
768
|
+
lines.push(
|
|
769
|
+
truncateTaskRow(
|
|
770
|
+
`${continuePrefix}${theme.fg("muted", replaceTabs(sanitizeText(progress.stage)))}`,
|
|
771
|
+
maxWidth,
|
|
772
|
+
"",
|
|
773
|
+
),
|
|
774
|
+
);
|
|
775
|
+
}
|
|
767
776
|
if (progress.currentTool) {
|
|
768
777
|
let toolLine = `${continuePrefix}${theme.tree.hook} ${theme.fg("muted", sanitizeText(progress.currentTool))}`;
|
|
769
778
|
const toolDetail = toolCallDetail(
|
|
@@ -1873,6 +1882,9 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
|
|
|
1873
1882
|
age: progress.currentToolStartMs ? Math.max(0, nowMs - progress.currentToolStartMs) : undefined,
|
|
1874
1883
|
}
|
|
1875
1884
|
: null;
|
|
1885
|
+
const summary = taskSummary(progress.description || progress.assignment || progress.task);
|
|
1886
|
+
// The agent's own stage line (from its completion probe) rides beside the task while it runs.
|
|
1887
|
+
const stage = running && progress.stage ? plainText(progress.stage) : "";
|
|
1876
1888
|
const retry =
|
|
1877
1889
|
running && progress.retryState
|
|
1878
1890
|
? {
|
|
@@ -1887,7 +1899,7 @@ function describeProgressAgent(progress: AgentProgress, state: AgentDescribeStat
|
|
|
1887
1899
|
{
|
|
1888
1900
|
name: formatTaskId(progress.id),
|
|
1889
1901
|
agent: progress.agent || undefined,
|
|
1890
|
-
task:
|
|
1902
|
+
task: stage ? (summary ? [span(summary), span(` · ${stage}`, "muted")] : stage) : summary || undefined,
|
|
1891
1903
|
status,
|
|
1892
1904
|
model: progress.resolvedModelIdentity ?? progress.resolvedModel,
|
|
1893
1905
|
thinking: thinkingToken(progress.resolvedThinkingLevel),
|
|
@@ -2383,6 +2395,18 @@ export interface AgentProgress {
|
|
|
2383
2395
|
advisor?: boolean;
|
|
2384
2396
|
/** The agent's latest self-estimate of task completion (0–100), from the periodic `task.completionProbe` side request. */
|
|
2385
2397
|
completionPercent?: number;
|
|
2398
|
+
/**
|
|
2399
|
+
* The agent's current step in its own words (one line, ≤ 80 chars), from the
|
|
2400
|
+
* same probe reply as {@link completionPercent}. Running rows prefer it over
|
|
2401
|
+
* `lastIntent`; the job widget uses it as its stage line.
|
|
2402
|
+
*/
|
|
2403
|
+
stage?: string;
|
|
2404
|
+
/**
|
|
2405
|
+
* What the agent says shows its progress live (one line, ≤ 200 chars), e.g.
|
|
2406
|
+
* "BENCH lines in bg_2 pipe:1, tok_s higher is better, target 70% of roofline";
|
|
2407
|
+
* from the probe reply. Feeds the job widget author's brief; never rendered.
|
|
2408
|
+
*/
|
|
2409
|
+
watch?: string;
|
|
2386
2410
|
/** Data extracted by registered subprocess tool handlers (keyed by tool name) */
|
|
2387
2411
|
extractedToolData?: Record<string, unknown[]>;
|
|
2388
2412
|
/**
|
package/src/tools/wait.ts
CHANGED
|
@@ -24,10 +24,11 @@ import {
|
|
|
24
24
|
type ConfiguredThinkingLevel,
|
|
25
25
|
} from "../render/render-utils";
|
|
26
26
|
import type { StructuredSubagentOutput } from "./task";
|
|
27
|
+
import { describeJobTiles, type JobTile, type JobWidgetView, renderJobTileLines } from "./job-widget";
|
|
27
28
|
import type { RenderResultOptions, ToolRenderer, ToolActivitySummary } from "./renderer";
|
|
28
29
|
import type { IrcDeliveryReceipt, IrcMessage } from "./irc";
|
|
29
30
|
import type { TspSpan } from "@oh-my-pi/pi-wire";
|
|
30
|
-
import { card as cardNode, compact, elapsed, md, node, row, span, text } from "../native/describe";
|
|
31
|
+
import { card as cardNode, compact, elapsed, keyed, md, node, row, span, text } from "../native/describe";
|
|
31
32
|
import type { NativeNode } from "../native/node";
|
|
32
33
|
import { OwnerMemo } from "../native/memo";
|
|
33
34
|
import { plainText } from "../native/spans";
|
|
@@ -63,6 +64,10 @@ export interface JobSnapshot {
|
|
|
63
64
|
advisor?: boolean;
|
|
64
65
|
/** The task agent's latest self-estimated completion (0–100); see `AgentProgress.completionPercent`. */
|
|
65
66
|
completionPercent?: number;
|
|
67
|
+
/** The task agent's latest stage in its own words; see `AgentProgress.stage`. */
|
|
68
|
+
stage?: string;
|
|
69
|
+
/** The job's live widget, while one is being drawn; frozen at its last state once the job settles. */
|
|
70
|
+
widget?: JobWidgetView;
|
|
66
71
|
resultText?: string;
|
|
67
72
|
errorText?: string;
|
|
68
73
|
/** Source-output metadata retained for per-job warnings and persisted row rendering. */
|
|
@@ -194,6 +199,40 @@ function flattenStructuredPreview(text: string): string {
|
|
|
194
199
|
return text.slice(0, PREVIEW_LINES_EXPANDED * PREVIEW_LINE_WIDTH * 2).replace(/\s+/g, " ");
|
|
195
200
|
}
|
|
196
201
|
|
|
202
|
+
/** The row label: a running task's own stage line when it reports one, else the job's label. */
|
|
203
|
+
function jobRowLabel(job: JobSnapshot): string {
|
|
204
|
+
return job.type === "task" && job.status === "running" && job.stage?.trim() ? job.stage : job.label;
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/** A job as its tile; `live` while the card's clocks run. */
|
|
208
|
+
function jobTile(job: JobSnapshot, live: boolean): JobTile {
|
|
209
|
+
return {
|
|
210
|
+
id: job.id,
|
|
211
|
+
type: job.type,
|
|
212
|
+
status: job.status,
|
|
213
|
+
label: job.label,
|
|
214
|
+
durationMs: job.durationMs,
|
|
215
|
+
live: live && job.status === "running",
|
|
216
|
+
...(job.widget ? { widget: job.widget } : {}),
|
|
217
|
+
...(job.status === "running" && job.completionPercent !== undefined
|
|
218
|
+
? { completionPercent: job.completionPercent }
|
|
219
|
+
: {}),
|
|
220
|
+
...(job.stage ? { stage: job.stage } : {}),
|
|
221
|
+
...(job.exitCode === undefined ? {} : { exitCode: job.exitCode }),
|
|
222
|
+
};
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
/** Identity of the widgets a job list draws: changes whenever any widget's data or state does. */
|
|
226
|
+
function widgetStamp(jobs: readonly JobSnapshot[] | undefined): string {
|
|
227
|
+
if (!jobs) return "";
|
|
228
|
+
let stamp = "";
|
|
229
|
+
for (const job of jobs) if (job.widget) stamp += `${job.id}:${job.widget.updatedAt}:${job.widget.state};`;
|
|
230
|
+
return stamp;
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
/** Tiles a native wait card shows before it counts the rest. */
|
|
234
|
+
const TILES_SHOWN = 9;
|
|
235
|
+
|
|
197
236
|
/** Pending wait frame. */
|
|
198
237
|
function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: Theme): Component {
|
|
199
238
|
return new Text(renderStatusLine({ icon: "pending", title: "Wait" }, uiTheme), 0, 0);
|
|
@@ -201,7 +240,11 @@ function waitRenderCall(_args: object, _options: RenderResultOptions, uiTheme: T
|
|
|
201
240
|
|
|
202
241
|
/** Result frame for wait snapshots and the agents roster. */
|
|
203
242
|
function jobsRenderResult(
|
|
204
|
-
result: {
|
|
243
|
+
result: {
|
|
244
|
+
content: Array<{ type: string; text?: string }>;
|
|
245
|
+
details?: CoordinationDetails;
|
|
246
|
+
isError?: boolean;
|
|
247
|
+
},
|
|
205
248
|
options: RenderResultOptions,
|
|
206
249
|
uiTheme: Theme,
|
|
207
250
|
): Component {
|
|
@@ -326,6 +369,7 @@ function jobsRenderResult(
|
|
|
326
369
|
.u32(spinnerFrame)
|
|
327
370
|
.bool(shimmerActive)
|
|
328
371
|
.bool(showModelBadge)
|
|
372
|
+
.str(widgetStamp(jobs))
|
|
329
373
|
.digest();
|
|
330
374
|
if (!shimmerActive && cached?.key === key) return cached.lines;
|
|
331
375
|
|
|
@@ -354,7 +398,8 @@ function jobsRenderResult(
|
|
|
354
398
|
Math.max(0, rowWidth - visibleWidth(`${icon} ${typeBadge} ${durationSuffix}`)),
|
|
355
399
|
Ellipsis.Unicode,
|
|
356
400
|
);
|
|
357
|
-
const
|
|
401
|
+
const rowLabel = jobRowLabel(job);
|
|
402
|
+
const rawLabelLines = (rowLabel || "(no label)").split(/\r?\n/);
|
|
358
403
|
const maxLabelLines = expanded ? LABEL_LINES_EXPANDED : LABEL_LINES_COLLAPSED;
|
|
359
404
|
const visibleLabelLines = rawLabelLines
|
|
360
405
|
.slice(0, maxLabelLines)
|
|
@@ -364,44 +409,63 @@ function jobsRenderResult(
|
|
|
364
409
|
visibleLabelLines[visibleLabelLines.length - 1] = `${last} …`;
|
|
365
410
|
}
|
|
366
411
|
const rowPrefix = `${icon} ${typeBadge} `;
|
|
367
|
-
const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
|
|
368
|
-
const modelBadge =
|
|
369
|
-
job.type === "task" && showModelBadge && typeof modelIdentity === "string"
|
|
370
|
-
? formatFeedModelBadge(
|
|
371
|
-
modelIdentity,
|
|
372
|
-
job.resolvedThinkingLevel,
|
|
373
|
-
job.advisor === true,
|
|
374
|
-
uiTheme,
|
|
375
|
-
Math.min(
|
|
376
|
-
FEED_MODEL_BADGE_WIDTH,
|
|
377
|
-
Math.max(0, rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1),
|
|
378
|
-
),
|
|
379
|
-
)
|
|
380
|
-
: "";
|
|
381
|
-
const modelLead = modelBadge ? `${modelBadge} ` : "";
|
|
382
|
-
const headRaw = displayId;
|
|
383
|
-
// Running rows in a live block shimmer their label; once the block
|
|
384
|
-
// stops animating (sealed, or a settled snapshot — spinnerFrame
|
|
385
|
-
// cleared) they render static so scrollback never keeps a mid-sweep
|
|
386
|
-
// shimmer band.
|
|
387
412
|
const live = job.status === "running" && options.spinnerFrame !== undefined;
|
|
388
|
-
const headLabel = live
|
|
389
|
-
? shimmerEnabled()
|
|
390
|
-
? shimmerText(headRaw, uiTheme)
|
|
391
|
-
: uiTheme.fg("accent", headRaw)
|
|
392
|
-
: uiTheme.fg("toolOutput", headRaw);
|
|
393
|
-
let row = `${rowPrefix}${modelLead}${headLabel}`;
|
|
394
|
-
const distinctLabel = job.label.trim() !== job.id;
|
|
395
|
-
const label = visibleLabelLines[0] ?? "";
|
|
396
|
-
const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
|
|
397
|
-
if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
|
|
398
|
-
row += durationSuffix;
|
|
399
|
-
lines.push(truncateToWidth(row, rowWidth, ""));
|
|
400
413
|
const continuationWidth = Math.max(0, rowWidth - visibleWidth(" "));
|
|
401
|
-
|
|
414
|
+
if (job.widget?.state === "live") {
|
|
415
|
+
const meta = compact([
|
|
416
|
+
job.status === "running" && job.completionPercent !== undefined
|
|
417
|
+
? uiTheme.fg("accent", `${job.completionPercent}%`)
|
|
418
|
+
: undefined,
|
|
419
|
+
uiTheme.fg("dim", formatDuration(job.durationMs)),
|
|
420
|
+
]).join(" ");
|
|
402
421
|
lines.push(
|
|
403
|
-
|
|
422
|
+
...renderJobTileLines(jobTile(job, live), rowWidth, uiTheme, {
|
|
423
|
+
lead: rowPrefix,
|
|
424
|
+
meta,
|
|
425
|
+
full: expanded,
|
|
426
|
+
}),
|
|
404
427
|
);
|
|
428
|
+
} else {
|
|
429
|
+
const modelIdentity = job.resolvedModelIdentity ?? job.resolvedModel;
|
|
430
|
+
const modelBadge =
|
|
431
|
+
job.type === "task" && showModelBadge && typeof modelIdentity === "string"
|
|
432
|
+
? formatFeedModelBadge(
|
|
433
|
+
modelIdentity,
|
|
434
|
+
job.resolvedThinkingLevel,
|
|
435
|
+
job.advisor === true,
|
|
436
|
+
uiTheme,
|
|
437
|
+
Math.min(
|
|
438
|
+
FEED_MODEL_BADGE_WIDTH,
|
|
439
|
+
Math.max(
|
|
440
|
+
0,
|
|
441
|
+
rowWidth - visibleWidth(`${rowPrefix}${displayId}${durationSuffix}`) - 1,
|
|
442
|
+
),
|
|
443
|
+
),
|
|
444
|
+
)
|
|
445
|
+
: "";
|
|
446
|
+
const modelLead = modelBadge ? `${modelBadge} ` : "";
|
|
447
|
+
const headRaw = displayId;
|
|
448
|
+
// Running rows in a live block shimmer their label; once the block
|
|
449
|
+
// stops animating (sealed, or a settled snapshot — spinnerFrame
|
|
450
|
+
// cleared) they render static so scrollback never keeps a mid-sweep
|
|
451
|
+
// shimmer band.
|
|
452
|
+
const headLabel = live
|
|
453
|
+
? shimmerEnabled()
|
|
454
|
+
? shimmerText(headRaw, uiTheme)
|
|
455
|
+
: uiTheme.fg("accent", headRaw)
|
|
456
|
+
: uiTheme.fg("toolOutput", headRaw);
|
|
457
|
+
let row = `${rowPrefix}${modelLead}${headLabel}`;
|
|
458
|
+
const distinctLabel = rowLabel.trim() !== job.id;
|
|
459
|
+
const label = visibleLabelLines[0] ?? "";
|
|
460
|
+
const inlineLabel = distinctLabel && visibleWidth(`${row} ${label}${durationSuffix}`) <= rowWidth;
|
|
461
|
+
if (inlineLabel) row += ` ${uiTheme.fg("toolOutput", label)}`;
|
|
462
|
+
row += durationSuffix;
|
|
463
|
+
lines.push(truncateToWidth(row, rowWidth, ""));
|
|
464
|
+
for (let i = distinctLabel && !inlineLabel ? 0 : 1; i < visibleLabelLines.length; i++) {
|
|
465
|
+
lines.push(
|
|
466
|
+
` ${uiTheme.fg("toolOutput", truncateToWidth(visibleLabelLines[i]!, continuationWidth))}`,
|
|
467
|
+
);
|
|
468
|
+
}
|
|
405
469
|
}
|
|
406
470
|
const artifactError = job.meta?.artifactError ?? job.artifactError;
|
|
407
471
|
if (artifactError) {
|
|
@@ -498,7 +562,11 @@ function bodyLines(
|
|
|
498
562
|
body: string,
|
|
499
563
|
expanded: boolean,
|
|
500
564
|
theme: Theme,
|
|
501
|
-
options: {
|
|
565
|
+
options: {
|
|
566
|
+
indent?: string;
|
|
567
|
+
tone?: "dim" | "toolOutput";
|
|
568
|
+
collapsedLines?: number;
|
|
569
|
+
} = {},
|
|
502
570
|
): string[] {
|
|
503
571
|
const indent = options.indent ?? "";
|
|
504
572
|
const tone = options.tone ?? "toolOutput";
|
|
@@ -561,7 +629,12 @@ export function createIrcMessageCard(
|
|
|
561
629
|
(width, expanded) => {
|
|
562
630
|
const lines = [renderStatusLine({ iconOverride: ircGlyph(uiTheme), title, meta }, uiTheme)];
|
|
563
631
|
if (body.trim()) {
|
|
564
|
-
lines.push(
|
|
632
|
+
lines.push(
|
|
633
|
+
...bodyLines(body, expanded, uiTheme, {
|
|
634
|
+
indent: " ",
|
|
635
|
+
collapsedLines: 3,
|
|
636
|
+
}),
|
|
637
|
+
);
|
|
565
638
|
}
|
|
566
639
|
return lines.map(line => truncateToWidth(line, width, Ellipsis.Unicode));
|
|
567
640
|
},
|
|
@@ -589,47 +662,6 @@ export function createIrcMessageCard(
|
|
|
589
662
|
return Object.assign(component, { describe: () => described });
|
|
590
663
|
}
|
|
591
664
|
|
|
592
|
-
/**
|
|
593
|
-
* One job row on one line: type badge, id + label, terminal-clocked duration
|
|
594
|
-
* (live while running). The job's output and errors fill the card of the call
|
|
595
|
-
* that started it (`parksAsBackground`), so the row carries no preview.
|
|
596
|
-
*/
|
|
597
|
-
function describeJob(job: JobSnapshot, isPartial: boolean): NativeNode {
|
|
598
|
-
const running = job.status === "running" && isPartial;
|
|
599
|
-
const label = job.label.trim() !== job.id ? plainText(job.label.split(/\r?\n/)[0] ?? "") : "";
|
|
600
|
-
const spans: TspSpan[] = [
|
|
601
|
-
span(plainText(job.id), running ? "accent" : "toolOutput", running ? { fx: "shimmer" } : undefined),
|
|
602
|
-
];
|
|
603
|
-
if (label) spans.push(span(` ${label}`, "toolOutput"));
|
|
604
|
-
if (job.exitCode !== undefined) spans.push(span(` exit ${job.exitCode}`, job.exitCode === 0 ? "muted" : "error"));
|
|
605
|
-
const tone =
|
|
606
|
-
job.status === "completed"
|
|
607
|
-
? "success"
|
|
608
|
-
: job.status === "failed"
|
|
609
|
-
? "error"
|
|
610
|
-
: job.status === "cancelled"
|
|
611
|
-
? "warning"
|
|
612
|
-
: "accent";
|
|
613
|
-
return node(
|
|
614
|
-
"row",
|
|
615
|
-
{ role: "omp.wait.job", tone, gap: "sm" },
|
|
616
|
-
compact([
|
|
617
|
-
node("badge", { text: job.type, tone }),
|
|
618
|
-
text(spans, { truncate: "end", grow: 1 }),
|
|
619
|
-
job.status === "running" && job.completionPercent !== undefined
|
|
620
|
-
? node("progress", {
|
|
621
|
-
value: job.completionPercent / 100,
|
|
622
|
-
label: `${job.completionPercent}%`,
|
|
623
|
-
tone: "accent",
|
|
624
|
-
title: "Agent's own completion estimate",
|
|
625
|
-
})
|
|
626
|
-
: undefined,
|
|
627
|
-
elapsed(job.durationMs, !running),
|
|
628
|
-
]),
|
|
629
|
-
job.id,
|
|
630
|
-
);
|
|
631
|
-
}
|
|
632
|
-
|
|
633
665
|
function describeJobsResult(
|
|
634
666
|
result: ToolRenderResult<CoordinationDetails>,
|
|
635
667
|
isPartial: boolean,
|
|
@@ -658,9 +690,23 @@ function describeJobsResult(
|
|
|
658
690
|
if (counts.completed > 0) head.push(span(` ${counts.completed} done`, "success"));
|
|
659
691
|
if (counts.failed > 0) head.push(span(` ${counts.failed} failed`, "error"));
|
|
660
692
|
if (counts.cancelled > 0) head.push(span(` ${counts.cancelled} cancelled`, "warning"));
|
|
661
|
-
const order: Record<JobSnapshot["status"], number> = {
|
|
693
|
+
const order: Record<JobSnapshot["status"], number> = {
|
|
694
|
+
running: 0,
|
|
695
|
+
failed: 1,
|
|
696
|
+
cancelled: 2,
|
|
697
|
+
completed: 3,
|
|
698
|
+
};
|
|
662
699
|
const sorted = [...jobs].sort((a, b) => order[a.status] - order[b.status] || b.durationMs - a.durationMs);
|
|
663
|
-
|
|
700
|
+
// One frame of tiles: each job's status, title, figure and widget together. The job's output
|
|
701
|
+
// and errors fill the card of the call that started it (`parksAsBackground`), so no previews.
|
|
702
|
+
const body: NativeNode[] = [];
|
|
703
|
+
if (sorted.length > 0) {
|
|
704
|
+
body.push(keyed(describeJobTiles(sorted.slice(0, TILES_SHOWN).map(job => jobTile(job, isPartial))), "tiles"));
|
|
705
|
+
}
|
|
706
|
+
if (sorted.length > TILES_SHOWN) {
|
|
707
|
+
const rest = sorted.length - TILES_SHOWN;
|
|
708
|
+
body.push(text([span(`+${rest} more ${rest === 1 ? "job" : "jobs"} · /jobs`, "dim")], { role: "omp.wait.more" }));
|
|
709
|
+
}
|
|
664
710
|
for (const agent of agents) {
|
|
665
711
|
const spans: TspSpan[] = [span(plainText(agent.id), "muted")];
|
|
666
712
|
if (agent.activity) spans.push(span(` ${plainText(agent.activity)}`, "toolOutput"));
|
|
@@ -684,6 +730,8 @@ function describeJobsResult(
|
|
|
684
730
|
return {
|
|
685
731
|
head,
|
|
686
732
|
tone: counts.failed > 0 ? "warning" : counts.running > 0 || agents.length > 0 ? "info" : "success",
|
|
733
|
+
// The tiles are what a wait shows: clamping the body by lines would cut them.
|
|
734
|
+
preview: "children",
|
|
687
735
|
body,
|
|
688
736
|
};
|
|
689
737
|
}
|
|
@@ -729,11 +777,15 @@ export const waitToolRenderer = {
|
|
|
729
777
|
result: ToolRenderResult<CoordinationDetails>,
|
|
730
778
|
options: RenderResultOptions,
|
|
731
779
|
): NativeToolView | undefined {
|
|
732
|
-
return waitResultMemo.get(result, [options.isPartial], () => {
|
|
780
|
+
return waitResultMemo.get(result, [options.isPartial, widgetStamp(result.details?.jobs)], () => {
|
|
733
781
|
if (result.isError) return errorView("Wait", resultText(result));
|
|
734
782
|
const details = result.details;
|
|
735
783
|
if (details?.interrupted) {
|
|
736
|
-
return {
|
|
784
|
+
return {
|
|
785
|
+
head: toolHead("Wait", "interrupted by message"),
|
|
786
|
+
tone: "info",
|
|
787
|
+
inline: true,
|
|
788
|
+
};
|
|
737
789
|
}
|
|
738
790
|
const waited = details?.waited;
|
|
739
791
|
if (!waited) return describeJobsResult(result, options.isPartial);
|
|
@@ -741,7 +793,11 @@ export const waitToolRenderer = {
|
|
|
741
793
|
});
|
|
742
794
|
},
|
|
743
795
|
renderResult(
|
|
744
|
-
result: {
|
|
796
|
+
result: {
|
|
797
|
+
content: Array<{ type: string; text?: string }>;
|
|
798
|
+
details?: CoordinationDetails;
|
|
799
|
+
isError?: boolean;
|
|
800
|
+
},
|
|
745
801
|
options: RenderResultOptions,
|
|
746
802
|
uiTheme: Theme,
|
|
747
803
|
): Component {
|
package/src/tui.ts
CHANGED
|
@@ -28,6 +28,7 @@ import { assumedTspHello, NativeBackend, type NativeHost } from "./native/backen
|
|
|
28
28
|
import { col } from "./native/describe";
|
|
29
29
|
import { TSP_PREFIX, type TspHello } from "./native/encode";
|
|
30
30
|
import type { DescribeContext, NativeNode, NativeScreen, NativeSurfaceProvider, NativeUiEvent } from "./native/node";
|
|
31
|
+
import { cropSixelTop } from "./sixel";
|
|
31
32
|
import { STDOUT_BACKLOG_CLEAR_BYTES, setAltScreenActive, type Terminal } from "./terminal";
|
|
32
33
|
import { classifyTerminalMultiplexerModule, terminalMultiplexerSessions } from "./terminal-multiplexer";
|
|
33
34
|
import {
|
|
@@ -36,6 +37,7 @@ import {
|
|
|
36
37
|
encodeKittyDeleteImage,
|
|
37
38
|
encodeKittyDeletePlacement,
|
|
38
39
|
encodeKittyPlacementLine,
|
|
40
|
+
getCellDimensions,
|
|
39
41
|
getTerminalBackgroundColor,
|
|
40
42
|
ImageProtocol,
|
|
41
43
|
isImageProtocolForced,
|
|
@@ -1004,6 +1006,8 @@ export class TUI extends Container {
|
|
|
1004
1006
|
#screenImagePlacements = new Map<number, LiveImagePlacement>();
|
|
1005
1007
|
#altImagePlacements = new Map<number, LiveImagePlacement>();
|
|
1006
1008
|
#nextImagePlacementId = 1;
|
|
1009
|
+
/** Last top-clipped SIXEL crop; only the block straddling row 0 is ever cropped. */
|
|
1010
|
+
#sixelCrop: { source: string; pixels: number; maxHeightPixels: number; output: string } | undefined;
|
|
1007
1011
|
#ghosttyInitialImageDelayDone = false;
|
|
1008
1012
|
#ghosttyInitialImageDelayTimer: RenderTimer | undefined;
|
|
1009
1013
|
#ghosttyImageReadyAtMs = 0;
|
|
@@ -3287,10 +3291,11 @@ export class TUI extends Container {
|
|
|
3287
3291
|
}
|
|
3288
3292
|
|
|
3289
3293
|
/**
|
|
3290
|
-
* Clip
|
|
3291
|
-
*
|
|
3292
|
-
*
|
|
3293
|
-
*
|
|
3294
|
+
* Clip direct placements to the visible source slice: Kitty tracks a live
|
|
3295
|
+
* placement owner, while SIXEL crops the encoded raster (iTerm2 images are
|
|
3296
|
+
* not cropped). SIXEL and iTerm2 have no delete command and composite over
|
|
3297
|
+
* existing pixels, so erase the visible rows they paint above `screenRow`
|
|
3298
|
+
* before repainting.
|
|
3294
3299
|
*/
|
|
3295
3300
|
#imageLineSequence(
|
|
3296
3301
|
line: string,
|
|
@@ -3301,7 +3306,16 @@ export class TUI extends Container {
|
|
|
3301
3306
|
if (screenRow < 0) return line;
|
|
3302
3307
|
const parsed = parseKittyDirectPlacementLine(line);
|
|
3303
3308
|
if (!parsed) {
|
|
3304
|
-
const
|
|
3309
|
+
const requestedLift = directPlacementLift(line);
|
|
3310
|
+
const lift = Math.min(requestedLift, screenRow);
|
|
3311
|
+
if (requestedLift > lift) {
|
|
3312
|
+
const cellHeight = getCellDimensions().heightPx;
|
|
3313
|
+
const cropPixels = Math.round((requestedLift - lift) * cellHeight);
|
|
3314
|
+
const maxHeightPixels = Math.floor((screenRow + 1) * cellHeight);
|
|
3315
|
+
line = line.replace(/\x1bP[0-9;]*q[\s\S]*?\x1b\\/g, sequence =>
|
|
3316
|
+
this.#cropSixel(sequence, cropPixels, maxHeightPixels),
|
|
3317
|
+
);
|
|
3318
|
+
}
|
|
3305
3319
|
return lift > 0 ? `\x1b[${lift}A${`${ERASE_LINE}\x1b[B`.repeat(lift)}${line}` : line;
|
|
3306
3320
|
}
|
|
3307
3321
|
const previous = placements?.get(placementRow);
|
|
@@ -3321,6 +3335,32 @@ export class TUI extends Container {
|
|
|
3321
3335
|
);
|
|
3322
3336
|
}
|
|
3323
3337
|
|
|
3338
|
+
/**
|
|
3339
|
+
* Crop a SIXEL raster whose top rows lie above the viewport. A raster the
|
|
3340
|
+
* cropper cannot parse is dropped: painting it whole would overwrite the
|
|
3341
|
+
* rows below the visible slice.
|
|
3342
|
+
*/
|
|
3343
|
+
#cropSixel(source: string, pixels: number, maxHeightPixels: number): string {
|
|
3344
|
+
const cached = this.#sixelCrop;
|
|
3345
|
+
if (
|
|
3346
|
+
cached !== undefined &&
|
|
3347
|
+
cached.pixels === pixels &&
|
|
3348
|
+
cached.maxHeightPixels === maxHeightPixels &&
|
|
3349
|
+
cached.source === source
|
|
3350
|
+
) {
|
|
3351
|
+
return cached.output;
|
|
3352
|
+
}
|
|
3353
|
+
let output: string;
|
|
3354
|
+
try {
|
|
3355
|
+
output = cropSixelTop(source, pixels, maxHeightPixels);
|
|
3356
|
+
} catch (err) {
|
|
3357
|
+
logger.warn("Dropping top-clipped SIXEL image that cannot be cropped", { err });
|
|
3358
|
+
output = "";
|
|
3359
|
+
}
|
|
3360
|
+
this.#sixelCrop = { source, pixels, maxHeightPixels, output };
|
|
3361
|
+
return output;
|
|
3362
|
+
}
|
|
3363
|
+
|
|
3324
3364
|
#terminalLine(line: PreparedLine): string {
|
|
3325
3365
|
if (line.hasOsc8) return line.terminalContent + LINE_TERMINATOR;
|
|
3326
3366
|
return line.terminalContent.endsWith(SEGMENT_RESET) ? line.terminalContent : line.terminalContent + SEGMENT_RESET;
|