@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
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Tool-approval prompt in the composer's place, presented like the ask
|
|
3
3
|
* dialog: the approval title and its details over a list of choices —
|
|
4
|
-
*
|
|
4
|
+
* optional grants (led by a toggle that keeps them across sessions), Approve,
|
|
5
|
+
* an optional "approve for this session", and Deny — and an action
|
|
5
6
|
* bar. The arrows move between the choices and Enter takes the highlighted
|
|
6
7
|
* one; `app.approval.approve` (Ctrl+Enter by default) approves this call
|
|
7
8
|
* outright and Esc denies. Enter and the approve chord only arm
|
|
@@ -45,7 +46,8 @@ export const APPROVE_ARM_MS = 500;
|
|
|
45
46
|
/** The Deny row while a composer draft holds Enter: sending it denies the call and steers the turn. */
|
|
46
47
|
const DENY_AND_SEND_LABEL = "Deny and send message";
|
|
47
48
|
|
|
48
|
-
|
|
49
|
+
/** `grant<N>` takes the Nth of {@link ApprovalDialogOptions.grants}; `remember` toggles their lifetime. */
|
|
50
|
+
type ApprovalAnswer = "once" | "session" | "deny" | "remember" | `grant${number}`;
|
|
49
51
|
|
|
50
52
|
interface ApprovalChoice {
|
|
51
53
|
readonly label: string;
|
|
@@ -57,6 +59,14 @@ interface ApprovalDialogCallbacks {
|
|
|
57
59
|
onDeny(): void;
|
|
58
60
|
}
|
|
59
61
|
|
|
62
|
+
/** A choice that approves the call and remembers more than it: a folder, or the exact target. */
|
|
63
|
+
export interface ApprovalDialogGrant {
|
|
64
|
+
/** Label while the grant is remembered for this session. */
|
|
65
|
+
readonly label: string;
|
|
66
|
+
/** Label once the remember toggle keeps it across sessions. */
|
|
67
|
+
readonly alwaysLabel: string;
|
|
68
|
+
}
|
|
69
|
+
|
|
60
70
|
/** The composer mounted beneath the dialog: it receives every key the dialog does not own. */
|
|
61
71
|
export interface ApprovalDialogDraft {
|
|
62
72
|
/** True while the composer holds a draft — text, attachments, or a paste still landing: keys then edit and send it. */
|
|
@@ -80,6 +90,12 @@ interface ApprovalDialogOptions {
|
|
|
80
90
|
draft?: ApprovalDialogDraft;
|
|
81
91
|
/** Offer to approve this and later like calls for the rest of the session, as the row between Approve and Deny. */
|
|
82
92
|
session?: { label: string; onApprove(): void };
|
|
93
|
+
/**
|
|
94
|
+
* Grants to offer above Approve, led by a toggle that keeps them across sessions; Approve then
|
|
95
|
+
* reads "Approve Once". `onGrant` gets the chosen grant and whether the toggle was on.
|
|
96
|
+
*/
|
|
97
|
+
grants?: readonly ApprovalDialogGrant[];
|
|
98
|
+
onGrant?(grant: ApprovalDialogGrant, always: boolean): void;
|
|
83
99
|
}
|
|
84
100
|
|
|
85
101
|
/** A prompt's detail rows: every line after the title, trailing blanks dropped. */
|
|
@@ -100,9 +116,11 @@ export class ApprovalDialogComponent implements Component {
|
|
|
100
116
|
readonly #footer = new PanelRows();
|
|
101
117
|
#scrollOffset = 0;
|
|
102
118
|
#bodyRows = MAX_DETAIL_ROWS;
|
|
103
|
-
/** In list order
|
|
104
|
-
|
|
119
|
+
/** In list order: the remember toggle and grants (when offered), Approve (highlighted on mount), the session row, Deny last. */
|
|
120
|
+
#choices: readonly ApprovalChoice[];
|
|
105
121
|
#choice = 0;
|
|
122
|
+
/** The remember toggle: grants are kept across sessions while it is on. */
|
|
123
|
+
#rememberAlways = false;
|
|
106
124
|
#closed = false;
|
|
107
125
|
#native: NativeNode | undefined;
|
|
108
126
|
/** Whether a draft held the keys when {@link #native} was described: it moves the highlight. */
|
|
@@ -122,11 +140,8 @@ export class ApprovalDialogComponent implements Component {
|
|
|
122
140
|
this.#summary = promptDetails(lines);
|
|
123
141
|
this.#full =
|
|
124
142
|
options.fullPrompt === undefined ? undefined : promptDetails(sanitizeDisplayLines(options.fullPrompt));
|
|
125
|
-
this.#choices =
|
|
126
|
-
|
|
127
|
-
...(options.session ? [{ label: options.session.label, answer: "session" as const }] : []),
|
|
128
|
-
{ label: "Deny", answer: "deny" },
|
|
129
|
-
];
|
|
143
|
+
this.#choices = this.#buildChoices();
|
|
144
|
+
this.#choice = this.#choices.findIndex(choice => choice.answer === "once");
|
|
130
145
|
this.#panel = new OverlayPanel(title, "omp.overlay.approval");
|
|
131
146
|
this.#footer.setHeight(1);
|
|
132
147
|
this.#panel.addChild(this.#body);
|
|
@@ -149,6 +164,33 @@ export class ApprovalDialogComponent implements Component {
|
|
|
149
164
|
}
|
|
150
165
|
}
|
|
151
166
|
|
|
167
|
+
#buildChoices(): ApprovalChoice[] {
|
|
168
|
+
const grants = this.options.grants ?? [];
|
|
169
|
+
const glyph = this.#rememberAlways ? theme.checkbox.checked : theme.checkbox.unchecked;
|
|
170
|
+
return [
|
|
171
|
+
...(grants.length > 0 ? [{ label: `${glyph} Remember across sessions`, answer: "remember" as const }] : []),
|
|
172
|
+
...grants.map((grant, index) => ({
|
|
173
|
+
label: this.#rememberAlways ? grant.alwaysLabel : grant.label,
|
|
174
|
+
answer: `grant${index}` as const,
|
|
175
|
+
})),
|
|
176
|
+
{ label: grants.length > 0 ? "Approve Once" : "Approve", answer: "once" },
|
|
177
|
+
...(this.options.session ? [{ label: this.options.session.label, answer: "session" as const }] : []),
|
|
178
|
+
{ label: "Deny", answer: "deny" },
|
|
179
|
+
];
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/** Run a choice: the remember toggle flips the grants' lifetime in place, every other choice answers. */
|
|
183
|
+
#take(answer: ApprovalAnswer): void {
|
|
184
|
+
if (answer !== "remember") {
|
|
185
|
+
this.#finish(answer);
|
|
186
|
+
return;
|
|
187
|
+
}
|
|
188
|
+
this.#rememberAlways = !this.#rememberAlways;
|
|
189
|
+
this.#choices = this.#buildChoices();
|
|
190
|
+
this.#native = undefined;
|
|
191
|
+
this.options.tui?.requestRender();
|
|
192
|
+
}
|
|
193
|
+
|
|
152
194
|
invalidate(): void {
|
|
153
195
|
this.#native = undefined;
|
|
154
196
|
this.#panel.invalidate();
|
|
@@ -191,7 +233,7 @@ export class ApprovalDialogComponent implements Component {
|
|
|
191
233
|
else if (matchesSelectPageUp(keyData)) this.#scroll(-page);
|
|
192
234
|
else if (matchesSelectPageDown(keyData)) this.#scroll(page);
|
|
193
235
|
else if (getKeybindings().matches(keyData, "tui.select.confirm")) {
|
|
194
|
-
if (performance.now() >= this.#armedAt) this.#
|
|
236
|
+
if (performance.now() >= this.#armedAt) this.#take(this.#choices[this.#choice]!.answer);
|
|
195
237
|
}
|
|
196
238
|
// Enter never reaches an empty composer: it would submit nothing, or interrupt the run.
|
|
197
239
|
else if (draft && !getKeybindings().matches(keyData, "tui.input.submit")) this.#editDraft(draft, keyData);
|
|
@@ -295,7 +337,7 @@ export class ApprovalDialogComponent implements Component {
|
|
|
295
337
|
tone: "error",
|
|
296
338
|
title: `Deny this call${cancelKey ? ` ${formatTooltipKey(cancelKey)}` : ""}`,
|
|
297
339
|
});
|
|
298
|
-
const approve = actionButton("Approve", "approve", {
|
|
340
|
+
const approve = actionButton(this.options.grants?.length ? "Approve Once" : "Approve", "approve", {
|
|
299
341
|
...(approveKey ? { keys: approveKey } : {}),
|
|
300
342
|
tone: "accent",
|
|
301
343
|
title: `Approve this call${approveKey ? ` ${formatTooltipKey(approveKey)}` : ""}`,
|
|
@@ -350,7 +392,7 @@ export class ApprovalDialogComponent implements Component {
|
|
|
350
392
|
}
|
|
351
393
|
if ((event.type !== "select" && event.type !== "activate") || leafKey(event.key) !== "choices") return;
|
|
352
394
|
const choice = this.#choices.find(candidate => candidate.answer === event.item);
|
|
353
|
-
if (choice) this.#
|
|
395
|
+
if (choice) this.#take(choice.answer);
|
|
354
396
|
}
|
|
355
397
|
|
|
356
398
|
/** Detail rows at `width`: one clipped row per detail, or wrapped rows once expanded. */
|
|
@@ -384,6 +426,10 @@ export class ApprovalDialogComponent implements Component {
|
|
|
384
426
|
this.#countdown?.dispose();
|
|
385
427
|
if (answer === "once") this.callbacks.onApprove();
|
|
386
428
|
else if (answer === "session") this.options.session?.onApprove();
|
|
387
|
-
else
|
|
429
|
+
else if (answer.startsWith("grant")) {
|
|
430
|
+
const grant = this.options.grants?.[Number(answer.slice("grant".length))];
|
|
431
|
+
if (grant) this.options.onGrant?.(grant, this.#rememberAlways);
|
|
432
|
+
else this.callbacks.onDeny();
|
|
433
|
+
} else this.callbacks.onDeny();
|
|
388
434
|
}
|
|
389
435
|
}
|
|
@@ -6,17 +6,19 @@
|
|
|
6
6
|
* (cwd, live pids, exit code, artifact) and live output.
|
|
7
7
|
*/
|
|
8
8
|
import type { TspSpan, TspTone } from "@oh-my-pi/pi-wire";
|
|
9
|
-
import { formatDuration } from "@oh-my-pi/pi-utils";
|
|
9
|
+
import { formatDuration, sanitizeText } from "@oh-my-pi/pi-utils";
|
|
10
10
|
import type { Component } from "../tui";
|
|
11
11
|
import { matchesSelectCancel, matchesSelectDown, matchesSelectUp } from "../keybinding-matchers";
|
|
12
12
|
import { matchesKey } from "../keys";
|
|
13
|
-
import { truncateToWidth } from "../utils";
|
|
13
|
+
import { replaceTabs, truncateToWidth, wrapTextWithAnsi } from "../utils";
|
|
14
14
|
import { collapseCommand } from "../apps/ps-data";
|
|
15
|
+
import { PREVIEW_LIMITS, shortenEmbeddedPaths } from "../render/render-utils";
|
|
15
16
|
import { ansi, code, col, compact, item, node, row, span, stableKey, text } from "../native/describe";
|
|
16
17
|
import type { DescribeContext, NativeChild, NativeNode, NativeUiEvent } from "../native/node";
|
|
17
18
|
import { actionBar, actionButton, selectList } from "../native/overlay";
|
|
18
19
|
import { theme } from "../theme/theme";
|
|
19
20
|
import { buildJobTree, type JobTreeAgent, type JobTreeRow, jobTreeGuide } from "./job-tree";
|
|
21
|
+
import { describeJobWidget, type JobWidgetView, renderJobWidgetLines } from "../tools/job-widget";
|
|
20
22
|
|
|
21
23
|
/** One async job as the session snapshots it. */
|
|
22
24
|
export interface JobsPanelJob {
|
|
@@ -54,6 +56,8 @@ export interface JobsPanelDetail {
|
|
|
54
56
|
readonly output?: string;
|
|
55
57
|
/** Artifact holding the full output when `output` is cut. */
|
|
56
58
|
readonly artifactId?: string;
|
|
59
|
+
/** The job's live widget (frozen once it settles), drawn above the output. */
|
|
60
|
+
readonly widget?: JobWidgetView;
|
|
57
61
|
}
|
|
58
62
|
|
|
59
63
|
/** The session behind a {@link JobsSheet}. */
|
|
@@ -149,6 +153,7 @@ export class JobsSheet implements Component {
|
|
|
149
153
|
detail?.pids.join(" "),
|
|
150
154
|
detail?.exitCode,
|
|
151
155
|
detail?.artifactId,
|
|
156
|
+
detail?.widget ? `${detail.widget.updatedAt}:${detail.widget.state}` : "",
|
|
152
157
|
jobs.some(job => job.status === "running") ? Math.floor(now / 1000) : "",
|
|
153
158
|
].join("|");
|
|
154
159
|
const output = detail?.output;
|
|
@@ -198,6 +203,17 @@ export class JobsSheet implements Component {
|
|
|
198
203
|
}
|
|
199
204
|
const detail = selected && this.#source.inspect(selected.id);
|
|
200
205
|
if (detail) {
|
|
206
|
+
// Rows show the cut label; the selected job's command wraps here,
|
|
207
|
+
// bounded so a long script never pushes the facts and output off the sheet.
|
|
208
|
+
if (detail.command) {
|
|
209
|
+
const commandRows = wrapTextWithAnsi(
|
|
210
|
+
shortenEmbeddedPaths(replaceTabs(sanitizeText(detail.command))),
|
|
211
|
+
Math.max(1, width - 3),
|
|
212
|
+
);
|
|
213
|
+
for (const line of commandRows.slice(0, PREVIEW_LIMITS.EXPANDED_LINES)) lines.push(` ${line}`);
|
|
214
|
+
const hidden = commandRows.length - PREVIEW_LIMITS.EXPANDED_LINES;
|
|
215
|
+
if (hidden > 0) lines.push(truncateToWidth(` … ${hidden} more command lines`, width));
|
|
216
|
+
}
|
|
201
217
|
const facts = compact([
|
|
202
218
|
detail.cwd && `cwd ${detail.cwd}`,
|
|
203
219
|
detail.pids.length > 0 && `pid ${detail.pids.join(", ")}`,
|
|
@@ -205,6 +221,11 @@ export class JobsSheet implements Component {
|
|
|
205
221
|
detail.artifactId && `artifact://${detail.artifactId}`,
|
|
206
222
|
]);
|
|
207
223
|
if (facts.length > 0) lines.push(truncateToWidth(` ${facts.join(" · ")}`, width));
|
|
224
|
+
if (detail.widget) {
|
|
225
|
+
for (const line of renderJobWidgetLines(detail.widget, Math.max(0, width - 3), theme)) {
|
|
226
|
+
lines.push(` ${line}`);
|
|
227
|
+
}
|
|
228
|
+
}
|
|
208
229
|
for (const line of this.#tailLines(selected.id, detail.output ?? "")) {
|
|
209
230
|
if (line) lines.push(truncateToWidth(` ${line}`, width));
|
|
210
231
|
}
|
|
@@ -413,6 +434,7 @@ function describeDetail(
|
|
|
413
434
|
? code(command, { lang: "bash", wrap: true, preview: { lines: 4 }, role: "omp.jobs.command" })
|
|
414
435
|
: text(job.label, { role: "omp.jobs.title", wrap: "word" }),
|
|
415
436
|
facts.length > 0 && node("kv", { items: facts, role: "omp.jobs.facts" }),
|
|
437
|
+
detail?.widget && node("col", { role: "omp.jobs.widget" }, [describeJobWidget(detail.widget)], "widget"),
|
|
416
438
|
node(
|
|
417
439
|
"col",
|
|
418
440
|
{ role: "omp.jobs.out", gap: "sm" },
|
|
@@ -63,6 +63,7 @@ export type ModelRole =
|
|
|
63
63
|
| "memory"
|
|
64
64
|
| "task"
|
|
65
65
|
| "advisor"
|
|
66
|
+
| "widget"
|
|
66
67
|
| "image"
|
|
67
68
|
| "web"
|
|
68
69
|
| "speech"
|
|
@@ -79,6 +80,7 @@ export const MODEL_ROLE_IDS: ModelRole[] = [
|
|
|
79
80
|
"memory",
|
|
80
81
|
"task",
|
|
81
82
|
"advisor",
|
|
83
|
+
"widget",
|
|
82
84
|
"image",
|
|
83
85
|
"web",
|
|
84
86
|
"speech",
|
|
@@ -96,6 +98,7 @@ export const CHAT_MODEL_ROLE_IDS: ModelRole[] = [
|
|
|
96
98
|
"memory",
|
|
97
99
|
"task",
|
|
98
100
|
"advisor",
|
|
101
|
+
"widget",
|
|
99
102
|
];
|
|
100
103
|
export const KIND_ROLE_IDS: ModelRole[] = ["image", "web", "speech", "dictation", "judge"];
|
|
101
104
|
|
|
@@ -93,7 +93,7 @@ export class AttachmentChipsBand implements Component {
|
|
|
93
93
|
nativeImageNode(Buffer.from(image.data, "base64"), image.mimeType);
|
|
94
94
|
images.set(image, native);
|
|
95
95
|
content = node("image", {
|
|
96
|
-
...native.p,
|
|
96
|
+
...(native.k === "image" ? native.p : undefined),
|
|
97
97
|
alt: title,
|
|
98
98
|
w: dims?.width,
|
|
99
99
|
h: dims?.height,
|
|
@@ -17,6 +17,8 @@ import { getStateBgColor, Hasher, padToWidth } from "./utils";
|
|
|
17
17
|
export interface OutputBlockOptions {
|
|
18
18
|
header?: string;
|
|
19
19
|
headerMeta?: string;
|
|
20
|
+
/** Facts drawn inside the bottom border (`╰── Wall 0.16s · Timeout 300s ──╯`). */
|
|
21
|
+
footerMeta?: string;
|
|
20
22
|
state?: State;
|
|
21
23
|
sections?: Array<{ label?: string; lines: readonly string[]; separator?: boolean }>;
|
|
22
24
|
width: number;
|
|
@@ -46,7 +48,7 @@ export function isFramedBlockComponent(component: Component): boolean {
|
|
|
46
48
|
|
|
47
49
|
type BlockRow =
|
|
48
50
|
| { kind: "bar"; leftChar: string; rightChar: string; label?: string; meta?: string }
|
|
49
|
-
| { kind: "bottom"; leftChar: string; rightChar: string }
|
|
51
|
+
| { kind: "bottom"; leftChar: string; rightChar: string; meta?: string }
|
|
50
52
|
| { kind: "content"; inner: string }
|
|
51
53
|
| { kind: "sixel"; raw: string };
|
|
52
54
|
|
|
@@ -157,7 +159,26 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
157
159
|
}
|
|
158
160
|
}
|
|
159
161
|
|
|
160
|
-
|
|
162
|
+
// Footer facts ride the bottom border only when they fit whole; a clipped
|
|
163
|
+
// border would silently drop trailing facts (exit code, artifact id), so an
|
|
164
|
+
// overflowing footer wraps into body rows above a plain border instead.
|
|
165
|
+
const footerMeta = options.footerMeta;
|
|
166
|
+
const footerFits =
|
|
167
|
+
footerMeta === undefined ||
|
|
168
|
+
visibleWidth(` ${footerMeta} `) <=
|
|
169
|
+
lineWidth - visibleWidth(`${theme.boxRound.bottomLeft}${cap}`) - visibleWidth(theme.boxRound.bottomRight);
|
|
170
|
+
if (footerMeta !== undefined && !footerFits) {
|
|
171
|
+
for (const wrappedLine of wrapTextWithAnsi(footerMeta, contentWidth)) {
|
|
172
|
+
const innerPadding = padding(Math.max(0, contentWidth - visibleWidth(wrappedLine)));
|
|
173
|
+
rows.push({ kind: "content", inner: `${wrappedLine}${innerPadding}` });
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
rows.push({
|
|
177
|
+
kind: "bottom",
|
|
178
|
+
leftChar: theme.boxRound.bottomLeft,
|
|
179
|
+
rightChar: theme.boxRound.bottomRight,
|
|
180
|
+
meta: footerFits ? footerMeta : undefined,
|
|
181
|
+
});
|
|
161
182
|
|
|
162
183
|
const H = rows.length;
|
|
163
184
|
|
|
@@ -182,14 +203,6 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
182
203
|
return `${border(leftGlyphs)}${trimmedLabel}${border(fillGlyphs)}${border(rightGlyph)}`;
|
|
183
204
|
};
|
|
184
205
|
|
|
185
|
-
const renderBottom = (row: { leftChar: string; rightChar: string }): string => {
|
|
186
|
-
const leftGlyphs = `${row.leftChar}${cap}`;
|
|
187
|
-
const rightGlyph = row.rightChar;
|
|
188
|
-
const fillCount = Math.max(0, lineWidth - visibleWidth(leftGlyphs) - visibleWidth(rightGlyph));
|
|
189
|
-
const fillGlyphs = h.repeat(fillCount);
|
|
190
|
-
return `${border(leftGlyphs)}${border(fillGlyphs)}${border(rightGlyph)}`;
|
|
191
|
-
};
|
|
192
|
-
|
|
193
206
|
const renderContent = (inner: string): string =>
|
|
194
207
|
`${border(v)}${contentLeftPadding}${inner}${contentRightPadding}${border(v)}`;
|
|
195
208
|
|
|
@@ -201,8 +214,7 @@ export function renderOutputBlock(options: OutputBlockOptions, theme: Theme): st
|
|
|
201
214
|
lines.push(row.raw);
|
|
202
215
|
continue;
|
|
203
216
|
}
|
|
204
|
-
const line =
|
|
205
|
-
row.kind === "bar" ? renderBar(row) : row.kind === "bottom" ? renderBottom(row) : renderContent(row.inner);
|
|
217
|
+
const line = row.kind === "bar" || row.kind === "bottom" ? renderBar(row) : renderContent(row.inner);
|
|
206
218
|
lines.push(padToWidth(clipFrame ? truncateToWidth(line, lineWidth, Ellipsis.Omit) : line, lineWidth, bgFn));
|
|
207
219
|
}
|
|
208
220
|
|
|
@@ -360,6 +372,7 @@ export class CachedOutputBlock {
|
|
|
360
372
|
);
|
|
361
373
|
h.optional(options.header);
|
|
362
374
|
h.optional(options.headerMeta);
|
|
375
|
+
h.optional(options.footerMeta);
|
|
363
376
|
h.optional(options.state);
|
|
364
377
|
h.optional(options.borderColor);
|
|
365
378
|
h.bool(options.applyBg ?? true);
|
package/src/render/tool-card.ts
CHANGED
|
@@ -43,6 +43,8 @@ export interface ToolCardSnapshot {
|
|
|
43
43
|
/** Structured status header, rendered by the shared status-line primitive. */
|
|
44
44
|
status?: StatusLineOptions;
|
|
45
45
|
headerMeta?: string;
|
|
46
|
+
/** Short facts drawn inside the frame's bottom border (framed); a trailing row otherwise. */
|
|
47
|
+
footerMeta?: string;
|
|
46
48
|
phase?: ToolCardPhase;
|
|
47
49
|
body?: ToolCardContent;
|
|
48
50
|
sections?: readonly ToolCardSection[];
|
|
@@ -219,6 +221,7 @@ export class ToolCard implements Component {
|
|
|
219
221
|
previousOptions?.width === width &&
|
|
220
222
|
previousOptions.header === header &&
|
|
221
223
|
previousOptions.headerMeta === snapshot.headerMeta &&
|
|
224
|
+
previousOptions.footerMeta === snapshot.footerMeta &&
|
|
222
225
|
previousOptions.state === state &&
|
|
223
226
|
previousOptions.applyBg === snapshot.applyBg &&
|
|
224
227
|
previousOptions.contentPaddingLeft === snapshot.contentPaddingLeft &&
|
|
@@ -230,6 +233,7 @@ export class ToolCard implements Component {
|
|
|
230
233
|
const blockOptions: OutputBlockOptions = reusableOptions ?? {
|
|
231
234
|
header,
|
|
232
235
|
headerMeta: snapshot.headerMeta,
|
|
236
|
+
footerMeta: snapshot.footerMeta,
|
|
233
237
|
state,
|
|
234
238
|
sections,
|
|
235
239
|
width,
|
|
@@ -251,6 +255,7 @@ export class ToolCard implements Component {
|
|
|
251
255
|
if (section.label) lines.push(section.label);
|
|
252
256
|
lines.push(...section.lines);
|
|
253
257
|
}
|
|
258
|
+
if (snapshot.footerMeta) lines.push(snapshot.footerMeta);
|
|
254
259
|
const text = lines.join("\n");
|
|
255
260
|
const key = `${width}:${text.length}:${Bun.hash(text).toString(36)}:${state ?? "info"}:${snapshot.applyBg ?? true}`;
|
|
256
261
|
if (key !== this.#plainKey) {
|
|
@@ -279,6 +284,7 @@ export class ToolCard implements Component {
|
|
|
279
284
|
}
|
|
280
285
|
if (snapshot.footer)
|
|
281
286
|
slotInputs.push({ label: undefined, separator: slotInputs.length > 0, content: snapshot.footer });
|
|
287
|
+
if (snapshot.footerMeta) slotInputs.push({ label: undefined, separator: false, content: [snapshot.footerMeta] });
|
|
282
288
|
|
|
283
289
|
const nextChildren: Component[] = [];
|
|
284
290
|
const slots = slotInputs.map((input, index) => {
|
package/src/sixel.ts
ADDED
|
@@ -0,0 +1,185 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Give an empty first band an explicit transparent width. Zellij derives the
|
|
3
|
+
* decoded image width from its first pixel row, ignoring the raster minimum
|
|
4
|
+
* for transparent images; an omitted band otherwise makes the image invisible.
|
|
5
|
+
* Palette definitions stay before drawing so cropping can still parse them.
|
|
6
|
+
*/
|
|
7
|
+
export function ensureSixelRasterWidth(sequence: string): string {
|
|
8
|
+
const prefix = /^(\x1bP[0-9;]*q"\d+;\d+;(\d+);\d+(?:#\d+(?:;[12];\d+;\d+;\d+)?|\$)*)(?=-)/.exec(sequence);
|
|
9
|
+
if (!prefix) return sequence;
|
|
10
|
+
return `${prefix[1]}!${prefix[2]}?$${sequence.slice(prefix[1]!.length)}`;
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
interface SixelRun {
|
|
14
|
+
bits: number;
|
|
15
|
+
count: number;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
interface SixelBand {
|
|
19
|
+
/** The selected register is inherited across graphics newlines. */
|
|
20
|
+
color: number;
|
|
21
|
+
tokens: (SixelRun | string)[];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function invalidSixel(reason: string): never {
|
|
25
|
+
throw new Error(`Cannot crop SIXEL: ${reason}`);
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
function sixelInteger(value: string): number {
|
|
29
|
+
const number = Number(value);
|
|
30
|
+
if (!Number.isSafeInteger(number)) invalidSixel("integer exceeds the safe range");
|
|
31
|
+
return number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function appendRun(output: string[], bits: number, count: number): void {
|
|
35
|
+
const character = String.fromCharCode(63 + bits);
|
|
36
|
+
// Keep repeats compressed, including long transparent gaps.
|
|
37
|
+
output.push(count > 3 ? `!${count}${character}` : character.repeat(count));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function appendBand(output: string[], band: SixelBand | undefined, shift: number, mask: number): void {
|
|
41
|
+
if (!band) return;
|
|
42
|
+
output.push(`#${band.color}`);
|
|
43
|
+
for (const token of band.tokens) {
|
|
44
|
+
if (typeof token === "string") {
|
|
45
|
+
output.push(token);
|
|
46
|
+
} else {
|
|
47
|
+
const bits = shift >= 0 ? token.bits >>> shift : token.bits << -shift;
|
|
48
|
+
appendRun(output, bits & mask, token.count);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Crop integer pixel rows from a raw (not tmux-wrapped) SIXEL DCS image.
|
|
55
|
+
* Runs are shifted between adjacent six-pixel bands, never decoded to pixels.
|
|
56
|
+
* Palette selections, overdraw order, transparent gaps and DCS background mode
|
|
57
|
+
* are retained. A nonpositive crop returns the original sequence unchanged.
|
|
58
|
+
*
|
|
59
|
+
* The supported grammar is the native icy_sixel encoder's: one leading raster
|
|
60
|
+
* declaration with positive dimensions and square pixels, prefix RGB/HLS palette
|
|
61
|
+
* definitions, register selections, sixel characters, repeats, `$` and `-`.
|
|
62
|
+
* ESC DCS/ST framing only, as the TUI emits it. Palette definitions after
|
|
63
|
+
* drawing, additional raster declarations, other controls and malformed input
|
|
64
|
+
* throw; forwarding an unsupported image uncropped would reintroduce raster
|
|
65
|
+
* overflow.
|
|
66
|
+
*
|
|
67
|
+
* Without maxHeightPixels the crop is exact, including a partial final band with
|
|
68
|
+
* zero padding bits and the exact declared raster height. Some terminals still
|
|
69
|
+
* allocate all six rows of that final band. The TUI supplies its available pixel
|
|
70
|
+
* height to cap the result at a whole-band boundary, matching the native encoder's
|
|
71
|
+
* policy of dropping bottom pixels rather than overwriting the following row.
|
|
72
|
+
*/
|
|
73
|
+
export function cropSixelTop(sequence: string, pixels: number, maxHeightPixels?: number): string {
|
|
74
|
+
if (pixels <= 0) return sequence;
|
|
75
|
+
if (!Number.isSafeInteger(pixels)) throw new RangeError("SIXEL crop must be an integer pixel count");
|
|
76
|
+
if (maxHeightPixels !== undefined && (!Number.isFinite(maxHeightPixels) || maxHeightPixels < 0)) {
|
|
77
|
+
throw new RangeError("SIXEL height limit must be a nonnegative finite pixel count");
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const header = /^\x1bP[0-9;]*q/.exec(sequence);
|
|
81
|
+
if (!header) invalidSixel("missing DCS introducer");
|
|
82
|
+
if (!sequence.endsWith("\x1b\\")) invalidSixel("missing string terminator");
|
|
83
|
+
const end = sequence.length - 2;
|
|
84
|
+
const tokenPattern = /[?-~]|!\d+[?-~]|#\d+(?:;\d+)*|"\d+(?:;\d+)*|[-$]/y;
|
|
85
|
+
const palette: string[] = [];
|
|
86
|
+
const bands: SixelBand[] = [];
|
|
87
|
+
let band: SixelBand = { color: 0, tokens: [] };
|
|
88
|
+
let color = 0;
|
|
89
|
+
let drawing = false;
|
|
90
|
+
let raster: string[] | undefined;
|
|
91
|
+
let width = 0;
|
|
92
|
+
let height = 0;
|
|
93
|
+
let x = 0;
|
|
94
|
+
let y = 0;
|
|
95
|
+
let dataWidth = 0;
|
|
96
|
+
let paintedHeight = 0;
|
|
97
|
+
let cursor = header[0].length;
|
|
98
|
+
|
|
99
|
+
while (cursor < end) {
|
|
100
|
+
tokenPattern.lastIndex = cursor;
|
|
101
|
+
const match = tokenPattern.exec(sequence);
|
|
102
|
+
if (!match || tokenPattern.lastIndex > end) invalidSixel(`unsupported token at offset ${cursor}`);
|
|
103
|
+
const token = match[0];
|
|
104
|
+
cursor = tokenPattern.lastIndex;
|
|
105
|
+
switch (token[0]) {
|
|
106
|
+
case '"': {
|
|
107
|
+
if (raster || drawing) invalidSixel("raster attributes must occur once before drawing");
|
|
108
|
+
raster = token.slice(1).split(";");
|
|
109
|
+
if (raster.length !== 4) invalidSixel("expected four raster attributes");
|
|
110
|
+
const pan = sixelInteger(raster[0]!);
|
|
111
|
+
const pad = sixelInteger(raster[1]!);
|
|
112
|
+
width = sixelInteger(raster[2]!);
|
|
113
|
+
height = sixelInteger(raster[3]!);
|
|
114
|
+
if (pan === 0 || pan !== pad) invalidSixel("only square pixels are supported");
|
|
115
|
+
if (width === 0 || height === 0) invalidSixel("raster dimensions must be positive");
|
|
116
|
+
break;
|
|
117
|
+
}
|
|
118
|
+
case "#": {
|
|
119
|
+
const parameters = token.slice(1).split(";");
|
|
120
|
+
color = sixelInteger(parameters[0]!);
|
|
121
|
+
if (parameters.length > 1) {
|
|
122
|
+
if (drawing) invalidSixel("palette definitions must precede drawing");
|
|
123
|
+
if (parameters.length !== 5 || (parameters[1] !== "1" && parameters[1] !== "2")) {
|
|
124
|
+
invalidSixel("expected an RGB or HLS palette definition");
|
|
125
|
+
}
|
|
126
|
+
for (let index = 2; index < parameters.length; index++) sixelInteger(parameters[index]!);
|
|
127
|
+
palette.push(token);
|
|
128
|
+
}
|
|
129
|
+
band.tokens.push(`#${color}`);
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
case "$":
|
|
133
|
+
drawing = true;
|
|
134
|
+
x = 0;
|
|
135
|
+
band.tokens.push(token);
|
|
136
|
+
break;
|
|
137
|
+
case "-":
|
|
138
|
+
drawing = true;
|
|
139
|
+
bands.push(band);
|
|
140
|
+
band = { color, tokens: [] };
|
|
141
|
+
x = 0;
|
|
142
|
+
y += 6;
|
|
143
|
+
break;
|
|
144
|
+
default: {
|
|
145
|
+
drawing = true;
|
|
146
|
+
const count = token[0] === "!" ? sixelInteger(token.slice(1, -1)) : 1;
|
|
147
|
+
if (count === 0) invalidSixel("repeat count must be positive");
|
|
148
|
+
const bits = token.charCodeAt(token.length - 1) - 63;
|
|
149
|
+
x += count;
|
|
150
|
+
if (!Number.isSafeInteger(x)) invalidSixel("run width exceeds the safe range");
|
|
151
|
+
dataWidth = Math.max(dataWidth, x);
|
|
152
|
+
if (bits !== 0) paintedHeight = Math.max(paintedHeight, y + 32 - Math.clz32(bits));
|
|
153
|
+
band.tokens.push({ bits, count });
|
|
154
|
+
break;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
bands.push(band);
|
|
159
|
+
if (!raster) invalidSixel("missing raster dimensions");
|
|
160
|
+
// Raster attributes specify a minimum, not a clip rectangle. Account for
|
|
161
|
+
// drawing beyond them rather than silently discarding those source pixels.
|
|
162
|
+
width = Math.max(width, dataWidth);
|
|
163
|
+
height = Math.max(height, paintedHeight);
|
|
164
|
+
let croppedHeight = Math.max(0, height - pixels);
|
|
165
|
+
if (maxHeightPixels !== undefined) {
|
|
166
|
+
croppedHeight = Math.min(croppedHeight, Math.floor(maxHeightPixels / 6) * 6);
|
|
167
|
+
}
|
|
168
|
+
if (croppedHeight === 0) return "";
|
|
169
|
+
|
|
170
|
+
const output = [header[0], `"${raster[0]};${raster[1]};${width};${croppedHeight}`, ...palette];
|
|
171
|
+
const firstBand = Math.floor(pixels / 6);
|
|
172
|
+
const shift = pixels % 6;
|
|
173
|
+
const bandCount = Math.ceil(croppedHeight / 6);
|
|
174
|
+
for (let index = 0; index < bandCount; index++) {
|
|
175
|
+
if (index > 0) output.push("-");
|
|
176
|
+
const mask = (1 << Math.min(6, croppedHeight - index * 6)) - 1;
|
|
177
|
+
appendBand(output, bands[firstBand + index], shift, mask);
|
|
178
|
+
if (shift !== 0) {
|
|
179
|
+
output.push("$");
|
|
180
|
+
appendBand(output, bands[firstBand + index + 1], shift - 6, mask);
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
output.push("\x1b\\");
|
|
184
|
+
return ensureSixelRasterWidth(output.join(""));
|
|
185
|
+
}
|
|
@@ -45,6 +45,7 @@ import { col, node, span } from "../native/describe";
|
|
|
45
45
|
import { getContextMeterThresholds } from "../chrome/context-thresholds";
|
|
46
46
|
import { isNativeRendering, onNativeRenderingChange } from "../native/state";
|
|
47
47
|
import { getSeparator } from "./separators";
|
|
48
|
+
import { formatJobWidgetGlance, type JobWidgetEntry, latestJobWidget } from "../tools/job-widget";
|
|
48
49
|
import type {
|
|
49
50
|
CollabStatus,
|
|
50
51
|
ComposerFacts,
|
|
@@ -59,6 +60,9 @@ import type {
|
|
|
59
60
|
StatusResetExpiry,
|
|
60
61
|
} from "./types";
|
|
61
62
|
|
|
63
|
+
/** Sparkline cells the ANSI jobs segment draws after the freshest widget's headline. */
|
|
64
|
+
const JOBS_SEGMENT_SPARK_CELLS = 8;
|
|
65
|
+
|
|
62
66
|
/** What a click on a native status segment asks omp to open. */
|
|
63
67
|
export type StatusLineNativeAction = "status.model" | "status.context" | "status.git" | "status.cost" | "status.path";
|
|
64
68
|
|
|
@@ -446,6 +450,8 @@ class StatusLineExternalInputs {
|
|
|
446
450
|
isFastModeActive = false;
|
|
447
451
|
anthropicSlowModeLabel: string | undefined = undefined;
|
|
448
452
|
compactionSpeculation: unknown = undefined;
|
|
453
|
+
/** The freshest running job widget's view (stable identity while its data is unchanged). */
|
|
454
|
+
jobWidgetRef: unknown = undefined;
|
|
449
455
|
}
|
|
450
456
|
|
|
451
457
|
interface CachedStatusLine {
|
|
@@ -2796,6 +2802,13 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2796
2802
|
? this.session.getAnthropicSlowModeLabel()
|
|
2797
2803
|
: undefined;
|
|
2798
2804
|
target.compactionSpeculation = this.session.compactionSpeculation;
|
|
2805
|
+
target.jobWidgetRef = this.#latestJobWidget()?.view;
|
|
2806
|
+
}
|
|
2807
|
+
|
|
2808
|
+
/** The running job widget that saw data last; the ANSI jobs segment appends its glance. */
|
|
2809
|
+
#latestJobWidget(): JobWidgetEntry | undefined {
|
|
2810
|
+
const entries = this.session.getRunningJobWidgets?.();
|
|
2811
|
+
return entries ? latestJobWidget(entries) : undefined;
|
|
2799
2812
|
}
|
|
2800
2813
|
|
|
2801
2814
|
#sameStatusLineExternalInputs(left: StatusLineExternalInputs, right: StatusLineExternalInputs): boolean {
|
|
@@ -2849,7 +2862,8 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
2849
2862
|
left.isAutoThinking === right.isAutoThinking &&
|
|
2850
2863
|
left.isFastModeActive === right.isFastModeActive &&
|
|
2851
2864
|
left.anthropicSlowModeLabel === right.anthropicSlowModeLabel &&
|
|
2852
|
-
left.compactionSpeculation === right.compactionSpeculation
|
|
2865
|
+
left.compactionSpeculation === right.compactionSpeculation &&
|
|
2866
|
+
left.jobWidgetRef === right.jobWidgetRef
|
|
2853
2867
|
);
|
|
2854
2868
|
}
|
|
2855
2869
|
|
|
@@ -3058,7 +3072,10 @@ export class StatusLineComponent<TSession extends StatusLineSession = StatusLine
|
|
|
3058
3072
|
if (layout !== "plain-left") {
|
|
3059
3073
|
const runningBackgroundJobs = this.runningBackgroundJobCount();
|
|
3060
3074
|
if (runningBackgroundJobs > 0) {
|
|
3061
|
-
|
|
3075
|
+
const widget = this.#latestJobWidget();
|
|
3076
|
+
const glance = widget ? formatJobWidgetGlance(widget.view, theme, JOBS_SEGMENT_SPARK_CELLS) : "";
|
|
3077
|
+
const count = theme.fg("statusLineSubagents", `${theme.icon.job} ${runningBackgroundJobs}`);
|
|
3078
|
+
rightParts.unshift(glance ? `${count} ${glance}` : count);
|
|
3062
3079
|
}
|
|
3063
3080
|
if (subagentBadge) rightParts.unshift(subagentBadge);
|
|
3064
3081
|
}
|
package/src/status-line/host.ts
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import type { AgentMessage, ThinkingLevel } from "@oh-my-pi/pi-agent-core";
|
|
2
2
|
import type { Tool, UsageLimit, UsageReport } from "@oh-my-pi/pi-ai";
|
|
3
3
|
import type { Model } from "@oh-my-pi/pi-catalog/types";
|
|
4
|
+
import type { JobWidgetEntry } from "../tools/job-widget";
|
|
4
5
|
import type { CompactionBoundaries } from "./context-usage";
|
|
5
6
|
import type { StatusLineSettings, StatusResetExpiry } from "./types";
|
|
6
7
|
|
|
@@ -59,6 +60,8 @@ export interface StatusLineSession {
|
|
|
59
60
|
getAnthropicSlowModeLabel?(): string | undefined;
|
|
60
61
|
getPrewalkState?(): unknown;
|
|
61
62
|
getAsyncJobSnapshot(): { running: readonly { type: string; agentId?: string }[] } | null | undefined;
|
|
63
|
+
/** Widgets of the running background jobs, in job order; the jobs segment appends the freshest one's glance. */
|
|
64
|
+
getRunningJobWidgets?(): readonly JobWidgetEntry[];
|
|
62
65
|
getGoalModeState(): { goal?: { status: string; tokensUsed: number; tokenBudget?: number } } | undefined;
|
|
63
66
|
getAdvisorStatusOverview?(): { configured: boolean; advisors: readonly { status: string; yielded: boolean }[] };
|
|
64
67
|
getAdvisorCost?(): number;
|