@alisio/alisio-code 0.1.0-alpha.8 → 0.1.0
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/README.md +95 -16
- package/dist/analysis.d.ts +27 -0
- package/dist/analysis.js +145 -0
- package/dist/banner.js +2 -1
- package/dist/builtin.d.ts +3 -1
- package/dist/builtin.js +0 -24
- package/dist/main.js +140 -7
- package/dist/serve.d.ts +15 -0
- package/dist/serve.js +99 -0
- package/dist/tui/agent-manager.d.ts +105 -0
- package/dist/tui/agent-manager.js +269 -0
- package/dist/tui/agents.d.ts +5 -0
- package/dist/tui/agents.js +5 -0
- package/dist/tui/app.d.ts +42 -1
- package/dist/tui/app.js +1895 -97
- package/dist/tui/artifact-preview.d.ts +52 -0
- package/dist/tui/artifact-preview.js +212 -0
- package/dist/tui/artifacts.d.ts +54 -0
- package/dist/tui/artifacts.js +207 -0
- package/dist/tui/btw.d.ts +57 -0
- package/dist/tui/btw.js +76 -0
- package/dist/tui/components.d.ts +174 -12
- package/dist/tui/components.js +962 -66
- package/dist/tui/connect-input.d.ts +2 -0
- package/dist/tui/connect-input.js +1 -1
- package/dist/tui/exit.d.ts +10 -0
- package/dist/tui/exit.js +15 -0
- package/dist/tui/git-branch.d.ts +48 -0
- package/dist/tui/git-branch.js +99 -0
- package/dist/tui/goal.d.ts +75 -0
- package/dist/tui/goal.js +171 -0
- package/dist/tui/modes.d.ts +71 -0
- package/dist/tui/modes.js +90 -0
- package/dist/tui/open-path.d.ts +38 -0
- package/dist/tui/open-path.js +80 -0
- package/dist/tui/plan-review.d.ts +48 -0
- package/dist/tui/plan-review.js +76 -0
- package/dist/tui/queue.d.ts +2 -0
- package/dist/tui/queue.js +5 -1
- package/dist/tui/scroll.d.ts +8 -0
- package/dist/tui/scroll.js +12 -0
- package/dist/tui/settings-menu.d.ts +40 -0
- package/dist/tui/settings-menu.js +132 -0
- package/dist/tui/settings.d.ts +194 -0
- package/dist/tui/settings.js +482 -0
- package/dist/tui/state.d.ts +343 -7
- package/dist/tui/state.js +823 -54
- package/dist/tui/tasks.d.ts +123 -0
- package/dist/tui/tasks.js +369 -0
- package/dist/tui/theme.d.ts +42 -1
- package/dist/tui/theme.js +516 -1
- package/dist/tui/viewer-state.d.ts +7 -0
- package/dist/tui/viewer-state.js +36 -0
- package/dist/version.d.ts +7 -5
- package/dist/version.js +44 -8
- package/package.json +7 -9
package/dist/tui/components.js
CHANGED
|
@@ -1,13 +1,357 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { homedir } from "node:os";
|
|
2
|
+
import { Container, getCapabilities, getImageDimensions, Image, Key, Markdown, matchesKey, truncateToWidth, visibleWidth, wrapTextWithAnsi, } from "@earendil-works/pi-tui";
|
|
3
|
+
import { terminalCapabilities } from "../banner.js";
|
|
4
|
+
import { artifactLines } from "./artifacts.js";
|
|
2
5
|
import { attachmentCaption, MAX_ATTACHMENTS_PER_MESSAGE, } from "./attachments.js";
|
|
6
|
+
import { btwCurrent, btwHints, btwPosition, btwUsageLine, btwWindow, reduceBtw, } from "./btw.js";
|
|
7
|
+
import { branchDisplay } from "./git-branch.js";
|
|
8
|
+
import { initialPlanReviewState, reducePlanReview, } from "./plan-review.js";
|
|
3
9
|
import { initialQuestionState, reduceQuestions, } from "./questions.js";
|
|
4
|
-
import {
|
|
5
|
-
import {
|
|
10
|
+
import { scrollWindow } from "./scroll.js";
|
|
11
|
+
import { contextLevel, contextPercent, editSummary, entryKeyOf, fitIdentityParts, fitSegments, foldCandidateOf, formatContext, formatDuration, formatTokens, groupIdentity, groupToolEntries, humanizeToolName, isUiBlock, previewLinesFor, previewRows, quietFor, REASONING_EXPAND_MAX_LINES, reasoningDurationMs, runPhase, STALLED_MS, } from "./state.js";
|
|
12
|
+
import { highlightCode, imageTheme, levelColor, markdownDefaultTextStyle, markdownTheme, markdownTransform, sanitizeCode, style, } from "./theme.js";
|
|
6
13
|
export const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
|
|
7
14
|
/** Shared animation clock advanced by the app while work is running. */
|
|
8
15
|
export const clock = { frame: 0, now: Date.now() };
|
|
9
16
|
const fit = (lines, width) => lines.map((l) => truncateToWidth(l, width));
|
|
10
17
|
const wrap = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
|
|
18
|
+
/** ANSI-aware right padding to a visible width. */
|
|
19
|
+
const padTo = (text, width) => `${text}${" ".repeat(Math.max(0, width - visibleWidth(text)))}`;
|
|
20
|
+
const wrapCell = (text, width) => text.split("\n").flatMap((line) => (line ? wrapTextWithAnsi(line, Math.max(1, width)) : [""]));
|
|
21
|
+
/**
|
|
22
|
+
* Rendered body of a tool call's rich parts (inline image + native ui block), reusing the exact
|
|
23
|
+
* singleton rendering so grouped detail rows and plain rows agree. Returns [] when there is none.
|
|
24
|
+
*/
|
|
25
|
+
function richBodyLines(view, width, markdownCache) {
|
|
26
|
+
const lines = [];
|
|
27
|
+
if (view.image) {
|
|
28
|
+
const inline = getCapabilities().images !== null && process.env.NO_COLOR === undefined;
|
|
29
|
+
if (inline) {
|
|
30
|
+
const image = new Image(view.image.data, view.image.mimeType, imageTheme, {
|
|
31
|
+
maxWidthCells: Math.max(10, Math.min(60, width - 4)),
|
|
32
|
+
});
|
|
33
|
+
lines.push(...image.render(Math.max(1, width - 2)));
|
|
34
|
+
}
|
|
35
|
+
else {
|
|
36
|
+
const dims = getImageDimensions(view.image.data, view.image.mimeType);
|
|
37
|
+
lines.push(style.dim(`[image: ${view.image.mimeType}${dims ? ` ${dims.widthPx}x${dims.heightPx}` : ""}]`));
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
if (view.ui) {
|
|
41
|
+
const inner = Math.max(1, width - 2);
|
|
42
|
+
if (view.ui.kind === "markdown") {
|
|
43
|
+
// Cache the Markdown instance so streaming/resizes never re-parse every frame.
|
|
44
|
+
if (markdownCache?.text !== view.ui.text)
|
|
45
|
+
markdownCache = {
|
|
46
|
+
text: view.ui.text,
|
|
47
|
+
renderer: new Markdown(view.ui.text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
48
|
+
transform: markdownTransform,
|
|
49
|
+
}),
|
|
50
|
+
};
|
|
51
|
+
lines.push(...markdownCache.renderer.render(inner));
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
lines.push(...renderUiBlock(view.ui, width, unicodeAt(width)));
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { lines, markdownCache: markdownCache ?? undefined };
|
|
58
|
+
}
|
|
59
|
+
/** Terminal Unicode support, mirroring the copy hint detection in AssistantBlock. */
|
|
60
|
+
const unicodeAt = (width) => terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode;
|
|
61
|
+
/** Capped preview rows of one member call inside an expanded group (`… N more lines` footer). */
|
|
62
|
+
function memberPreviewLines(member, indent) {
|
|
63
|
+
const rows = previewRows(member.preview ?? "");
|
|
64
|
+
if (!rows.length)
|
|
65
|
+
return [];
|
|
66
|
+
const cap = previewLinesFor(member.status);
|
|
67
|
+
const shown = rows.slice(0, cap);
|
|
68
|
+
const paint = member.status === "error" ? style.red : style.gray;
|
|
69
|
+
const lines = shown.map((line, n) => `${indent} ${style.gray(n === 0 ? "⎿" : " ")} ${paint(line.replace(/\t/g, " "))}`);
|
|
70
|
+
if (rows.length > shown.length)
|
|
71
|
+
lines.push(style.gray(`${indent} … ${rows.length - shown.length} more lines`));
|
|
72
|
+
return lines;
|
|
73
|
+
}
|
|
74
|
+
/** `Command exited with code 0.` (green) / `Command exited with code N.` (red) when known. */
|
|
75
|
+
export const exitCodeLine = (code, indent = " ") => code === undefined
|
|
76
|
+
? undefined
|
|
77
|
+
: `${indent}${code === 0 ? style.green("Command exited with code 0.") : style.red(`Command exited with code ${code}.`)}`;
|
|
78
|
+
/**
|
|
79
|
+
* Aligned-column table rendering: cells wrap to their column width, the header is bold, a dim
|
|
80
|
+
* separator row sits under it, and the whole block respects the given inner width. Long values
|
|
81
|
+
* wrap instead of being cut; the final `truncateToWidth` pass only bites on very narrow
|
|
82
|
+
* terminals.
|
|
83
|
+
*/
|
|
84
|
+
function renderTableBlock(block, width) {
|
|
85
|
+
const caption = block.caption ? [style.dim(block.caption)] : [];
|
|
86
|
+
if (!block.columns.length)
|
|
87
|
+
return [...caption, style.gray("(empty table)")];
|
|
88
|
+
const inner = Math.max(1, width - 2);
|
|
89
|
+
const n = block.columns.length;
|
|
90
|
+
const natural = block.columns.map((header, i) => Math.max(1, visibleWidth(header), ...block.rows.map((row) => visibleWidth(row[i] ?? ""))));
|
|
91
|
+
const separatorCost = (n - 1) * 3;
|
|
92
|
+
const totalNatural = natural.reduce((a, b) => a + b, 0);
|
|
93
|
+
let widths;
|
|
94
|
+
if (totalNatural + separatorCost <= inner) {
|
|
95
|
+
widths = natural;
|
|
96
|
+
}
|
|
97
|
+
else {
|
|
98
|
+
const budget = Math.max(n, inner - separatorCost);
|
|
99
|
+
const scale = Math.min(1, budget / totalNatural);
|
|
100
|
+
widths = natural.map((w) => Math.max(1, Math.floor(w * scale)));
|
|
101
|
+
let left = budget - widths.reduce((a, b) => a + b, 0);
|
|
102
|
+
// Hand the leftover to the widest columns that still want space (round-robin, deterministic).
|
|
103
|
+
for (let round = 0; round < n && left > 0; round++) {
|
|
104
|
+
const widest = natural.findIndex((w, i) => (widths[i] ?? 0) < w);
|
|
105
|
+
if (widest < 0)
|
|
106
|
+
break;
|
|
107
|
+
widths[widest] = (widths[widest] ?? 0) + 1;
|
|
108
|
+
left--;
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
const cols = block.columns.map((header, i) => ({
|
|
112
|
+
header: wrapCell(header, widths[i] ?? 1),
|
|
113
|
+
body: block.rows.map((row) => wrapCell(row[i] ?? "", widths[i] ?? 1)),
|
|
114
|
+
}));
|
|
115
|
+
const headerHeight = Math.max(...cols.map((c) => c.header.length));
|
|
116
|
+
const lines = [];
|
|
117
|
+
for (let li = 0; li < headerHeight; li++)
|
|
118
|
+
lines.push(cols.map((c, i) => style.bold(padTo(c.header[li] ?? "", widths[i] ?? 1))).join(" | "));
|
|
119
|
+
lines.push(style.gray(cols.map((c, i) => "─".repeat(widths[i] ?? 1)).join(" │ ")));
|
|
120
|
+
for (const [ri] of block.rows.entries()) {
|
|
121
|
+
const wrapped = cols.map((c, i) => c.body[ri] ?? [""]);
|
|
122
|
+
const height = Math.max(...wrapped.map((w) => w.length));
|
|
123
|
+
for (let li = 0; li < height; li++)
|
|
124
|
+
lines.push(cols.map((c, i) => padTo(wrapped[i]?.[li] ?? "", widths[i] ?? 1)).join(" | "));
|
|
125
|
+
}
|
|
126
|
+
return [...caption, ...lines];
|
|
127
|
+
}
|
|
128
|
+
/** Two-column key-value rendering: bright-cyan keys padded left, values wrapped on the right. */
|
|
129
|
+
function renderKeyValueBlock(block, width) {
|
|
130
|
+
const caption = block.caption ? [style.dim(block.caption)] : [];
|
|
131
|
+
if (!block.entries.length)
|
|
132
|
+
return [...caption, style.gray("(empty)")];
|
|
133
|
+
const inner = Math.max(1, width - 2);
|
|
134
|
+
const keyWidth = Math.min(24, Math.max(...block.entries.map(([k]) => visibleWidth(k)), 1));
|
|
135
|
+
const valueWidth = Math.max(1, inner - keyWidth - 2);
|
|
136
|
+
const lines = [];
|
|
137
|
+
for (const [key, value] of block.entries) {
|
|
138
|
+
wrapCell(value, valueWidth).forEach((line, i) => {
|
|
139
|
+
const keyPart = i === 0 ? style.brightCyan(padTo(key, keyWidth)) : " ".repeat(keyWidth);
|
|
140
|
+
lines.push(`${keyPart} ${line}`);
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
return [...caption, ...lines];
|
|
144
|
+
}
|
|
145
|
+
/**
|
|
146
|
+
* Tree rendering with branch glyphs: `├─`/`└─`/`│` when the terminal supports Unicode, ASCII
|
|
147
|
+
* `|-`/`` `- ``/`|` otherwise.
|
|
148
|
+
*/
|
|
149
|
+
export function renderTreeLines(nodes, unicode) {
|
|
150
|
+
const branch = unicode ? "├─ " : "|- ";
|
|
151
|
+
const last = unicode ? "└─ " : "`- ";
|
|
152
|
+
const vertical = unicode ? "│ " : "| ";
|
|
153
|
+
const gap = " ";
|
|
154
|
+
const lines = [];
|
|
155
|
+
const walk = (items, prefix) => {
|
|
156
|
+
items.forEach((node, index) => {
|
|
157
|
+
const isLast = index === items.length - 1;
|
|
158
|
+
const meta = node.meta ? ` ${style.dim(`(${node.meta})`)}` : "";
|
|
159
|
+
lines.push(`${prefix}${isLast ? last : branch}${node.label}${meta}`);
|
|
160
|
+
if (node.children?.length)
|
|
161
|
+
walk(node.children, `${prefix}${isLast ? gap : vertical}`);
|
|
162
|
+
});
|
|
163
|
+
};
|
|
164
|
+
walk(nodes, "");
|
|
165
|
+
return lines;
|
|
166
|
+
}
|
|
167
|
+
/** Mini code block: dim fenced header, highlighted body (the same highlighter as markdown), dim close. */
|
|
168
|
+
function renderCodeBlock(block, _width) {
|
|
169
|
+
const caption = block.caption ? [style.dim(block.caption)] : [];
|
|
170
|
+
return [
|
|
171
|
+
...caption,
|
|
172
|
+
style.dim(block.lang ? `\`\`\`${block.lang}` : "```"),
|
|
173
|
+
...highlightCode(block.code, block.lang),
|
|
174
|
+
style.dim("```"),
|
|
175
|
+
];
|
|
176
|
+
}
|
|
177
|
+
/** Longest JSON/unknown-block text shown inline before a `… truncated` marker. */
|
|
178
|
+
const MAX_BLOCK_LINES = 200;
|
|
179
|
+
const MAX_BLOCK_CHARS = 20_000;
|
|
180
|
+
/** Caps free-form text by lines and characters, appending a dim marker when anything is cut. */
|
|
181
|
+
function capText(text) {
|
|
182
|
+
let out = text.length > MAX_BLOCK_CHARS ? text.slice(0, MAX_BLOCK_CHARS) : text;
|
|
183
|
+
const lines = out.split("\n");
|
|
184
|
+
if (lines.length > MAX_BLOCK_LINES)
|
|
185
|
+
out = lines.slice(0, MAX_BLOCK_LINES).join("\n");
|
|
186
|
+
return { text: out, truncated: out.length < text.length };
|
|
187
|
+
}
|
|
188
|
+
/** `JSON.stringify` that never throws and never returns `undefined`. */
|
|
189
|
+
function jsonText(value) {
|
|
190
|
+
try {
|
|
191
|
+
return JSON.stringify(value, null, 2) ?? String(value);
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
return String(value);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
function renderCappedCode(code, lang, caption) {
|
|
198
|
+
const capped = capText(code);
|
|
199
|
+
return [
|
|
200
|
+
...renderCodeBlock({
|
|
201
|
+
kind: "code",
|
|
202
|
+
code: capped.text,
|
|
203
|
+
...(lang ? { lang } : {}),
|
|
204
|
+
...(caption ? { caption } : {}),
|
|
205
|
+
}, 0),
|
|
206
|
+
...(capped.truncated ? [style.gray("… truncated")] : []),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
function renderDiffBlock(block) {
|
|
210
|
+
const caption = [block.caption, block.path].filter(Boolean).join(" · ") || undefined;
|
|
211
|
+
if (block.patch !== undefined)
|
|
212
|
+
return renderCappedCode(block.patch, "diff", caption);
|
|
213
|
+
if (block.before === undefined && block.after === undefined)
|
|
214
|
+
return [...(caption ? [style.dim(caption)] : []), style.gray("(empty diff)")];
|
|
215
|
+
return [
|
|
216
|
+
...(caption ? [style.dim(caption)] : []),
|
|
217
|
+
...renderCappedCode(block.before ?? "", block.lang, "before"),
|
|
218
|
+
...renderCappedCode(block.after ?? "", block.lang, "after"),
|
|
219
|
+
];
|
|
220
|
+
}
|
|
221
|
+
function renderTerminalBlock(block) {
|
|
222
|
+
const head = block.command
|
|
223
|
+
? [style.dim(`$ ${block.command}${block.cwd ? ` (${block.cwd})` : ""}`)]
|
|
224
|
+
: [];
|
|
225
|
+
const status = [
|
|
226
|
+
block.exitCode !== undefined ? `exit ${block.exitCode}` : "",
|
|
227
|
+
block.durationMs !== undefined ? formatDuration(block.durationMs) : "",
|
|
228
|
+
]
|
|
229
|
+
.filter(Boolean)
|
|
230
|
+
.join(" · ");
|
|
231
|
+
const paint = block.exitCode !== undefined && block.exitCode !== 0 ? style.red : style.gray;
|
|
232
|
+
return [
|
|
233
|
+
...head,
|
|
234
|
+
...renderCappedCode(sanitizeCode(block.output), undefined),
|
|
235
|
+
...(block.truncated ? [style.gray("… output truncated")] : []),
|
|
236
|
+
...(status ? [paint(status)] : []),
|
|
237
|
+
];
|
|
238
|
+
}
|
|
239
|
+
function renderTestResultsBlock(block, width, unicode) {
|
|
240
|
+
const cases = block.suites.flatMap((suite) => suite.cases.map((c) => ({ suite, c })));
|
|
241
|
+
const counts = new Map();
|
|
242
|
+
for (const { c } of cases)
|
|
243
|
+
counts.set(c.status, (counts.get(c.status) ?? 0) + 1);
|
|
244
|
+
const summary = [
|
|
245
|
+
block.framework,
|
|
246
|
+
["passed", "failed", "skipped", "todo"]
|
|
247
|
+
.filter((status) => counts.get(status))
|
|
248
|
+
.map((status) => `${counts.get(status)} ${status}`)
|
|
249
|
+
.join(", ") || "no tests",
|
|
250
|
+
block.durationMs !== undefined ? formatDuration(block.durationMs) : undefined,
|
|
251
|
+
]
|
|
252
|
+
.filter(Boolean)
|
|
253
|
+
.join(" · ");
|
|
254
|
+
const table = renderTableBlock({
|
|
255
|
+
kind: "table",
|
|
256
|
+
columns: ["suite", "case", "status", "time"],
|
|
257
|
+
rows: cases.map(({ suite, c }) => [
|
|
258
|
+
suite.name,
|
|
259
|
+
c.name,
|
|
260
|
+
c.status,
|
|
261
|
+
c.durationMs !== undefined ? formatDuration(c.durationMs) : "",
|
|
262
|
+
]),
|
|
263
|
+
caption: summary,
|
|
264
|
+
}, width);
|
|
265
|
+
const errors = cases
|
|
266
|
+
.filter(({ c }) => c.status === "failed" && c.error)
|
|
267
|
+
.map(({ suite, c }) => style.red(`${unicode ? "✗" : "x"} ${suite.name} ${unicode ? "›" : ">"} ${c.name}${suite.file ? ` (${suite.file}${c.line !== undefined ? `:${c.line}` : ""})` : ""}: ${c.error}`));
|
|
268
|
+
return [...table, ...errors];
|
|
269
|
+
}
|
|
270
|
+
const PROGRESS_GLYPHS = {
|
|
271
|
+
unicode: { completed: "✓", running: "●", pending: "○", failed: "✗", cancelled: "⊘" },
|
|
272
|
+
ascii: { completed: "[x]", running: "[>]", pending: "[ ]", failed: "[!]", cancelled: "[-]" },
|
|
273
|
+
};
|
|
274
|
+
function renderProgressBlock(block, unicode) {
|
|
275
|
+
const glyphs = PROGRESS_GLYPHS[unicode ? "unicode" : "ascii"];
|
|
276
|
+
const paint = {
|
|
277
|
+
completed: style.green,
|
|
278
|
+
running: style.brightCyan,
|
|
279
|
+
pending: style.gray,
|
|
280
|
+
failed: style.red,
|
|
281
|
+
cancelled: style.gray,
|
|
282
|
+
};
|
|
283
|
+
return [
|
|
284
|
+
...(block.title ? [style.bold(block.title)] : []),
|
|
285
|
+
...(block.steps.length ? [] : [style.gray("(no steps)")]),
|
|
286
|
+
...block.steps.map((step) => `${paint[step.status](glyphs[step.status])} ${step.label}${step.detail ? ` ${style.dim(step.detail)}` : ""}`),
|
|
287
|
+
];
|
|
288
|
+
}
|
|
289
|
+
/** Text fallback for a kind this build does not know (or a malformed block): never throws. */
|
|
290
|
+
function renderUnknownBlock(block) {
|
|
291
|
+
const kind = block && typeof block === "object" && typeof block.kind === "string"
|
|
292
|
+
? block.kind
|
|
293
|
+
: "unknown";
|
|
294
|
+
return [style.gray(`[ui block: ${kind}]`), ...renderCappedCode(jsonText(block), "json")];
|
|
295
|
+
}
|
|
296
|
+
/** Local path of a published artifact (set by the TUI from the artifact store). */
|
|
297
|
+
let artifactPath;
|
|
298
|
+
export function setArtifactPathResolver(resolve) {
|
|
299
|
+
artifactPath = resolve;
|
|
300
|
+
}
|
|
301
|
+
/** The two-line announcement of an `artifact` block (kind, name, size and local path). */
|
|
302
|
+
export function renderArtifactBlock(block, width, unicode, path) {
|
|
303
|
+
return artifactLines(block.artifact, {
|
|
304
|
+
width,
|
|
305
|
+
unicode,
|
|
306
|
+
home: homedir(),
|
|
307
|
+
...((path ?? artifactPath?.(block.artifact.id))
|
|
308
|
+
? { path: path ?? artifactPath?.(block.artifact.id) }
|
|
309
|
+
: {}),
|
|
310
|
+
});
|
|
311
|
+
}
|
|
312
|
+
/**
|
|
313
|
+
* Renders a `{type:"ui"}` block to styled lines for the given inner width. Blocks come from
|
|
314
|
+
* persisted transcripts too, so anything that does not validate (a kind added by a newer Alisio,
|
|
315
|
+
* or a malformed plugin block) falls back to labeled JSON instead of throwing.
|
|
316
|
+
*/
|
|
317
|
+
export function renderUiBlock(block, width, unicode) {
|
|
318
|
+
if (!isUiBlock(block))
|
|
319
|
+
return renderUnknownBlock(block);
|
|
320
|
+
switch (block.kind) {
|
|
321
|
+
case "table":
|
|
322
|
+
return renderTableBlock(block, width);
|
|
323
|
+
case "key-value":
|
|
324
|
+
return renderKeyValueBlock(block, width);
|
|
325
|
+
case "tree":
|
|
326
|
+
return renderTreeLines(block.nodes, unicode);
|
|
327
|
+
case "code":
|
|
328
|
+
return renderCodeBlock(block, width);
|
|
329
|
+
case "markdown": {
|
|
330
|
+
const md = new Markdown(block.text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
331
|
+
transform: markdownTransform,
|
|
332
|
+
});
|
|
333
|
+
return md.render(Math.max(1, width - 2));
|
|
334
|
+
}
|
|
335
|
+
case "diff":
|
|
336
|
+
return renderDiffBlock(block);
|
|
337
|
+
case "terminal":
|
|
338
|
+
return renderTerminalBlock(block);
|
|
339
|
+
case "mermaid":
|
|
340
|
+
return renderCappedCode(block.source, "mermaid", block.title);
|
|
341
|
+
case "math":
|
|
342
|
+
return block.latex.split("\n");
|
|
343
|
+
case "json":
|
|
344
|
+
return renderCappedCode(jsonText(block.value), "json", block.caption);
|
|
345
|
+
case "test-results":
|
|
346
|
+
return renderTestResultsBlock(block, width, unicode);
|
|
347
|
+
case "progress":
|
|
348
|
+
return renderProgressBlock(block, unicode);
|
|
349
|
+
case "artifact":
|
|
350
|
+
return renderArtifactBlock(block, width, unicode);
|
|
351
|
+
default:
|
|
352
|
+
return renderUnknownBlock(block);
|
|
353
|
+
}
|
|
354
|
+
}
|
|
11
355
|
const permission = (label, state, priority) => ({
|
|
12
356
|
text: `${label}:${state}`,
|
|
13
357
|
priority,
|
|
@@ -28,20 +372,31 @@ export class Header {
|
|
|
28
372
|
render(width) {
|
|
29
373
|
const i = this.info(), v = this.view();
|
|
30
374
|
const line1 = line([
|
|
31
|
-
{ text: "◆
|
|
375
|
+
{ text: "◆ Alisio Code", priority: 8, paint: (t) => style.bold(style.brightCyan(t)) },
|
|
32
376
|
{ text: `v${i.version}`, priority: 1, paint: style.gray },
|
|
33
377
|
{
|
|
34
|
-
text: v.model || "not connected",
|
|
378
|
+
text: i.modelName || v.model || "not connected",
|
|
35
379
|
priority: 10,
|
|
36
|
-
paint: v.model ? style.bold : style.yellow,
|
|
380
|
+
paint: v.model ? (t) => style.bold(style.cyan(t)) : style.yellow,
|
|
37
381
|
},
|
|
38
|
-
|
|
39
|
-
{ text: i.
|
|
382
|
+
// `model · provider · effort` with each piece in a distinct color.
|
|
383
|
+
...(i.provider ? [{ text: i.provider, priority: 6, paint: style.magenta }] : []),
|
|
384
|
+
...(i.effort ? [{ text: i.effort, priority: 5, paint: style.yellow }] : []),
|
|
385
|
+
{ text: i.host, priority: 4, paint: style.gray },
|
|
40
386
|
{ text: i.apiMode, priority: 2, paint: style.gray },
|
|
41
387
|
], width);
|
|
42
388
|
const perms = i.readOnly
|
|
43
389
|
? [{ text: "read-only", priority: 10, paint: style.red }]
|
|
44
390
|
: [
|
|
391
|
+
...(i.mode
|
|
392
|
+
? [
|
|
393
|
+
{
|
|
394
|
+
text: `mode:${i.mode}`,
|
|
395
|
+
priority: 10,
|
|
396
|
+
paint: i.mode === "full" || i.mode === "custom" ? style.red : style.green,
|
|
397
|
+
},
|
|
398
|
+
]
|
|
399
|
+
: []),
|
|
45
400
|
permission("write", i.write, 10),
|
|
46
401
|
permission("process", i.process, 10),
|
|
47
402
|
{
|
|
@@ -53,6 +408,19 @@ export class Header {
|
|
|
53
408
|
const line2 = line([
|
|
54
409
|
{ text: i.cwd, priority: 6, paint: style.cyan },
|
|
55
410
|
{ text: `session ${i.session}`, priority: 4, paint: style.gray },
|
|
411
|
+
// Priority 3 (below session's 4): the branch is ambient context, so on narrow terminals it
|
|
412
|
+
// drops first — before the session ID and permissions. At equal priority fitSegments drops
|
|
413
|
+
// the FIRST lowest-priority segment, which would mis-drop the session when branch sits
|
|
414
|
+
// after it, so the branch keeps a strictly lower priority instead.
|
|
415
|
+
...(i.branch
|
|
416
|
+
? [
|
|
417
|
+
{
|
|
418
|
+
text: branchDisplay(i.branch, terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode),
|
|
419
|
+
priority: 3,
|
|
420
|
+
paint: style.cyan,
|
|
421
|
+
},
|
|
422
|
+
]
|
|
423
|
+
: []),
|
|
56
424
|
...perms,
|
|
57
425
|
], width);
|
|
58
426
|
return fit([line1, line2, style.gray("─".repeat(Math.max(0, width)))], width);
|
|
@@ -63,11 +431,13 @@ export class Footer {
|
|
|
63
431
|
budget;
|
|
64
432
|
hint;
|
|
65
433
|
statuses;
|
|
66
|
-
|
|
434
|
+
identity;
|
|
435
|
+
constructor(view, budget, hint, statuses = () => [], identity) {
|
|
67
436
|
this.view = view;
|
|
68
437
|
this.budget = budget;
|
|
69
438
|
this.hint = hint;
|
|
70
439
|
this.statuses = statuses;
|
|
440
|
+
this.identity = identity;
|
|
71
441
|
}
|
|
72
442
|
invalidate() { }
|
|
73
443
|
render(width) {
|
|
@@ -78,10 +448,14 @@ export class Footer {
|
|
|
78
448
|
const bar = pct === undefined
|
|
79
449
|
? style.gray("░".repeat(cells))
|
|
80
450
|
: levelColor(level)("█".repeat(filled)) + style.gray("░".repeat(cells - filled));
|
|
451
|
+
const quiet = v.streaming ? quietFor(v, clock.now) : 0;
|
|
452
|
+
const quietNote = quiet
|
|
453
|
+
? ` · no response for ${formatDuration(quiet)}${quiet >= STALLED_MS ? " (stuck?)" : ""}`
|
|
454
|
+
: "";
|
|
81
455
|
const state = v.compacting
|
|
82
456
|
? `${SPINNER[clock.frame % SPINNER.length]} compacting`
|
|
83
457
|
: v.streaming
|
|
84
|
-
? `${SPINNER[clock.frame % SPINNER.length]}
|
|
458
|
+
? `${SPINNER[clock.frame % SPINNER.length]} ${runPhase(v).label} ${formatDuration(clock.now - (v.runStartedAt ?? clock.now))}${quietNote}`
|
|
85
459
|
: "● idle";
|
|
86
460
|
const s = v.stats;
|
|
87
461
|
const segments = [
|
|
@@ -90,7 +464,15 @@ export class Footer {
|
|
|
90
464
|
priority: 10,
|
|
91
465
|
paint: pct === undefined ? style.gray : levelColor(level),
|
|
92
466
|
},
|
|
93
|
-
{
|
|
467
|
+
{
|
|
468
|
+
text: state,
|
|
469
|
+
priority: 9,
|
|
470
|
+
paint: quiet >= STALLED_MS
|
|
471
|
+
? style.yellow
|
|
472
|
+
: v.streaming || v.compacting
|
|
473
|
+
? style.cyan
|
|
474
|
+
: style.green,
|
|
475
|
+
},
|
|
94
476
|
{
|
|
95
477
|
text: `↑${formatTokens(s.input)} ↓${formatTokens(s.output)}${s.cached ? ` ⚡${formatTokens(s.cached)}` : ""}`,
|
|
96
478
|
priority: 6,
|
|
@@ -109,7 +491,23 @@ export class Footer {
|
|
|
109
491
|
(v.streaming || v.compacting
|
|
110
492
|
? "Esc interrupt · Ctrl+C clear/exit"
|
|
111
493
|
: "Enter send · Shift+Enter newline · /help commands · Ctrl+D exit");
|
|
112
|
-
|
|
494
|
+
// Status row below the editor: `agent: <name> · <model> · <provider> · <effort>`, each piece
|
|
495
|
+
// in a distinct color (model bold/cyan, provider magenta, effort yellow); the effort part is
|
|
496
|
+
// omitted when the active model advertises no supported levels. Truncation drops the lowest
|
|
497
|
+
// priority parts first (effort, then provider) before truncating what remains.
|
|
498
|
+
const identity = this.identity?.();
|
|
499
|
+
const identityLine = identity
|
|
500
|
+
? fitIdentityParts(identity, width)
|
|
501
|
+
.map((part) => ({
|
|
502
|
+
agent: (t) => style.bold(style.magenta(t)),
|
|
503
|
+
mode: (t) => (/full|custom/.test(t) ? style.red(t) : style.green(t)),
|
|
504
|
+
model: (t) => style.bold(style.cyan(t)),
|
|
505
|
+
provider: style.magenta,
|
|
506
|
+
effort: style.yellow,
|
|
507
|
+
})[part.role](part.text))
|
|
508
|
+
.join(style.gray(" · "))
|
|
509
|
+
: undefined;
|
|
510
|
+
return fit(identityLine ? [identityLine, line1, style.dim(hint)] : [line1, style.dim(hint)], width);
|
|
113
511
|
}
|
|
114
512
|
}
|
|
115
513
|
export class UserBlock {
|
|
@@ -130,49 +528,107 @@ export class UserBlock {
|
|
|
130
528
|
];
|
|
131
529
|
}
|
|
132
530
|
}
|
|
531
|
+
/**
|
|
532
|
+
* Subtle, non-interactive copy affordance under a completed response: it only tells the user the
|
|
533
|
+
* raw response can be copied (`/copy`, or `c` on an empty input); it never triggers itself.
|
|
534
|
+
* Falls back to ASCII when the terminal cannot render Unicode (dumb terminal / C locale).
|
|
535
|
+
*/
|
|
536
|
+
const copyHint = (width) => {
|
|
537
|
+
const unicode = terminalCapabilities({ env: process.env, columns: width, tty: true }).unicode;
|
|
538
|
+
return ` ${style.dim(unicode ? "⎘ copy · /copy" : "[copy] · /copy")}`;
|
|
539
|
+
};
|
|
133
540
|
export class AssistantBlock {
|
|
134
|
-
markdown = new Markdown("", 0, 0, markdownTheme
|
|
541
|
+
markdown = new Markdown("", 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
542
|
+
transform: markdownTransform,
|
|
543
|
+
});
|
|
544
|
+
/** Bumped by TranscriptSync when this row's content changes; render caches on it. */
|
|
545
|
+
version = 0;
|
|
546
|
+
fold;
|
|
135
547
|
item;
|
|
548
|
+
cache;
|
|
136
549
|
constructor(item) {
|
|
137
550
|
this.item = item;
|
|
138
551
|
this.markdown.setText(item.text);
|
|
139
552
|
}
|
|
140
|
-
update(item) {
|
|
553
|
+
update(item, fold) {
|
|
141
554
|
if (item.text !== this.item.text)
|
|
142
555
|
this.markdown.setText(item.text);
|
|
143
556
|
this.item = item;
|
|
557
|
+
this.fold = fold;
|
|
144
558
|
}
|
|
145
559
|
invalidate() {
|
|
146
560
|
this.markdown.invalidate();
|
|
561
|
+
this.cache = undefined;
|
|
147
562
|
}
|
|
148
563
|
render(width) {
|
|
149
|
-
const lines = [""];
|
|
150
564
|
const reasoning = this.item.reasoning.trim();
|
|
565
|
+
// The live thinking view animates with streaming deltas and must never be cached.
|
|
566
|
+
const live = !!reasoning && !this.item.text && !this.item.done;
|
|
567
|
+
if (!live &&
|
|
568
|
+
this.cache?.width === width &&
|
|
569
|
+
this.cache.version === this.version &&
|
|
570
|
+
this.cache.fold?.expanded === this.fold?.expanded)
|
|
571
|
+
return this.cache.lines;
|
|
572
|
+
const lines = [""];
|
|
151
573
|
if (reasoning) {
|
|
152
|
-
if (
|
|
153
|
-
const words = reasoning.split(/\s+/).length;
|
|
154
|
-
lines.push(style.dim(style.italic(`✻ reasoning (${words} words)`)));
|
|
155
|
-
}
|
|
156
|
-
else {
|
|
574
|
+
if (live) {
|
|
157
575
|
lines.push(style.dim(style.italic("✻ thinking…")));
|
|
158
576
|
lines.push(...wrap(reasoning, width - 2)
|
|
159
577
|
.slice(-4)
|
|
160
578
|
.map((l) => style.dim(` ${l}`)));
|
|
161
579
|
}
|
|
580
|
+
else {
|
|
581
|
+
// Finished: a collapsible `+ Thought · 2.9s` header; expanded shows the bounded text.
|
|
582
|
+
const expanded = this.fold?.expanded ?? false;
|
|
583
|
+
const duration = reasoningDurationMs(this.item);
|
|
584
|
+
lines.push(`${style.dim(expanded ? "−" : "+")} ${style.dim(style.italic("Thought"))}${duration !== undefined ? ` ${style.dim(`· ${formatDuration(duration)}`)}` : ""}`);
|
|
585
|
+
if (expanded) {
|
|
586
|
+
const wrapped = wrap(reasoning, Math.max(1, width - 4));
|
|
587
|
+
lines.push(...wrapped.slice(0, REASONING_EXPAND_MAX_LINES).map((l) => ` ${style.dim(l)}`));
|
|
588
|
+
if (wrapped.length > REASONING_EXPAND_MAX_LINES)
|
|
589
|
+
lines.push(style.gray(` … reasoning truncated (${wrapped.length - REASONING_EXPAND_MAX_LINES} more lines)`));
|
|
590
|
+
}
|
|
591
|
+
}
|
|
162
592
|
}
|
|
163
|
-
if (this.item.text)
|
|
593
|
+
if (this.item.text) {
|
|
164
594
|
lines.push(...this.markdown.render(width));
|
|
165
|
-
|
|
595
|
+
// The affordance appears only once the answer is complete (never while streaming).
|
|
596
|
+
if (this.item.done)
|
|
597
|
+
lines.push(copyHint(width));
|
|
598
|
+
}
|
|
599
|
+
const out = fit(lines, width);
|
|
600
|
+
if (!live)
|
|
601
|
+
this.cache = { width, version: this.version, fold: this.fold, lines: out };
|
|
602
|
+
return out;
|
|
166
603
|
}
|
|
167
604
|
}
|
|
168
605
|
export class ToolBlock {
|
|
169
606
|
item;
|
|
607
|
+
/** Bumped by TranscriptSync when this row's content changes; render caches on it. */
|
|
608
|
+
version = 0;
|
|
609
|
+
fold;
|
|
610
|
+
markdown;
|
|
611
|
+
cache;
|
|
170
612
|
constructor(item) {
|
|
171
613
|
this.item = item;
|
|
172
614
|
}
|
|
173
|
-
|
|
615
|
+
update(item, fold) {
|
|
616
|
+
this.item = item;
|
|
617
|
+
this.fold = fold;
|
|
618
|
+
}
|
|
619
|
+
invalidate() {
|
|
620
|
+
this.cache = undefined;
|
|
621
|
+
}
|
|
174
622
|
render(width) {
|
|
175
623
|
const i = this.item;
|
|
624
|
+
// Running/approval rows animate and must never be cached; terminal rows only change when
|
|
625
|
+
// TranscriptSync bumps `version`, so clock frames between events reuse the cached lines.
|
|
626
|
+
const live = i.status === "running" || i.status === "approval";
|
|
627
|
+
if (!live &&
|
|
628
|
+
this.cache?.width === width &&
|
|
629
|
+
this.cache.version === this.version &&
|
|
630
|
+
this.cache.fold?.expanded === this.fold?.expanded)
|
|
631
|
+
return this.cache.lines;
|
|
176
632
|
const icon = i.status === "running"
|
|
177
633
|
? style.cyan(SPINNER[clock.frame % SPINNER.length] ?? "…")
|
|
178
634
|
: i.status === "approval"
|
|
@@ -180,12 +636,13 @@ export class ToolBlock {
|
|
|
180
636
|
: i.status === "ok"
|
|
181
637
|
? style.green("✓")
|
|
182
638
|
: style.red("✗");
|
|
639
|
+
const marker = this.fold ? `${style.dim(this.fold.expanded ? "−" : "+")} ` : "";
|
|
183
640
|
const right = i.status === "approval"
|
|
184
641
|
? style.yellow("awaiting approval")
|
|
185
642
|
: i.durationMs !== undefined
|
|
186
643
|
? style.gray(formatDuration(i.durationMs))
|
|
187
644
|
: "";
|
|
188
|
-
const head = `${icon} ${style.bold(i.name)} ${style.gray(i.summary)}`;
|
|
645
|
+
const head = `${marker}${icon} ${style.bold(humanizeToolName(i.name))} ${style.gray(i.summary)}`;
|
|
189
646
|
const gap = width - visibleWidth(head) - visibleWidth(right) - 1;
|
|
190
647
|
const lines = [
|
|
191
648
|
gap > 0 ? `${head}${" ".repeat(gap + 1)}${right}` : truncateToWidth(head, width),
|
|
@@ -199,17 +656,93 @@ export class ToolBlock {
|
|
|
199
656
|
if (diff.lines.length > max)
|
|
200
657
|
lines.push(style.gray(` … ${diff.lines.length - max} more lines`));
|
|
201
658
|
}
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
659
|
+
// Native rich rendering: an inline image (when the terminal supports images and color is
|
|
660
|
+
// on) and/or a structured ui block replace the plain preview, exactly as the emitted text
|
|
661
|
+
// projection reads. Every rich line is indented two spaces like the preview lines.
|
|
662
|
+
const rich = richBodyLines(i, width, this.markdown);
|
|
663
|
+
const richLines = rich.lines;
|
|
664
|
+
if (rich.markdownCache)
|
|
665
|
+
this.markdown = rich.markdownCache;
|
|
666
|
+
if (richLines.length) {
|
|
667
|
+
lines.push(...richLines.map((line) => ` ${line}`));
|
|
668
|
+
}
|
|
669
|
+
else if (i.preview && (i.status === "error" || !diff)) {
|
|
670
|
+
const rows = previewRows(i.preview);
|
|
671
|
+
const cap = previewLinesFor(i.status);
|
|
672
|
+
const expanded = this.fold?.expanded ?? false;
|
|
673
|
+
const shown = expanded ? rows : rows.slice(0, cap);
|
|
205
674
|
const paint = i.status === "error" ? style.red : style.gray;
|
|
206
675
|
shown.forEach((l, n) => {
|
|
207
676
|
lines.push(` ${style.gray(n === 0 ? "⎿" : " ")} ${paint(l.replace(/\t/g, " "))}`);
|
|
208
677
|
});
|
|
209
|
-
if (
|
|
210
|
-
lines.push(style.gray(` … ${
|
|
678
|
+
if (!expanded && rows.length > shown.length)
|
|
679
|
+
lines.push(style.gray(` … ${rows.length - shown.length} more lines`));
|
|
211
680
|
}
|
|
212
|
-
|
|
681
|
+
const exitLine = exitCodeLine(i.exitCode);
|
|
682
|
+
if (exitLine)
|
|
683
|
+
lines.push(exitLine);
|
|
684
|
+
const out = fit(lines, width);
|
|
685
|
+
if (!live)
|
|
686
|
+
this.cache = { width, version: this.version, fold: this.fold, lines: out };
|
|
687
|
+
return out;
|
|
688
|
+
}
|
|
689
|
+
}
|
|
690
|
+
/**
|
|
691
|
+
* One collapsible row for a GROUPED batch of finished tool calls (e.g. `✓ Read File — 3 reads`).
|
|
692
|
+
* Collapsed it shows the aggregate header; expanded, each call with its status, duration,
|
|
693
|
+
* summary and capped preview/rich body. All members are terminal, so lines are cached per
|
|
694
|
+
* version/width/fold just like the other terminal rows.
|
|
695
|
+
*/
|
|
696
|
+
export class ToolGroupBlock {
|
|
697
|
+
group;
|
|
698
|
+
/** Bumped by TranscriptSync when this row's content changes; render caches on it. */
|
|
699
|
+
version = 0;
|
|
700
|
+
fold;
|
|
701
|
+
cache;
|
|
702
|
+
constructor(group) {
|
|
703
|
+
this.group = group;
|
|
704
|
+
}
|
|
705
|
+
update(group, fold) {
|
|
706
|
+
this.group = group;
|
|
707
|
+
this.fold = fold;
|
|
708
|
+
}
|
|
709
|
+
invalidate() {
|
|
710
|
+
this.cache = undefined;
|
|
711
|
+
}
|
|
712
|
+
render(width) {
|
|
713
|
+
if (this.cache?.width === width &&
|
|
714
|
+
this.cache.version === this.version &&
|
|
715
|
+
this.cache.fold?.expanded === this.fold?.expanded)
|
|
716
|
+
return this.cache.lines;
|
|
717
|
+
const g = this.group;
|
|
718
|
+
const expanded = this.fold?.expanded ?? false;
|
|
719
|
+
const icon = g.status === "error" ? style.red("✗") : style.green("✓");
|
|
720
|
+
const head = `${style.dim(expanded ? "−" : "+")} ${icon} ${style.bold(g.label)} — ${g.members.length} ${g.word}${g.totalMs > 0 ? ` ${style.gray(`· ${formatDuration(g.totalMs)}`)}` : ""}`;
|
|
721
|
+
const lines = [head];
|
|
722
|
+
if (expanded) {
|
|
723
|
+
for (const member of g.members) {
|
|
724
|
+
const micon = member.status === "error" ? style.red("✗") : style.green("✓");
|
|
725
|
+
const name = member.humanName === member.name
|
|
726
|
+
? style.bold(member.humanName)
|
|
727
|
+
: `${style.bold(member.humanName)} ${style.dim(`(${member.name})`)}`;
|
|
728
|
+
lines.push(` ${micon} ${name}${member.durationMs !== undefined
|
|
729
|
+
? ` ${style.gray(`· ${formatDuration(member.durationMs)}`)}`
|
|
730
|
+
: ""}${member.summary ? ` ${style.gray(member.summary)}` : ""}`);
|
|
731
|
+
const rich = richBodyLines(member, width);
|
|
732
|
+
if (rich.lines.length) {
|
|
733
|
+
lines.push(...rich.lines.map((line) => ` ${line}`));
|
|
734
|
+
}
|
|
735
|
+
else {
|
|
736
|
+
lines.push(...memberPreviewLines(member, " "));
|
|
737
|
+
}
|
|
738
|
+
const exitLine = exitCodeLine(member.exitCode, " ");
|
|
739
|
+
if (exitLine)
|
|
740
|
+
lines.push(exitLine);
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
const out = fit(lines, width);
|
|
744
|
+
this.cache = { width, version: this.version, fold: this.fold, lines: out };
|
|
745
|
+
return out;
|
|
213
746
|
}
|
|
214
747
|
}
|
|
215
748
|
export class LineBlock {
|
|
@@ -229,7 +762,9 @@ export class LineBlock {
|
|
|
229
762
|
export class InfoBlock {
|
|
230
763
|
markdown;
|
|
231
764
|
constructor(text) {
|
|
232
|
-
this.markdown = new Markdown(text, 1, 0, markdownTheme
|
|
765
|
+
this.markdown = new Markdown(text, 1, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
766
|
+
transform: markdownTransform,
|
|
767
|
+
});
|
|
233
768
|
}
|
|
234
769
|
invalidate() {
|
|
235
770
|
this.markdown.invalidate();
|
|
@@ -239,18 +774,184 @@ export class InfoBlock {
|
|
|
239
774
|
return fit(["", ...inner.map((l) => `${style.gray("│")} ${l}`)], width);
|
|
240
775
|
}
|
|
241
776
|
}
|
|
242
|
-
|
|
243
|
-
|
|
777
|
+
/**
|
|
778
|
+
* Minimum usable inner width (cells) the transcript keeps no matter how narrow the terminal is or
|
|
779
|
+
* how large the configured inset grows. The effective inset is clamped so this floor is honored,
|
|
780
|
+
* which also keeps the inner width positive.
|
|
781
|
+
*/
|
|
782
|
+
export const MIN_CONTENT_WIDTH = 20;
|
|
783
|
+
/**
|
|
784
|
+
* Inset actually applied on each side: the configured value capped so the inner column stays at
|
|
785
|
+
* least `MIN_CONTENT_WIDTH` wide. On terminals narrower than that floor the inset degrades to 0
|
|
786
|
+
* rather than overflowing (the inner width then simply equals the terminal width).
|
|
787
|
+
*/
|
|
788
|
+
export function effectiveContentInset(width, inset) {
|
|
789
|
+
const requested = Math.max(0, Math.floor(inset));
|
|
790
|
+
const max = Math.max(0, Math.floor((Math.floor(width) - MIN_CONTENT_WIDTH) / 2));
|
|
791
|
+
return Math.min(requested, max);
|
|
792
|
+
}
|
|
793
|
+
/** Inner width handed to transcript children after the effective inset is applied. Always >= 1. */
|
|
794
|
+
export function contentInnerWidth(width, inset) {
|
|
795
|
+
return Math.max(1, Math.floor(width) - effectiveContentInset(width, inset) * 2);
|
|
796
|
+
}
|
|
797
|
+
/**
|
|
798
|
+
* Applies the transcript's horizontal inset in ONE place: it hands children a reduced width and
|
|
799
|
+
* prefixes every produced line with the inset, so all transcript chrome (text, copy hint, tool
|
|
800
|
+
* sub-lines, reasoning lines, group headers) shares the same visual column. The reduced width is a
|
|
801
|
+
* deterministic function of the terminal width, so per-row caches keyed by width stay effective.
|
|
802
|
+
*/
|
|
803
|
+
export class ContentInset {
|
|
804
|
+
inner;
|
|
805
|
+
inset;
|
|
806
|
+
constructor(inner, inset) {
|
|
807
|
+
this.inner = inner;
|
|
808
|
+
this.inset = inset;
|
|
809
|
+
}
|
|
810
|
+
invalidate() {
|
|
811
|
+
this.inner.invalidate();
|
|
812
|
+
}
|
|
813
|
+
render(width) {
|
|
814
|
+
const inset = effectiveContentInset(width, this.inset());
|
|
815
|
+
const lines = this.inner.render(contentInnerWidth(width, inset));
|
|
816
|
+
if (!inset)
|
|
817
|
+
return lines;
|
|
818
|
+
const pad = " ".repeat(inset);
|
|
819
|
+
return lines.map((line) => `${pad}${line}`);
|
|
820
|
+
}
|
|
821
|
+
}
|
|
822
|
+
export function componentFor(entry) {
|
|
823
|
+
if (entry.kind === "group")
|
|
824
|
+
return new ToolGroupBlock(entry.group);
|
|
825
|
+
switch (entry.kind) {
|
|
244
826
|
case "user":
|
|
245
|
-
return new UserBlock(
|
|
827
|
+
return new UserBlock(entry.text);
|
|
246
828
|
case "assistant":
|
|
247
|
-
return new AssistantBlock(
|
|
829
|
+
return new AssistantBlock(entry);
|
|
248
830
|
case "tool":
|
|
249
|
-
return new ToolBlock(
|
|
831
|
+
return new ToolBlock(entry);
|
|
250
832
|
case "info":
|
|
251
|
-
return new InfoBlock(
|
|
833
|
+
return new InfoBlock(entry.text);
|
|
252
834
|
default:
|
|
253
|
-
return new LineBlock(
|
|
835
|
+
return new LineBlock(entry.text, entry.kind);
|
|
836
|
+
}
|
|
837
|
+
}
|
|
838
|
+
/** Applies a fresh entry (and its fold state) to a component; kinds never collide under a key. */
|
|
839
|
+
function updateComponent(component, entry, fold) {
|
|
840
|
+
if (entry.kind === "assistant" && component instanceof AssistantBlock)
|
|
841
|
+
return component.update(entry, fold);
|
|
842
|
+
if (entry.kind === "tool" && component instanceof ToolBlock)
|
|
843
|
+
return component.update(entry, fold);
|
|
844
|
+
if (entry.kind === "group" && component instanceof ToolGroupBlock)
|
|
845
|
+
return component.update(entry.group, fold);
|
|
846
|
+
}
|
|
847
|
+
/** Whether a reused component can adopt the (new) entry kind (foldable rows also carry `version`). */
|
|
848
|
+
function isCompatible(component, entry) {
|
|
849
|
+
return ((entry.kind === "assistant" && component instanceof AssistantBlock) ||
|
|
850
|
+
(entry.kind === "tool" && component instanceof ToolBlock) ||
|
|
851
|
+
(entry.kind === "group" && component instanceof ToolGroupBlock) ||
|
|
852
|
+
((entry.kind === "user" ||
|
|
853
|
+
entry.kind === "notice" ||
|
|
854
|
+
entry.kind === "info" ||
|
|
855
|
+
entry.kind === "error") &&
|
|
856
|
+
!(component instanceof AssistantBlock) &&
|
|
857
|
+
!(component instanceof ToolBlock) &&
|
|
858
|
+
!(component instanceof ToolGroupBlock)));
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Keeps a container of transcript components in step with view-model items. Rows are keyed
|
|
862
|
+
* (stable per fold candidate: tool ids, group ids, append-only item indices) and reused across
|
|
863
|
+
* syncs; batch groups replace their members' singleton components with one row once all of them
|
|
864
|
+
* finish. Content versions bump only when a row's entry actually changed, so clock frames
|
|
865
|
+
* between events reuse each component's cached lines instead of re-rendering. `setFoldResolver`
|
|
866
|
+
* supplies the effective expanded state; the same resolution feeds `entryAt`, so keybindings
|
|
867
|
+
* and mouse clicks agree with the renderer.
|
|
868
|
+
*/
|
|
869
|
+
export class TranscriptSync {
|
|
870
|
+
container = new Container();
|
|
871
|
+
rows = new Map();
|
|
872
|
+
order = [];
|
|
873
|
+
foldOf = (candidate) => candidate.defaultExpanded;
|
|
874
|
+
setFoldResolver(resolver) {
|
|
875
|
+
this.foldOf = resolver;
|
|
876
|
+
}
|
|
877
|
+
sync(items) {
|
|
878
|
+
const entries = groupToolEntries(items);
|
|
879
|
+
const kept = new Set();
|
|
880
|
+
for (const { entry, at } of entries) {
|
|
881
|
+
const key = entryKeyOf(entry, at);
|
|
882
|
+
kept.add(key);
|
|
883
|
+
const candidate = foldCandidateOf(entry, at);
|
|
884
|
+
const fold = candidate
|
|
885
|
+
? { expanded: this.foldOf(candidate), kind: candidate.kind }
|
|
886
|
+
: undefined;
|
|
887
|
+
const row = this.rows.get(key);
|
|
888
|
+
if (row && isCompatible(row.component, entry)) {
|
|
889
|
+
const same = row.entry === entry ||
|
|
890
|
+
(entry.kind === "group" &&
|
|
891
|
+
row.entry.kind === "group" &&
|
|
892
|
+
groupIdentity(entry.group) === groupIdentity(row.entry.group));
|
|
893
|
+
if (!same) {
|
|
894
|
+
row.version++;
|
|
895
|
+
row.entry = entry;
|
|
896
|
+
row.at = at;
|
|
897
|
+
}
|
|
898
|
+
updateComponent(row.component, entry, fold);
|
|
899
|
+
if (row.component instanceof AssistantBlock ||
|
|
900
|
+
row.component instanceof ToolBlock ||
|
|
901
|
+
row.component instanceof ToolGroupBlock)
|
|
902
|
+
row.component.version = row.version;
|
|
903
|
+
}
|
|
904
|
+
else {
|
|
905
|
+
// Key collision across kinds (defensive; keys are per-kind in practice): drop any stale
|
|
906
|
+
// component so the container never renders a duplicated row, then add the fresh one.
|
|
907
|
+
if (row)
|
|
908
|
+
this.container.removeChild(row.component);
|
|
909
|
+
const component = componentFor(entry);
|
|
910
|
+
updateComponent(component, entry, fold);
|
|
911
|
+
this.rows.set(key, { entry, at, component, version: 0 });
|
|
912
|
+
this.container.addChild(component);
|
|
913
|
+
}
|
|
914
|
+
}
|
|
915
|
+
for (const [key, row] of this.rows)
|
|
916
|
+
if (!kept.has(key)) {
|
|
917
|
+
this.container.removeChild(row.component);
|
|
918
|
+
this.rows.delete(key);
|
|
919
|
+
}
|
|
920
|
+
this.order = entries.map(({ entry, at }) => entryKeyOf(entry, at));
|
|
921
|
+
// Preserve external children (startup banner) at the front, then managed rows in item order.
|
|
922
|
+
const managed = new Set(this.order.map((k) => this.rows.get(k)?.component).filter((c) => !!c));
|
|
923
|
+
this.container.children = [
|
|
924
|
+
...this.container.children.filter((c) => !managed.has(c)),
|
|
925
|
+
...this.order.map((k) => this.rows.get(k).component),
|
|
926
|
+
];
|
|
927
|
+
}
|
|
928
|
+
/**
|
|
929
|
+
* Fold candidate at a transcript content row, when the click landed on the row's header
|
|
930
|
+
* (assistant entries start with a blank spacer line, so their first two rows count). Used by
|
|
931
|
+
* click-to-toggle; keyboard toggling uses `foldCandidates` instead and needs no heights.
|
|
932
|
+
*/
|
|
933
|
+
entryAt(contentY, width) {
|
|
934
|
+
let acc = 0;
|
|
935
|
+
for (const key of this.order) {
|
|
936
|
+
const row = this.rows.get(key);
|
|
937
|
+
if (!row)
|
|
938
|
+
continue;
|
|
939
|
+
const height = row.component.render(width).length;
|
|
940
|
+
if (contentY >= acc && contentY < acc + height) {
|
|
941
|
+
const candidate = foldCandidateOf(row.entry, row.at);
|
|
942
|
+
if (!candidate)
|
|
943
|
+
return undefined;
|
|
944
|
+
const headerRows = row.entry.kind === "assistant" ? 2 : 1;
|
|
945
|
+
return contentY < acc + headerRows ? candidate : undefined;
|
|
946
|
+
}
|
|
947
|
+
acc += height;
|
|
948
|
+
}
|
|
949
|
+
return undefined;
|
|
950
|
+
}
|
|
951
|
+
reset() {
|
|
952
|
+
this.rows.clear();
|
|
953
|
+
this.order = [];
|
|
954
|
+
this.container.clear();
|
|
254
955
|
}
|
|
255
956
|
}
|
|
256
957
|
/** Startup screen rendered by core for the current width (cached until the width changes). */
|
|
@@ -269,34 +970,6 @@ export class BannerBlock {
|
|
|
269
970
|
return fit(this.cache.lines, width);
|
|
270
971
|
}
|
|
271
972
|
}
|
|
272
|
-
/** Keeps a container of transcript components in step with view-model items. */
|
|
273
|
-
export class TranscriptSync {
|
|
274
|
-
container = new Container();
|
|
275
|
-
rendered = [];
|
|
276
|
-
sync(items) {
|
|
277
|
-
if (items.length < this.rendered.length)
|
|
278
|
-
this.reset();
|
|
279
|
-
items.forEach((item, index) => {
|
|
280
|
-
const entry = this.rendered[index];
|
|
281
|
-
if (!entry) {
|
|
282
|
-
const component = componentFor(item);
|
|
283
|
-
this.container.addChild(component);
|
|
284
|
-
this.rendered.push({ item, component });
|
|
285
|
-
}
|
|
286
|
-
else if (entry.item !== item) {
|
|
287
|
-
if (entry.component instanceof AssistantBlock && item.kind === "assistant")
|
|
288
|
-
entry.component.update(item);
|
|
289
|
-
else if (entry.component instanceof ToolBlock && item.kind === "tool")
|
|
290
|
-
entry.component.item = item;
|
|
291
|
-
entry.item = item;
|
|
292
|
-
}
|
|
293
|
-
});
|
|
294
|
-
}
|
|
295
|
-
reset() {
|
|
296
|
-
this.container.clear();
|
|
297
|
-
this.rendered = [];
|
|
298
|
-
}
|
|
299
|
-
}
|
|
300
973
|
/** Renders one of several components (main conversation or a read-only child view). */
|
|
301
974
|
export class Switch {
|
|
302
975
|
pick;
|
|
@@ -485,3 +1158,226 @@ export class QuestionPanel {
|
|
|
485
1158
|
return fit(lines, width);
|
|
486
1159
|
}
|
|
487
1160
|
}
|
|
1161
|
+
/**
|
|
1162
|
+
* The plan review panel (`exit_plan`): the three decisions under the plan the transcript already
|
|
1163
|
+
* shows. "Add context" turns the panel into a one-line text field (Enter sends, Esc goes back);
|
|
1164
|
+
* Esc while choosing is "Skip for now". The logic lives in `plan-review.ts`.
|
|
1165
|
+
*/
|
|
1166
|
+
export class PlanReviewPanel {
|
|
1167
|
+
header;
|
|
1168
|
+
onSubmit;
|
|
1169
|
+
placeholder;
|
|
1170
|
+
state;
|
|
1171
|
+
constructor(options, header, onSubmit, placeholder = "Add context for the plan") {
|
|
1172
|
+
this.header = header;
|
|
1173
|
+
this.onSubmit = onSubmit;
|
|
1174
|
+
this.placeholder = placeholder;
|
|
1175
|
+
this.state = initialPlanReviewState(options);
|
|
1176
|
+
}
|
|
1177
|
+
invalidate() { }
|
|
1178
|
+
dispatch(action) {
|
|
1179
|
+
const { state, effect } = reducePlanReview(this.state, action);
|
|
1180
|
+
this.state = state;
|
|
1181
|
+
if (effect)
|
|
1182
|
+
this.onSubmit(effect);
|
|
1183
|
+
}
|
|
1184
|
+
handleInput(data) {
|
|
1185
|
+
const text = this.state.mode === "text";
|
|
1186
|
+
if (matchesKey(data, Key.escape))
|
|
1187
|
+
return this.dispatch({ type: "escape" });
|
|
1188
|
+
if (matchesKey(data, Key.enter))
|
|
1189
|
+
return this.dispatch({ type: "confirm" });
|
|
1190
|
+
if (!text) {
|
|
1191
|
+
if (matchesKey(data, Key.up))
|
|
1192
|
+
return this.dispatch({ type: "up" });
|
|
1193
|
+
if (matchesKey(data, Key.down))
|
|
1194
|
+
return this.dispatch({ type: "down" });
|
|
1195
|
+
return;
|
|
1196
|
+
}
|
|
1197
|
+
if (matchesKey(data, Key.left))
|
|
1198
|
+
return this.dispatch({ type: "left" });
|
|
1199
|
+
if (matchesKey(data, Key.right))
|
|
1200
|
+
return this.dispatch({ type: "right" });
|
|
1201
|
+
if (matchesKey(data, Key.home))
|
|
1202
|
+
return this.dispatch({ type: "home" });
|
|
1203
|
+
if (matchesKey(data, Key.end))
|
|
1204
|
+
return this.dispatch({ type: "end" });
|
|
1205
|
+
if (matchesKey(data, Key.backspace))
|
|
1206
|
+
return this.dispatch({ type: "backspace" });
|
|
1207
|
+
if (matchesKey(data, Key.delete))
|
|
1208
|
+
return this.dispatch({ type: "delete" });
|
|
1209
|
+
this.dispatch({ type: "insert", text: data });
|
|
1210
|
+
}
|
|
1211
|
+
render(width) {
|
|
1212
|
+
const s = this.state;
|
|
1213
|
+
const lines = [];
|
|
1214
|
+
const { title, revision, artifactId, question } = this.header;
|
|
1215
|
+
lines.push(truncateToWidth(`${style.bold(style.yellow("Plan review"))} ${style.dim(`${title}${revision > 1 ? ` · revision ${revision}` : ""}`)}`, width));
|
|
1216
|
+
lines.push(...wrap(question, Math.max(1, width - 2)).map((l) => ` ${l}`));
|
|
1217
|
+
s.options.forEach((option, i) => {
|
|
1218
|
+
const focused = i === s.cursor;
|
|
1219
|
+
const cursor = focused ? style.cyan("❯ ") : " ";
|
|
1220
|
+
const label = focused ? style.bold(style.cyan(option.label)) : option.label;
|
|
1221
|
+
const tag = option.recommended ? ` ${style.green("(recommended)")}` : "";
|
|
1222
|
+
lines.push(truncateToWidth(`${cursor}${label}${tag}`, width));
|
|
1223
|
+
});
|
|
1224
|
+
if (s.mode === "text") {
|
|
1225
|
+
const value = Array.from(s.text.value);
|
|
1226
|
+
const before = value.slice(0, s.text.cursor).join("");
|
|
1227
|
+
const current = value[s.text.cursor] ?? " ";
|
|
1228
|
+
const after = value.slice(s.text.cursor + 1).join("");
|
|
1229
|
+
lines.push(truncateToWidth(value.length
|
|
1230
|
+
? ` ${style.brightCyan("›")} ${before}${style.brightCyan(`▏${current}`)}${after}`
|
|
1231
|
+
: ` ${style.brightCyan("›")} ${style.dim(this.placeholder)} ${style.brightCyan("▏")}`, width));
|
|
1232
|
+
}
|
|
1233
|
+
if (artifactId)
|
|
1234
|
+
lines.push(style.dim(` Saved as plan.md (artifact ${artifactId}) · /artifacts`));
|
|
1235
|
+
lines.push(style.dim(` ${s.mode === "text" ? "Enter send · Esc back to the choices" : "↑↓ select · Enter confirm · Esc skip for now"}`));
|
|
1236
|
+
return fit(lines, width);
|
|
1237
|
+
}
|
|
1238
|
+
}
|
|
1239
|
+
/**
|
|
1240
|
+
* The `/btw` side-question panel (picker slot, never the transcript): a pending question with a
|
|
1241
|
+
* spinner, then the answer rendered as Markdown like assistant text, scrollable, with ←/→ through
|
|
1242
|
+
* the session's earlier side answers. `onClose` runs on Esc (the app cancels a pending question).
|
|
1243
|
+
*/
|
|
1244
|
+
/**
|
|
1245
|
+
* A scrollable Markdown panel in the picker slot (`/changelog`): Up/Down and PgUp/PgDn scroll,
|
|
1246
|
+
* Home/End jump, Esc or q closes. Pure rendering: the text and the height come from the caller.
|
|
1247
|
+
*/
|
|
1248
|
+
export class MarkdownPanel {
|
|
1249
|
+
title;
|
|
1250
|
+
markdown;
|
|
1251
|
+
onClose;
|
|
1252
|
+
height;
|
|
1253
|
+
scroll = 0;
|
|
1254
|
+
cache;
|
|
1255
|
+
constructor(title, markdown, onClose, height) {
|
|
1256
|
+
this.title = title;
|
|
1257
|
+
this.markdown = markdown;
|
|
1258
|
+
this.onClose = onClose;
|
|
1259
|
+
this.height = height;
|
|
1260
|
+
}
|
|
1261
|
+
invalidate() {
|
|
1262
|
+
this.cache = undefined;
|
|
1263
|
+
}
|
|
1264
|
+
handleInput(data) {
|
|
1265
|
+
if (matchesKey(data, Key.escape) || data === "q")
|
|
1266
|
+
return this.onClose();
|
|
1267
|
+
const page = Math.max(1, this.height());
|
|
1268
|
+
if (matchesKey(data, Key.up))
|
|
1269
|
+
this.scroll -= 1;
|
|
1270
|
+
else if (matchesKey(data, Key.down))
|
|
1271
|
+
this.scroll += 1;
|
|
1272
|
+
else if (matchesKey(data, Key.pageUp))
|
|
1273
|
+
this.scroll -= page;
|
|
1274
|
+
else if (matchesKey(data, Key.pageDown))
|
|
1275
|
+
this.scroll += page;
|
|
1276
|
+
else if (matchesKey(data, Key.home))
|
|
1277
|
+
this.scroll = 0;
|
|
1278
|
+
else if (matchesKey(data, Key.end))
|
|
1279
|
+
this.scroll = Number.MAX_SAFE_INTEGER;
|
|
1280
|
+
}
|
|
1281
|
+
render(width) {
|
|
1282
|
+
const inner = Math.max(1, width - 2);
|
|
1283
|
+
if (this.cache?.width !== inner)
|
|
1284
|
+
this.cache = {
|
|
1285
|
+
width: inner,
|
|
1286
|
+
lines: new Markdown(this.markdown, 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
1287
|
+
transform: markdownTransform,
|
|
1288
|
+
}).render(inner),
|
|
1289
|
+
};
|
|
1290
|
+
const view = scrollWindow(this.cache.lines, this.scroll, Math.max(3, this.height()));
|
|
1291
|
+
this.scroll = view.scroll;
|
|
1292
|
+
const bar = style.gray("│");
|
|
1293
|
+
return fit([
|
|
1294
|
+
"",
|
|
1295
|
+
style.bold(style.yellow(this.title)),
|
|
1296
|
+
...(view.above ? [`${bar} ${style.dim("↑ more")}`] : []),
|
|
1297
|
+
...view.lines.map((line) => `${bar} ${line}`),
|
|
1298
|
+
...(view.below ? [`${bar} ${style.dim("↓ more")}`] : []),
|
|
1299
|
+
style.dim(` ${view.above || view.below ? "↑/↓ PgUp/PgDn scroll · " : ""}Esc close`),
|
|
1300
|
+
], width);
|
|
1301
|
+
}
|
|
1302
|
+
}
|
|
1303
|
+
export class BtwPanel {
|
|
1304
|
+
state;
|
|
1305
|
+
onClose;
|
|
1306
|
+
height;
|
|
1307
|
+
markdown;
|
|
1308
|
+
constructor(state, onClose, height) {
|
|
1309
|
+
this.state = state;
|
|
1310
|
+
this.onClose = onClose;
|
|
1311
|
+
this.height = height;
|
|
1312
|
+
}
|
|
1313
|
+
get pending() {
|
|
1314
|
+
return !!this.state.pending;
|
|
1315
|
+
}
|
|
1316
|
+
dispatch(action) {
|
|
1317
|
+
this.state = reduceBtw(this.state, action);
|
|
1318
|
+
}
|
|
1319
|
+
invalidate() {
|
|
1320
|
+
this.markdown = undefined;
|
|
1321
|
+
}
|
|
1322
|
+
handleInput(data) {
|
|
1323
|
+
if (matchesKey(data, Key.escape))
|
|
1324
|
+
return this.onClose();
|
|
1325
|
+
if (matchesKey(data, Key.left))
|
|
1326
|
+
return this.dispatch({ type: "prev" });
|
|
1327
|
+
if (matchesKey(data, Key.right))
|
|
1328
|
+
return this.dispatch({ type: "next" });
|
|
1329
|
+
if (matchesKey(data, Key.up))
|
|
1330
|
+
return this.dispatch({ type: "scroll", lines: -1 });
|
|
1331
|
+
if (matchesKey(data, Key.down))
|
|
1332
|
+
return this.dispatch({ type: "scroll", lines: 1 });
|
|
1333
|
+
if (matchesKey(data, Key.pageUp))
|
|
1334
|
+
return this.dispatch({ type: "scroll", lines: -this.height() });
|
|
1335
|
+
if (matchesKey(data, Key.pageDown))
|
|
1336
|
+
return this.dispatch({ type: "scroll", lines: this.height() });
|
|
1337
|
+
}
|
|
1338
|
+
answerLines(id, text, width) {
|
|
1339
|
+
if (this.markdown?.id === id && this.markdown.width === width)
|
|
1340
|
+
return this.markdown.lines;
|
|
1341
|
+
const lines = new Markdown(text, 0, 0, markdownTheme, markdownDefaultTextStyle, {
|
|
1342
|
+
transform: markdownTransform,
|
|
1343
|
+
}).render(Math.max(1, width));
|
|
1344
|
+
this.markdown = { id, width, lines };
|
|
1345
|
+
return lines;
|
|
1346
|
+
}
|
|
1347
|
+
render(width) {
|
|
1348
|
+
const s = this.state;
|
|
1349
|
+
const inner = Math.max(1, width - 2);
|
|
1350
|
+
const bar = style.gray("│");
|
|
1351
|
+
const current = btwCurrent(s);
|
|
1352
|
+
const position = btwPosition(s);
|
|
1353
|
+
const lines = [
|
|
1354
|
+
"",
|
|
1355
|
+
`${style.bold(style.yellow("btw"))}${position ? ` ${style.dim(position)}` : ""}${style.dim(" · side question, not added to the conversation")}`,
|
|
1356
|
+
];
|
|
1357
|
+
const question = s.pending?.question ?? s.error?.question ?? current?.question ?? "";
|
|
1358
|
+
lines.push(...wrap(question, inner).map((l) => `${bar} ${style.bold(l)}`));
|
|
1359
|
+
let overflow = false;
|
|
1360
|
+
if (s.pending) {
|
|
1361
|
+
const spinner = SPINNER[clock.frame % SPINNER.length] ?? "…";
|
|
1362
|
+
lines.push(`${bar} ${style.cyan(spinner)} Thinking… ${style.dim(formatDuration(clock.now - s.pending.startedAt))}`);
|
|
1363
|
+
}
|
|
1364
|
+
else if (s.error) {
|
|
1365
|
+
lines.push(...wrap(s.error.message, inner).map((l) => `${bar} ${style.red(l)}`));
|
|
1366
|
+
}
|
|
1367
|
+
else if (current) {
|
|
1368
|
+
const body = this.answerLines(current.id, current.answer, inner);
|
|
1369
|
+
const view = btwWindow(body, s.scroll, Math.max(3, this.height()));
|
|
1370
|
+
if (view.scroll !== s.scroll)
|
|
1371
|
+
this.state = { ...s, scroll: view.scroll };
|
|
1372
|
+
overflow = view.above || view.below;
|
|
1373
|
+
if (view.above)
|
|
1374
|
+
lines.push(`${bar} ${style.dim("↑ more")}`);
|
|
1375
|
+
lines.push(...view.lines.map((l) => `${bar} ${l}`));
|
|
1376
|
+
if (view.below)
|
|
1377
|
+
lines.push(`${bar} ${style.dim("↓ more")}`);
|
|
1378
|
+
lines.push(`${bar} ${style.dim(btwUsageLine(current))}`);
|
|
1379
|
+
}
|
|
1380
|
+
lines.push(style.dim(` ${btwHints(this.state, overflow)}`));
|
|
1381
|
+
return fit(lines, width);
|
|
1382
|
+
}
|
|
1383
|
+
}
|