flashlite 0.0.0-stage → 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +583 -2
- package/dist/adapters/anthropic.js +341 -0
- package/dist/adapters/openai.js +241 -0
- package/dist/approval.js +28 -0
- package/dist/boostworkflow.js +15 -0
- package/dist/boundedfs.js +214 -0
- package/dist/chatviewport.js +123 -0
- package/dist/checkpoint.js +100 -0
- package/dist/client.js +463 -0
- package/dist/clipboard.js +72 -0
- package/dist/commandpolicy.js +544 -0
- package/dist/commands.js +29 -0
- package/dist/compaction.js +66 -0
- package/dist/config.js +206 -0
- package/dist/contextbudget.js +201 -0
- package/dist/customcommands.js +105 -0
- package/dist/effort.js +79 -0
- package/dist/hooks.js +86 -0
- package/dist/images.js +84 -0
- package/dist/index.js +459 -0
- package/dist/lineinput.js +982 -0
- package/dist/loop.js +442 -0
- package/dist/markdown.js +426 -0
- package/dist/memory.js +55 -0
- package/dist/pathcomplete.js +92 -0
- package/dist/projectinstructions.js +77 -0
- package/dist/repl.js +1659 -0
- package/dist/researchweb.js +87 -0
- package/dist/sanitize.js +89 -0
- package/dist/sessions.js +209 -0
- package/dist/sse.js +80 -0
- package/dist/subagents.js +182 -0
- package/dist/systemprompt.js +35 -0
- package/dist/terminal.js +235 -0
- package/dist/todo.js +34 -0
- package/dist/token.js +17 -0
- package/dist/tokenprofiles.js +25 -0
- package/dist/toolcache.js +50 -0
- package/dist/toolpreview.js +253 -0
- package/dist/toolresultview.js +62 -0
- package/dist/tools.js +889 -0
- package/dist/types.js +3 -0
- package/dist/ui.js +1630 -0
- package/package.json +28 -4
package/dist/ui.js
ADDED
|
@@ -0,0 +1,1630 @@
|
|
|
1
|
+
// ui.ts — sole terminal writer. The chat scrolls above a bottom-anchored composer;
|
|
2
|
+
// pipes and very small terminals retain the simple line-oriented interface.
|
|
3
|
+
import { stripVTControlCharacters } from 'node:util';
|
|
4
|
+
import { ChatViewport } from "./chatviewport.js";
|
|
5
|
+
import { highlightCode } from "./markdown.js";
|
|
6
|
+
const colorEnabled = () => process.stdout.isTTY === true && !process.env.NO_COLOR;
|
|
7
|
+
const animated = colorEnabled;
|
|
8
|
+
function wrap(text, code) {
|
|
9
|
+
return colorEnabled() ? `\x1b[${code}m${text}\x1b[0m` : text;
|
|
10
|
+
}
|
|
11
|
+
/** The warm palette is tuned for dark terminals. On a light background the same hues are
|
|
12
|
+
* darkened and pinned to explicit true-color — no bright `9x`, no `2` faint — so every
|
|
13
|
+
* text role keeps >=4.5:1 contrast. Set `FLASHLITE_THEME=light` (or `dark`, the default). */
|
|
14
|
+
const LIGHT = (process.env.FLASHLITE_THEME ?? '').trim().toLowerCase() === 'light';
|
|
15
|
+
/** Dark terminals: warm light-yellow (#ffe066) primary, orange (#ff9f40) secondary; bright
|
|
16
|
+
* `9x` status colors and true-color RGB both clear 4.5:1 on a dark background. */
|
|
17
|
+
const DARK_THEME = {
|
|
18
|
+
dim: '2',
|
|
19
|
+
yellow: '93',
|
|
20
|
+
red: '91',
|
|
21
|
+
green: '92',
|
|
22
|
+
accent: '38;2;255;224;102',
|
|
23
|
+
orange: '38;2;255;159;64',
|
|
24
|
+
gradientFrom: [255, 159, 64],
|
|
25
|
+
gradientTo: [255, 224, 102],
|
|
26
|
+
effort: {
|
|
27
|
+
auto: '255;224;102',
|
|
28
|
+
low: '255;205;90',
|
|
29
|
+
medium: '255;176;74',
|
|
30
|
+
high: '255;138;62',
|
|
31
|
+
xhigh: '255;102;66',
|
|
32
|
+
max: '255;74;74',
|
|
33
|
+
},
|
|
34
|
+
mutedBg: '48;2;56;56;56',
|
|
35
|
+
mutedFg: '38;2;180;180;180',
|
|
36
|
+
hlOk: '48;2;0;58;0;97',
|
|
37
|
+
hlFail: '48;2;74;8;8;97',
|
|
38
|
+
hlWarn: '48;2;64;54;0;97',
|
|
39
|
+
hlNeutral: '48;2;45;45;58;97',
|
|
40
|
+
toolSecondary: '38;2;185;195;185',
|
|
41
|
+
};
|
|
42
|
+
/** Light terminals: the same warm hues darkened and made explicit, each >=4.5:1 on both
|
|
43
|
+
* #f5f5f5 and #ffffff (see test/theme.test.ts). */
|
|
44
|
+
const LIGHT_THEME = {
|
|
45
|
+
dim: '38;2;95;99;104',
|
|
46
|
+
yellow: '38;2;122;92;0',
|
|
47
|
+
red: '38;2;179;18;26',
|
|
48
|
+
green: '38;2;11;122;53',
|
|
49
|
+
accent: '38;2;125;98;0',
|
|
50
|
+
orange: '38;2;150;75;0',
|
|
51
|
+
gradientFrom: [150, 75, 0],
|
|
52
|
+
gradientTo: [125, 98, 0],
|
|
53
|
+
effort: {
|
|
54
|
+
auto: '125;98;0',
|
|
55
|
+
low: '133;90;0',
|
|
56
|
+
medium: '141;82;0',
|
|
57
|
+
high: '150;75;0',
|
|
58
|
+
xhigh: '160;60;0',
|
|
59
|
+
max: '160;0;0',
|
|
60
|
+
},
|
|
61
|
+
mutedBg: '48;2;232;232;232',
|
|
62
|
+
mutedFg: '38;2;60;60;60',
|
|
63
|
+
hlOk: '48;2;215;245;215;38;2;10;61;10',
|
|
64
|
+
hlFail: '48;2;251;220;220;38;2;90;15;15',
|
|
65
|
+
hlWarn: '48;2;246;239;207;38;2;74;59;0',
|
|
66
|
+
hlNeutral: '48;2;230;230;238;38;2;45;45;58',
|
|
67
|
+
toolSecondary: '38;2;60;66;68',
|
|
68
|
+
};
|
|
69
|
+
/** Resolved palette for the active theme. */
|
|
70
|
+
export const theme = LIGHT ? LIGHT_THEME : DARK_THEME;
|
|
71
|
+
export const dim = (t) => wrap(t, theme.dim);
|
|
72
|
+
export const bold = (t) => wrap(t, '1');
|
|
73
|
+
export const yellow = (t) => wrap(t, theme.yellow);
|
|
74
|
+
export const red = (t) => wrap(t, theme.red);
|
|
75
|
+
export const green = (t) => wrap(t, theme.green);
|
|
76
|
+
export const accent = (t) => wrap(t, theme.accent);
|
|
77
|
+
export const orange = (t) => wrap(t, theme.orange);
|
|
78
|
+
/** Warm brand gradient: light yellow (#ffe066) interpolated from orange (#ff9f40)
|
|
79
|
+
* across a string's visible characters, so titles and the brand read as one warm run.
|
|
80
|
+
* Callers pass plain text; returns plain text when colors are disabled. */
|
|
81
|
+
export function gradient(text, opts = {}) {
|
|
82
|
+
if (!colorEnabled() || !text)
|
|
83
|
+
return text;
|
|
84
|
+
const chars = [...text];
|
|
85
|
+
const channel = (from, to, at) => Math.round(from + (to - from) * at);
|
|
86
|
+
let out = opts.bold ? '\x1b[1m' : '';
|
|
87
|
+
for (let i = 0; i < chars.length; i++) {
|
|
88
|
+
const t = chars.length === 1 ? 0 : i / (chars.length - 1);
|
|
89
|
+
out += `\x1b[38;2;${channel(theme.gradientFrom[0], theme.gradientTo[0], t)};${channel(theme.gradientFrom[1], theme.gradientTo[1], t)};${channel(theme.gradientFrom[2], theme.gradientTo[2], t)}m${chars[i]}`;
|
|
90
|
+
}
|
|
91
|
+
return out + '\x1b[0m';
|
|
92
|
+
}
|
|
93
|
+
/** Faint layer for the conversation while a dock modal owns focus. A loud SGR inside a
|
|
94
|
+
* row would otherwise cancel the dim, so re-assert it after every reset; the plain
|
|
95
|
+
* text is byte-for-byte unchanged. */
|
|
96
|
+
function faint(line) {
|
|
97
|
+
if (!colorEnabled() || !line)
|
|
98
|
+
return line;
|
|
99
|
+
return `\x1b[2m${line.replaceAll('\x1b[0m', '\x1b[0m\x1b[2m')}\x1b[0m`;
|
|
100
|
+
}
|
|
101
|
+
/** Color for the effort level: light yellow at `auto` through red at `max`. The ramp is
|
|
102
|
+
* per-theme (see `Theme.effort`) so it stays legible on either background. */
|
|
103
|
+
export function effortColor(level) {
|
|
104
|
+
const code = theme.effort[level] ?? theme.effort.auto;
|
|
105
|
+
return (text) => wrap(text, `38;2;${code}`);
|
|
106
|
+
}
|
|
107
|
+
/** A dim short label followed by a colored value, e.g. `mode ask` or `effort med`. */
|
|
108
|
+
function labeled(label, value, color) {
|
|
109
|
+
return dim(label) + ' ' + color(value);
|
|
110
|
+
}
|
|
111
|
+
/** Compact token counts for the context gauge: 950, 42k, 390.7M — never a long digit run. */
|
|
112
|
+
function compactCount(value) {
|
|
113
|
+
if (!Number.isFinite(value))
|
|
114
|
+
return '?';
|
|
115
|
+
if (value >= 1_000_000)
|
|
116
|
+
return `${(value / 1_000_000).toFixed(value >= 10_000_000 ? 0 : 1).replace(/\.0$/, '')}M`;
|
|
117
|
+
if (value >= 1000)
|
|
118
|
+
return `${(value / 1000).toFixed(value >= 10_000 ? 0 : 1).replace(/\.0$/, '')}k`;
|
|
119
|
+
return String(value);
|
|
120
|
+
}
|
|
121
|
+
function usageStyle(text) {
|
|
122
|
+
return text
|
|
123
|
+
.replace(/ctx (\d+)%/g, (_, pct) => {
|
|
124
|
+
const pressure = Number(pct);
|
|
125
|
+
const color = pressure > 85 ? red : pressure > 70 ? yellow : accent;
|
|
126
|
+
return `${dim('ctx')} ${color(`${pct}%`)}`;
|
|
127
|
+
})
|
|
128
|
+
.replace(/\b([a-z][\w-]*):/g, (label, name) => ['account', 'usage', 'quota'].includes(name) ? dim(label) : accent(bold(label)))
|
|
129
|
+
.replace(/([\d,]+) in \/ ([\d,]+) out/, (_, input, output) => `${accent(input)} ${dim('in')} / ${orange(output)} ${dim('out')}`)
|
|
130
|
+
.replace(/\b(\d+)%/g, (percent, value) => Number(value) < 15 ? red(bold(percent)) : Number(value) < 40 ? yellow(bold(percent)) : accent(bold(percent)))
|
|
131
|
+
.replace(/([\d,]+) rpm/, (_, value) => `${accent(value)} ${dim('rpm')}`)
|
|
132
|
+
.replace(/\b(day|week)\b/g, (label) => dim(label))
|
|
133
|
+
.replace(/\bunlimited\b/g, (value) => green(value))
|
|
134
|
+
.replace(/\$[\d,.]+/g, (value) => accent(value))
|
|
135
|
+
.replace(/\b(unknown|not reported)\b/g, (value) => yellow(value))
|
|
136
|
+
.replace(/ · /g, dim(' · '));
|
|
137
|
+
}
|
|
138
|
+
// Dock labels must stay on one physical row, even with wide model names/paths.
|
|
139
|
+
function cells(c) {
|
|
140
|
+
if (/\p{Mark}/u.test(c))
|
|
141
|
+
return 0;
|
|
142
|
+
const cp = c.codePointAt(0);
|
|
143
|
+
return cp >= 0x1100 && (cp <= 0x115f || cp >= 0x2e80 && cp <= 0xa4cf ||
|
|
144
|
+
cp >= 0xac00 && cp <= 0xd7a3 || cp >= 0xf900 && cp <= 0xfaff ||
|
|
145
|
+
cp >= 0xfe10 && cp <= 0xfe6f || cp >= 0xff00 && cp <= 0xff60 || cp >= 0x1f300) ? 2 : 1;
|
|
146
|
+
}
|
|
147
|
+
function clean(text) {
|
|
148
|
+
return stripVTControlCharacters(text).replace(/[\x00-\x1f\x7f]/g, ' ');
|
|
149
|
+
}
|
|
150
|
+
function width(text) {
|
|
151
|
+
return [...clean(text)].reduce((n, c) => n + cells(c), 0);
|
|
152
|
+
}
|
|
153
|
+
function fit(text, limit) {
|
|
154
|
+
let out = '';
|
|
155
|
+
let used = 0;
|
|
156
|
+
for (const c of clean(text)) {
|
|
157
|
+
if (used + cells(c) > limit)
|
|
158
|
+
break;
|
|
159
|
+
used += cells(c);
|
|
160
|
+
out += c;
|
|
161
|
+
}
|
|
162
|
+
return out;
|
|
163
|
+
}
|
|
164
|
+
/** Animated intensity is presentation-only, so honor explicit opt-outs. */
|
|
165
|
+
function motionEnabled() {
|
|
166
|
+
return colorEnabled() && !process.env.FLASHLITE_REDUCED_MOTION;
|
|
167
|
+
}
|
|
168
|
+
/** One width-aware model row. A hot sweep travels the whole bar; the model name and
|
|
169
|
+
* bolt keep their neon orange between passes. */
|
|
170
|
+
function boostBar(text, modelStart, boltStart, sweep) {
|
|
171
|
+
if (!colorEnabled())
|
|
172
|
+
return text;
|
|
173
|
+
const end = Math.max(0, width(text) - 1);
|
|
174
|
+
let column = 0;
|
|
175
|
+
let out = '\x1b[1m';
|
|
176
|
+
for (const char of text) {
|
|
177
|
+
const onBolt = column >= boltStart && column < end;
|
|
178
|
+
const onModel = !onBolt && column >= modelStart && column < end;
|
|
179
|
+
const base = LIGHT
|
|
180
|
+
? onBolt ? [162, 45, 0] : onModel ? [174, 61, 0] : [150, 81, 20]
|
|
181
|
+
: onBolt ? [255, 105, 0] : onModel ? [255, 115, 0] : [255, 135, 15];
|
|
182
|
+
const hot = sweep < 0 ? 0 : Math.max(0, 1 - Math.abs(column - sweep) / 12);
|
|
183
|
+
const peak = LIGHT ? [120, 20, 0] : [255, 246, 132];
|
|
184
|
+
const rgb = base.map((value, channel) => Math.round(value + (peak[channel] - value) * hot));
|
|
185
|
+
out += `\x1b[38;2;${rgb.join(';')}m${char}`;
|
|
186
|
+
column += cells(char);
|
|
187
|
+
}
|
|
188
|
+
return out + '\x1b[0m';
|
|
189
|
+
}
|
|
190
|
+
/** A full-width, diff-style outcome row: dark background through the right edge, light text. */
|
|
191
|
+
function highlightRow(text, code, columns, secondary = false) {
|
|
192
|
+
const body = fit(` ${text} `, columns);
|
|
193
|
+
if (!colorEnabled())
|
|
194
|
+
return body.trim();
|
|
195
|
+
// Explicit secondary foreground is predictable in light/dark themes; ANSI faint
|
|
196
|
+
// depends on the user's terminal and can make normal-sized text unreadable.
|
|
197
|
+
const style = secondary ? `${code};${theme.toolSecondary}` : code;
|
|
198
|
+
return `\x1b[${style}m${body + ' '.repeat(Math.max(0, columns - width(body)))}\x1b[0m`;
|
|
199
|
+
}
|
|
200
|
+
/** Bold only the call title. SGR 22 ends bold without clearing the row background. */
|
|
201
|
+
function highlightedToolLine(text, title, code, columns) {
|
|
202
|
+
const body = fit(` ${text} `, columns);
|
|
203
|
+
if (!colorEnabled())
|
|
204
|
+
return body.trim();
|
|
205
|
+
const index = body.indexOf(title);
|
|
206
|
+
const padded = body + ' '.repeat(Math.max(0, columns - width(body)));
|
|
207
|
+
const styled = index < 0 ? padded :
|
|
208
|
+
padded.slice(0, index) + `\x1b[1m${padded.slice(index, index + title.length)}\x1b[22m` + padded.slice(index + title.length);
|
|
209
|
+
return `\x1b[${code}m${styled}\x1b[0m`;
|
|
210
|
+
}
|
|
211
|
+
/** Render tool preview/result lines as a tree beneath their parent node. File
|
|
212
|
+
* edits (edit_file/write_file) read better as a mini unified diff: a colored left
|
|
213
|
+
* bar per line plus a `+N −M` chip; every other tool keeps the dim tree. */
|
|
214
|
+
function treePreview(lines, language) {
|
|
215
|
+
if (isDiff(lines))
|
|
216
|
+
return diffRows(lines, language);
|
|
217
|
+
return lines.map((line, i) => {
|
|
218
|
+
const connector = i === lines.length - 1 ? '└─ ' : '├─ ';
|
|
219
|
+
const body = line.startsWith('+ ') ? green(line) : line.startsWith('− ') ? red(line) : dim(line);
|
|
220
|
+
return dim(' ') + dim(connector) + body;
|
|
221
|
+
});
|
|
222
|
+
}
|
|
223
|
+
/** A file-edit preview: additions, removals, numbered context, or a collapse marker. */
|
|
224
|
+
function isDiff(lines) {
|
|
225
|
+
return lines.length > 0 &&
|
|
226
|
+
lines.some((line) => /^[+−]/.test(line)) &&
|
|
227
|
+
lines.every((line) => /^[+−]\d* /.test(line) || /^ \d+ /.test(line) || line.startsWith('…'));
|
|
228
|
+
}
|
|
229
|
+
/** Parse one preview line: `+N code`, `−N code`, ` N code` (numbered) or `+ code`/`− code`. */
|
|
230
|
+
function parseDiffLine(line) {
|
|
231
|
+
if (line.startsWith('…'))
|
|
232
|
+
return { kind: 'elide', text: line };
|
|
233
|
+
let match = /^\+(\d*) (.*)$/.exec(line);
|
|
234
|
+
if (match)
|
|
235
|
+
return { kind: 'add', ...(match[1] ? { line: Number(match[1]) } : {}), text: match[2] };
|
|
236
|
+
match = /^−(\d*) (.*)$/.exec(line);
|
|
237
|
+
if (match)
|
|
238
|
+
return { kind: 'delete', ...(match[1] ? { line: Number(match[1]) } : {}), text: match[2] };
|
|
239
|
+
match = /^ (\d+) (.*)$/.exec(line);
|
|
240
|
+
if (match)
|
|
241
|
+
return { kind: 'context', line: Number(match[1]), text: match[2] };
|
|
242
|
+
return { kind: 'context', text: line };
|
|
243
|
+
}
|
|
244
|
+
/** Mini unified diff: a colored left bar + `+`/`−` marker, an aligned line-number gutter
|
|
245
|
+
* when the preview carries line numbers, a `+N −M` chip on the first row, and a syntax-
|
|
246
|
+
* highlighted code body. `/^[+−]/` is tested by `isDiff`, so plain lines never land here. */
|
|
247
|
+
function diffRows(lines, language) {
|
|
248
|
+
const parsed = lines.map(parseDiffLine);
|
|
249
|
+
const added = parsed.filter((line) => line.kind === 'add').length;
|
|
250
|
+
const removed = parsed.filter((line) => line.kind === 'delete').length;
|
|
251
|
+
const chip = `${green('+' + added)} ${red('−' + removed)}`;
|
|
252
|
+
const gutter = parsed.reduce((width, line) => Math.max(width, line.line !== undefined ? String(line.line).length : 0), 0);
|
|
253
|
+
const paint = (code) => language ? highlightCode(code, language) : code;
|
|
254
|
+
const chipRow = parsed.findIndex((line) => line.kind !== 'elide');
|
|
255
|
+
return parsed.map((line, i) => {
|
|
256
|
+
if (line.kind === 'elide')
|
|
257
|
+
return dim(' ') + dim(line.text);
|
|
258
|
+
const bar = line.kind === 'add' ? green('▌') : line.kind === 'delete' ? red('▌') : dim('▌');
|
|
259
|
+
const marker = line.kind === 'add' ? green('+') : line.kind === 'delete' ? red('−') : dim(' ');
|
|
260
|
+
const color = line.kind === 'add' ? green : line.kind === 'delete' ? red : dim;
|
|
261
|
+
const prefix = line.line !== undefined
|
|
262
|
+
? marker + color(String(line.line).padStart(gutter)) + ' '
|
|
263
|
+
: (line.kind === 'add' ? green('+ ') : line.kind === 'delete' ? red('− ') : dim(' '));
|
|
264
|
+
return dim(' ') + bar + ' ' + prefix + paint(line.text) + (i === chipRow ? dim(' ') + chip : '');
|
|
265
|
+
});
|
|
266
|
+
}
|
|
267
|
+
const inlinePlain = (text) => text;
|
|
268
|
+
/** Split one line into styled segments for the lightweight inline markdown used in
|
|
269
|
+
* prompts/panels: `**bold**` and `` `code` ``. Unclosed markers stay plain. */
|
|
270
|
+
function inlineSegments(line) {
|
|
271
|
+
const segs = [];
|
|
272
|
+
let i = 0;
|
|
273
|
+
while (i < line.length) {
|
|
274
|
+
if (line[i] === '`') {
|
|
275
|
+
const end = line.indexOf('`', i + 1);
|
|
276
|
+
if (end === -1) {
|
|
277
|
+
segs.push({ text: line.slice(i), style: inlinePlain });
|
|
278
|
+
break;
|
|
279
|
+
}
|
|
280
|
+
segs.push({ text: line.slice(i + 1, end), style: accent });
|
|
281
|
+
i = end + 1;
|
|
282
|
+
continue;
|
|
283
|
+
}
|
|
284
|
+
if (line.startsWith('**', i)) {
|
|
285
|
+
const end = line.indexOf('**', i + 2);
|
|
286
|
+
if (end === -1) {
|
|
287
|
+
segs.push({ text: line.slice(i), style: inlinePlain });
|
|
288
|
+
break;
|
|
289
|
+
}
|
|
290
|
+
segs.push({ text: line.slice(i + 2, end), style: bold });
|
|
291
|
+
i = end + 2;
|
|
292
|
+
continue;
|
|
293
|
+
}
|
|
294
|
+
let j = i;
|
|
295
|
+
while (j < line.length && line[j] !== '`' && !line.startsWith('**', j))
|
|
296
|
+
j++;
|
|
297
|
+
segs.push({ text: line.slice(i, j), style: inlinePlain });
|
|
298
|
+
i = j;
|
|
299
|
+
}
|
|
300
|
+
return segs;
|
|
301
|
+
}
|
|
302
|
+
/** Wrap a markdown-marked line into styled rows fitted to `limit` cells, keeping each
|
|
303
|
+
* styled run intact and width-aware (escape codes never count as visible cells). */
|
|
304
|
+
function styledApprovalRows(line, limit) {
|
|
305
|
+
const rows = [];
|
|
306
|
+
let rowParts = [];
|
|
307
|
+
let used = 0;
|
|
308
|
+
const flush = () => {
|
|
309
|
+
rows.push(rowParts.map((part) => part.style(part.text)).join(''));
|
|
310
|
+
rowParts = [];
|
|
311
|
+
used = 0;
|
|
312
|
+
};
|
|
313
|
+
for (const seg of inlineSegments(line)) {
|
|
314
|
+
for (const ch of seg.text) {
|
|
315
|
+
const w = cells(ch);
|
|
316
|
+
if (used + w > limit && used > 0)
|
|
317
|
+
flush();
|
|
318
|
+
const last = rowParts[rowParts.length - 1];
|
|
319
|
+
if (last && last.style === seg.style)
|
|
320
|
+
last.text += ch;
|
|
321
|
+
else
|
|
322
|
+
rowParts.push({ style: seg.style, text: ch });
|
|
323
|
+
used += w;
|
|
324
|
+
}
|
|
325
|
+
}
|
|
326
|
+
if (rowParts.length)
|
|
327
|
+
flush();
|
|
328
|
+
else if (rows.length === 0)
|
|
329
|
+
rows.push('');
|
|
330
|
+
return rows;
|
|
331
|
+
}
|
|
332
|
+
/** Build one run's terminal-command card (header + tree), sized to the terminal. */
|
|
333
|
+
function runCommandCard(run, columns) {
|
|
334
|
+
const maxInner = Math.max(0, columns - 6);
|
|
335
|
+
const label = run.name + (run.count > 1 ? ` ${run.count}x` : '');
|
|
336
|
+
const shownSubjects = (run.subjects ?? [])
|
|
337
|
+
.map((s) => run.name === 'run_command' && !s.startsWith('$') ? `$ ${s}` : s)
|
|
338
|
+
.slice(0, 3)
|
|
339
|
+
.map((s) => fit(s, maxInner));
|
|
340
|
+
const truncated = (run.subjects ?? []).length - 3;
|
|
341
|
+
const duration = run.elapsedMs ? ` · ${formatDuration(run.elapsedMs)}` : '';
|
|
342
|
+
const lines = [];
|
|
343
|
+
let glyph = '● ';
|
|
344
|
+
let bg = theme.hlNeutral;
|
|
345
|
+
const summary = [];
|
|
346
|
+
if (run.denied === run.count && run.count) {
|
|
347
|
+
glyph = '⊘ ';
|
|
348
|
+
bg = theme.hlWarn;
|
|
349
|
+
lines.push(...shownSubjects);
|
|
350
|
+
lines.push('denied');
|
|
351
|
+
}
|
|
352
|
+
else if (run.done === run.count && run.count) {
|
|
353
|
+
glyph = '✓ ';
|
|
354
|
+
bg = theme.hlOk;
|
|
355
|
+
lines.push(...shownSubjects);
|
|
356
|
+
if (truncated > 0)
|
|
357
|
+
lines.push(`+${truncated} more`);
|
|
358
|
+
}
|
|
359
|
+
else if (run.error === run.count && run.count) {
|
|
360
|
+
glyph = '✗ ';
|
|
361
|
+
bg = theme.hlFail;
|
|
362
|
+
lines.push(...shownSubjects);
|
|
363
|
+
lines.push(run.failure ? fit(`failed · ${run.failure}`, maxInner) : 'failed');
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
bg = run.error ? theme.hlFail : run.denied ? theme.hlWarn : theme.hlNeutral;
|
|
367
|
+
if (run.done)
|
|
368
|
+
summary.push(`${run.done} done`);
|
|
369
|
+
if (run.error)
|
|
370
|
+
summary.push(`${run.error} failed`);
|
|
371
|
+
if (run.denied)
|
|
372
|
+
summary.push(`${run.denied} denied`);
|
|
373
|
+
lines.push(...shownSubjects);
|
|
374
|
+
if (truncated > 0)
|
|
375
|
+
lines.push(`+${truncated} more`);
|
|
376
|
+
}
|
|
377
|
+
const prefix = `$ ${label}`;
|
|
378
|
+
const header = fit(`${glyph}${prefix}${summary.length ? ` · ${summary.join(' · ')}` : ''}${duration}`, maxInner);
|
|
379
|
+
for (let i = 0; i < lines.length; i++) {
|
|
380
|
+
const connector = i === lines.length - 1 ? '└─ ' : '├─ ';
|
|
381
|
+
lines[i] = connector + fit(lines[i], maxInner);
|
|
382
|
+
}
|
|
383
|
+
return { bg, header, lines };
|
|
384
|
+
}
|
|
385
|
+
/** Plain lines for a generic call. The batch renderer applies one background to every
|
|
386
|
+
* line, so the subject, result and status are always part of the same tool block. */
|
|
387
|
+
function runRows(run, columns) {
|
|
388
|
+
const card = runCommandCard(run, columns);
|
|
389
|
+
if (run.name === 'run_command' && run.count === 1 && run.subjects?.[0]) {
|
|
390
|
+
return [` $ ${run.subjects[0]}`, ` ${card.header}${run.failure ? ` · ${run.failure}` : ''}`];
|
|
391
|
+
}
|
|
392
|
+
return [` ${card.header}`, ...card.lines.map((line) => run.count === 1 ? ` ${line.replace(/^[├└]─ /, '')}` : ` ${line}`)];
|
|
393
|
+
}
|
|
394
|
+
/** Pi-style compact calls. One blank row separates blocks, never their content. */
|
|
395
|
+
function toolBatchRows(runs, columns, expanded) {
|
|
396
|
+
const rows = [];
|
|
397
|
+
for (const run of runs) {
|
|
398
|
+
if (rows.length)
|
|
399
|
+
rows.push('');
|
|
400
|
+
const card = runCommandCard(run, columns);
|
|
401
|
+
const result = run.results?.at(-1);
|
|
402
|
+
const lines = [];
|
|
403
|
+
if (run.name === 'run_command' && run.count === 1 && run.subjects?.[0]) {
|
|
404
|
+
lines.push(` $ ${run.subjects[0]}`);
|
|
405
|
+
if (result) {
|
|
406
|
+
const output = expanded ? result.lines : result.lines.slice(-4);
|
|
407
|
+
if (!expanded && result.lines.length > 4)
|
|
408
|
+
lines.push(` … (${result.lines.length - 4} hidden lines · ctrl+o expand)`);
|
|
409
|
+
for (const line of output)
|
|
410
|
+
lines.push(` ${line}`);
|
|
411
|
+
}
|
|
412
|
+
lines.push(` ${card.header}${result ? ` · ${result.summary}` : run.failure ? ` · ${run.failure}` : ''}${result?.lines.length ? ' · ctrl+o expand' : ''}`);
|
|
413
|
+
}
|
|
414
|
+
else if (run.name === 'read_file' && run.count === 1 && run.subjects?.[0]) {
|
|
415
|
+
const summary = result?.summary ? ` · ${result.summary}` : '';
|
|
416
|
+
const duration = run.elapsedMs ? ` · ${formatDuration(run.elapsedMs)}` : '';
|
|
417
|
+
lines.push(` ${run.error ? '✗' : run.denied ? '⊘' : run.done ? '✓' : '●'} read ${run.subjects[0]}${summary}${duration}`);
|
|
418
|
+
if (expanded && result?.lines.length) {
|
|
419
|
+
for (const line of result.lines)
|
|
420
|
+
lines.push(` ${line}`);
|
|
421
|
+
}
|
|
422
|
+
}
|
|
423
|
+
else {
|
|
424
|
+
lines.push(...runRows(run, columns));
|
|
425
|
+
if (result)
|
|
426
|
+
lines.push(` ${result.summary}${result.lines.length ? ' · ctrl+o expand' : ''}`);
|
|
427
|
+
if (expanded && result)
|
|
428
|
+
for (const line of result.lines)
|
|
429
|
+
lines.push(` ${line}`);
|
|
430
|
+
}
|
|
431
|
+
const title = run.name === 'run_command' && run.count === 1 && run.subjects?.[0]
|
|
432
|
+
? `$ ${run.subjects[0]}` : run.name === 'read_file' && run.count === 1 ? 'read' : `$ ${run.name}${run.count > 1 ? ` ${run.count}x` : ''}`;
|
|
433
|
+
rows.push(...lines.map((line, index) => index === 0
|
|
434
|
+
? highlightedToolLine(line, title, card.bg, columns)
|
|
435
|
+
: highlightRow(line, card.bg, columns, true)));
|
|
436
|
+
}
|
|
437
|
+
return rows;
|
|
438
|
+
}
|
|
439
|
+
/** A full-width muted band (theme-aware), used for notices and queued-steering rows. */
|
|
440
|
+
function mutedRow(text, columns) {
|
|
441
|
+
const body = fit(text, columns);
|
|
442
|
+
if (!colorEnabled())
|
|
443
|
+
return body.trim();
|
|
444
|
+
return wrap(body + ' '.repeat(Math.max(0, columns - width(body))), `${theme.mutedBg};${theme.mutedFg}`);
|
|
445
|
+
}
|
|
446
|
+
/** A full-width muted-grey notice row, distinct from model prose and tool outcomes. */
|
|
447
|
+
function noticeRow(text, columns) {
|
|
448
|
+
return mutedRow(` ${text} `, columns);
|
|
449
|
+
}
|
|
450
|
+
/** Compact completion duration: 380ms, 1.2s, 2m 05s, 1h 02m. */
|
|
451
|
+
export function formatDuration(ms) {
|
|
452
|
+
if (!Number.isFinite(ms) || ms <= 0)
|
|
453
|
+
return '';
|
|
454
|
+
if (ms < 1000)
|
|
455
|
+
return `${Math.max(1, Math.round(ms))}ms`;
|
|
456
|
+
const seconds = ms / 1000;
|
|
457
|
+
if (seconds < 60) {
|
|
458
|
+
const whole = Math.round(seconds); // round first so the remainder can never be 60
|
|
459
|
+
if (whole >= 60)
|
|
460
|
+
return '1m 00s';
|
|
461
|
+
return `${whole < 10 ? seconds.toFixed(1).replace(/\.0$/, '') : whole}s`;
|
|
462
|
+
}
|
|
463
|
+
const total = Math.round(seconds);
|
|
464
|
+
const minutes = Math.floor(total / 60);
|
|
465
|
+
const rest = String(total % 60).padStart(2, '0');
|
|
466
|
+
if (minutes < 60)
|
|
467
|
+
return `${minutes}m ${rest}s`;
|
|
468
|
+
const hours = Math.floor(minutes / 60);
|
|
469
|
+
const mins = String(minutes % 60).padStart(2, '0');
|
|
470
|
+
return `${hours}h ${mins}m`;
|
|
471
|
+
}
|
|
472
|
+
/** ANSI-shadow block “F” wordmark shown at the top of the startup screen. */
|
|
473
|
+
const WELCOME_GLYPH = [
|
|
474
|
+
'███████╗',
|
|
475
|
+
'██╔════╝',
|
|
476
|
+
'█████╗░░',
|
|
477
|
+
'██╔══╝░░',
|
|
478
|
+
'██║░░░░░',
|
|
479
|
+
'╚═╝░░░░░',
|
|
480
|
+
];
|
|
481
|
+
class UiImpl {
|
|
482
|
+
lineOpen = false;
|
|
483
|
+
composerVisible = false;
|
|
484
|
+
dock = false;
|
|
485
|
+
dockRows = new Map();
|
|
486
|
+
chatRows = 0;
|
|
487
|
+
chatRow = 1;
|
|
488
|
+
chatColumn = 1;
|
|
489
|
+
restoredWrap = false;
|
|
490
|
+
viewport = new ChatViewport();
|
|
491
|
+
model = '';
|
|
492
|
+
prompt = '❯ ';
|
|
493
|
+
text = '';
|
|
494
|
+
cursor = 0;
|
|
495
|
+
mode = 'ask before writes';
|
|
496
|
+
effort = 'auto';
|
|
497
|
+
boost = false;
|
|
498
|
+
boostSweep = null;
|
|
499
|
+
boostFrame = -1;
|
|
500
|
+
boostThinking = false;
|
|
501
|
+
thinkingSnapshot = null;
|
|
502
|
+
attachmentNames = [];
|
|
503
|
+
status = 'ready';
|
|
504
|
+
queuedSteering = null;
|
|
505
|
+
steeringMessages = [];
|
|
506
|
+
contextUsage = '';
|
|
507
|
+
menuItems = null;
|
|
508
|
+
menuIndex = 0;
|
|
509
|
+
menuTop = 0;
|
|
510
|
+
menuCancel = null;
|
|
511
|
+
menuHint = '↑/↓ move · enter select · esc/ctrl+c cancel';
|
|
512
|
+
panelLines = null;
|
|
513
|
+
panelTop = 0;
|
|
514
|
+
panelCancel = null;
|
|
515
|
+
footerNotice = '';
|
|
516
|
+
commandMode = false;
|
|
517
|
+
searchActive = false;
|
|
518
|
+
searchQuery = '';
|
|
519
|
+
searchMatch = '';
|
|
520
|
+
searchRank = 0;
|
|
521
|
+
searchTotal = 0;
|
|
522
|
+
approvalTitle = '';
|
|
523
|
+
approvalLines = [];
|
|
524
|
+
approvalHint = '';
|
|
525
|
+
approvalChoice = '';
|
|
526
|
+
approvalCursor = 0;
|
|
527
|
+
approvalTop = 0;
|
|
528
|
+
agentActivity = '';
|
|
529
|
+
toolCount = 0;
|
|
530
|
+
previewTitle = '';
|
|
531
|
+
previewLines = [];
|
|
532
|
+
previewLanguage;
|
|
533
|
+
// A run_command preview is transient status (`queued`/`running` + the command line).
|
|
534
|
+
// It belongs on the live activity card only — the run-summary box already carries the
|
|
535
|
+
// command as its subject, so appending it again would duplicate the finished card.
|
|
536
|
+
previewTransient = false;
|
|
537
|
+
cardStart = null;
|
|
538
|
+
cardEnd = null;
|
|
539
|
+
cardKind = null;
|
|
540
|
+
todos = [];
|
|
541
|
+
subagentStatus = [];
|
|
542
|
+
lastToolCard = null;
|
|
543
|
+
toolDetailsExpanded = false;
|
|
544
|
+
/** A dock modal (approval, command menu, or panel) layers over a dimmed conversation. */
|
|
545
|
+
modalFocused() {
|
|
546
|
+
return this.dock && !!(this.approvalTitle || this.menuItems || this.panelLines);
|
|
547
|
+
}
|
|
548
|
+
approvalRows() {
|
|
549
|
+
const limit = Math.max(1, (process.stdout.columns || 80) - 5);
|
|
550
|
+
return this.approvalLines.flatMap((line) => styledApprovalRows(line, limit));
|
|
551
|
+
}
|
|
552
|
+
approvalHeight() {
|
|
553
|
+
if (!this.approvalTitle)
|
|
554
|
+
return 0;
|
|
555
|
+
const base = this.menuItems || this.panelLines ? 3 : 5;
|
|
556
|
+
return Math.min(this.approvalRows().length + 2, Math.max(3, (process.stdout.rows || 24) - base - 2));
|
|
557
|
+
}
|
|
558
|
+
approvalPageSize() {
|
|
559
|
+
return Math.max(1, this.approvalHeight() - 2);
|
|
560
|
+
}
|
|
561
|
+
steeringHeight() {
|
|
562
|
+
if (!this.steeringMessages.length || this.menuItems || this.panelLines || this.commandMode)
|
|
563
|
+
return 0;
|
|
564
|
+
const room = (process.stdout.rows || 24) - 4 - this.approvalHeight() - 3;
|
|
565
|
+
return Math.min(4, this.steeringMessages.length + 1, Math.max(0, room));
|
|
566
|
+
}
|
|
567
|
+
todosHeight() {
|
|
568
|
+
if (!this.todos.length || this.menuItems || this.panelLines || this.commandMode)
|
|
569
|
+
return 0;
|
|
570
|
+
const room = (process.stdout.rows || 24) - 4 - this.approvalHeight() - this.steeringHeight() - 3;
|
|
571
|
+
return Math.min(6, this.todos.length + 1, Math.max(0, room));
|
|
572
|
+
}
|
|
573
|
+
agentStatusHeight() {
|
|
574
|
+
if (!this.subagentStatus.length || this.menuItems || this.panelLines || this.commandMode)
|
|
575
|
+
return 0;
|
|
576
|
+
const room = (process.stdout.rows || 24) - 4 - this.approvalHeight() - this.steeringHeight() - this.todosHeight() - 3;
|
|
577
|
+
return Math.min(this.subagentStatus.length, 3, Math.max(0, room));
|
|
578
|
+
}
|
|
579
|
+
dockHeight() {
|
|
580
|
+
return (this.menuItems || this.panelLines ? 3 : 4) + this.approvalHeight() + this.steeringHeight() + this.todosHeight() + this.agentStatusHeight() + (this.searchActive ? 1 : 0);
|
|
581
|
+
}
|
|
582
|
+
setCommandMode(active) {
|
|
583
|
+
this.commandMode = active;
|
|
584
|
+
if (!this.dock)
|
|
585
|
+
return;
|
|
586
|
+
this.reflow();
|
|
587
|
+
this.drawDock();
|
|
588
|
+
}
|
|
589
|
+
startTool(name, preview) {
|
|
590
|
+
this.previewTitle = preview ? clean(preview.title) : '';
|
|
591
|
+
this.previewLines = preview ? preview.lines.map(clean) : [];
|
|
592
|
+
this.previewLanguage = preview?.language;
|
|
593
|
+
this.previewTransient = name.startsWith('run_command');
|
|
594
|
+
if (!this.dock) {
|
|
595
|
+
this.setAgentActivity(`● ${this.previewTitle || name}`);
|
|
596
|
+
return;
|
|
597
|
+
}
|
|
598
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
599
|
+
if (this.previewTransient) {
|
|
600
|
+
const command = this.previewLines.find((line) => line.startsWith('$ '));
|
|
601
|
+
if (command)
|
|
602
|
+
this.renderRunCard([mutedRow(` ${command}`, columns), dim(` ${this.previewLines[0] ?? 'running'}`)], false, 'activity');
|
|
603
|
+
else
|
|
604
|
+
this.setAgentActivity(`● ${this.previewTitle || name}`);
|
|
605
|
+
}
|
|
606
|
+
else {
|
|
607
|
+
const lines = [dim(` ● ${this.previewTitle || name}`), ...treePreview(this.previewLines, this.previewLanguage)];
|
|
608
|
+
this.renderRunCard(lines, false, 'activity');
|
|
609
|
+
}
|
|
610
|
+
}
|
|
611
|
+
preview(title, lines, language) {
|
|
612
|
+
this.previewTitle = clean(title);
|
|
613
|
+
this.previewLines = lines.map(clean);
|
|
614
|
+
this.previewLanguage = language;
|
|
615
|
+
this.previewTransient = false;
|
|
616
|
+
if (!this.dock) {
|
|
617
|
+
this.setAgentActivity(`● ${this.previewTitle} · preparing`);
|
|
618
|
+
return;
|
|
619
|
+
}
|
|
620
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
621
|
+
const previewLines = treePreview(this.previewLines, this.previewLanguage);
|
|
622
|
+
const rows = [dim(` ● ${this.previewTitle} · preparing`), ...previewLines];
|
|
623
|
+
this.cardKind = 'activity';
|
|
624
|
+
this.agentActivity = rows.join(' · ');
|
|
625
|
+
this.writeActivityCard(rows, columns);
|
|
626
|
+
}
|
|
627
|
+
beginTurn() {
|
|
628
|
+
if (this.viewport.offset)
|
|
629
|
+
this.scrollChat(-Infinity);
|
|
630
|
+
this.toolCount = 0;
|
|
631
|
+
if (this.cardStart && this.cardEnd)
|
|
632
|
+
this.viewport.release(this.cardStart, this.cardEnd);
|
|
633
|
+
this.cardStart = this.cardEnd = null;
|
|
634
|
+
this.cardKind = null;
|
|
635
|
+
this.previewTitle = '';
|
|
636
|
+
this.previewLines = [];
|
|
637
|
+
this.previewLanguage = undefined;
|
|
638
|
+
this.previewTransient = false;
|
|
639
|
+
this.setAgentActivity('');
|
|
640
|
+
}
|
|
641
|
+
writeActivityCard(lines, columns) {
|
|
642
|
+
if (!this.dock)
|
|
643
|
+
return;
|
|
644
|
+
const card = lines.join('\n') + '\n';
|
|
645
|
+
if (this.cardStart && this.cardEnd) {
|
|
646
|
+
this.viewport.replace(this.cardStart, this.cardEnd, card, columns);
|
|
647
|
+
this.paintChat();
|
|
648
|
+
}
|
|
649
|
+
else {
|
|
650
|
+
this.endTurn();
|
|
651
|
+
this.cardStart = this.viewport.position();
|
|
652
|
+
this.writeChat(card);
|
|
653
|
+
this.cardEnd = this.viewport.position();
|
|
654
|
+
}
|
|
655
|
+
}
|
|
656
|
+
setAgentActivity(text) {
|
|
657
|
+
this.cardKind = 'activity';
|
|
658
|
+
this.agentActivity = text;
|
|
659
|
+
if (!this.dock || !text)
|
|
660
|
+
return;
|
|
661
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
662
|
+
const title = clean(text);
|
|
663
|
+
const styled = title.includes('✓') ? green(bold(title)) : title.includes('⊘') ? yellow(title)
|
|
664
|
+
: title.includes(':') && /^\d+ tools? · /.test(title) ? red(title) : green(bold(title));
|
|
665
|
+
const lines = [dim(` ● ${styled}`), ...treePreview(this.previewLines, this.previewLanguage)];
|
|
666
|
+
this.writeActivityCard(lines, columns);
|
|
667
|
+
}
|
|
668
|
+
activity(label, text) {
|
|
669
|
+
if (!this.dock)
|
|
670
|
+
return false;
|
|
671
|
+
if (label.startsWith('compacting conversation'))
|
|
672
|
+
this.setStatus(text);
|
|
673
|
+
else {
|
|
674
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
675
|
+
const command = this.previewLines.find((line) => line.startsWith('$ '));
|
|
676
|
+
const lines = this.previewTransient && command
|
|
677
|
+
? [mutedRow(` ${command}`, columns), dim(` ${text}`)]
|
|
678
|
+
: [dim(` ● ${this.previewTitle || label} · ${text}`), ...treePreview(this.previewLines, this.previewLanguage)];
|
|
679
|
+
this.renderRunCard(lines, false, 'activity');
|
|
680
|
+
}
|
|
681
|
+
return true;
|
|
682
|
+
}
|
|
683
|
+
stopActivity(note) {
|
|
684
|
+
if (!this.agentActivity || this.agentActivity.includes('✓') || this.agentActivity.includes('⊘'))
|
|
685
|
+
return;
|
|
686
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
687
|
+
this.renderRunCard([mutedRow(` ⊘ ${this.previewTitle || 'tool'}`, columns), dim(` ${note}`)], true, 'activity');
|
|
688
|
+
if (!this.dock)
|
|
689
|
+
this.say(`⊘ ${this.previewTitle || 'tool'} · ${note}`);
|
|
690
|
+
if (this.cardStart && this.cardEnd)
|
|
691
|
+
this.viewport.release(this.cardStart, this.cardEnd);
|
|
692
|
+
this.cardStart = this.cardEnd = null;
|
|
693
|
+
}
|
|
694
|
+
finishActivity(label, final) {
|
|
695
|
+
if (label.startsWith('compacting conversation'))
|
|
696
|
+
return;
|
|
697
|
+
if (final)
|
|
698
|
+
this.toolCount++;
|
|
699
|
+
const result = this.previewTitle ? clean(final).replace(/\b(write_file|edit_file|make_dir|copy_path|move_path|remove_path|run_command)\b/, () => this.previewTitle) : clean(final);
|
|
700
|
+
if (final) {
|
|
701
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
702
|
+
const command = this.previewLines.find((line) => line.startsWith('$ '));
|
|
703
|
+
const lines = this.previewTransient && command
|
|
704
|
+
? [mutedRow(` ${command}`, columns), green(` ✓ ${result}`)]
|
|
705
|
+
: [green(` ✓ ${result}`), ...treePreview(this.previewLines, this.previewLanguage)];
|
|
706
|
+
this.cardKind = 'activity';
|
|
707
|
+
this.agentActivity = lines.join(' · ');
|
|
708
|
+
this.writeActivityCard(lines, columns);
|
|
709
|
+
if (!this.dock)
|
|
710
|
+
this.say(lines.join('\n'));
|
|
711
|
+
if (this.cardStart && this.cardEnd)
|
|
712
|
+
this.viewport.release(this.cardStart, this.cardEnd);
|
|
713
|
+
this.cardStart = this.cardEnd = null;
|
|
714
|
+
this.cardKind = null;
|
|
715
|
+
}
|
|
716
|
+
else
|
|
717
|
+
this.discardLiveCard();
|
|
718
|
+
}
|
|
719
|
+
/** Replace a single pending call preview as its status changes. Completed calls are
|
|
720
|
+
* committed once, so output never jumps from a live card into a rebuilt batch summary. */
|
|
721
|
+
renderRunCard(lines, commit, kind) {
|
|
722
|
+
if (this.cardStart && this.cardEnd && this.cardKind !== kind)
|
|
723
|
+
this.discardLiveCard();
|
|
724
|
+
this.cardKind = kind;
|
|
725
|
+
this.agentActivity = lines.join(' · ');
|
|
726
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
727
|
+
if (kind === 'activity') {
|
|
728
|
+
this.writeActivityCard(lines, columns);
|
|
729
|
+
if (commit) {
|
|
730
|
+
if (this.cardStart && this.cardEnd)
|
|
731
|
+
this.viewport.release(this.cardStart, this.cardEnd);
|
|
732
|
+
this.cardStart = this.cardEnd = null;
|
|
733
|
+
this.cardKind = null;
|
|
734
|
+
}
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
// A run-summary box already names the command it ran; drop the transient
|
|
738
|
+
// run_command status preview so the finished card is not duplicated.
|
|
739
|
+
const card = lines.join('\n') + '\n';
|
|
740
|
+
if (this.cardStart && this.cardEnd) {
|
|
741
|
+
this.viewport.replace(this.cardStart, this.cardEnd, card, columns);
|
|
742
|
+
this.paintChat();
|
|
743
|
+
}
|
|
744
|
+
else {
|
|
745
|
+
this.endTurn();
|
|
746
|
+
this.cardStart = this.viewport.position();
|
|
747
|
+
this.writeChat(card);
|
|
748
|
+
this.cardEnd = this.viewport.position();
|
|
749
|
+
}
|
|
750
|
+
if (commit) {
|
|
751
|
+
if (this.cardStart && this.cardEnd)
|
|
752
|
+
this.viewport.release(this.cardStart, this.cardEnd);
|
|
753
|
+
this.cardStart = this.cardEnd = null;
|
|
754
|
+
this.cardKind = null;
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
/** Render an in-flight tally by replacing the live card. */
|
|
758
|
+
holdToolRuns(runs) {
|
|
759
|
+
if (!runs.length || !this.dock)
|
|
760
|
+
return;
|
|
761
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
762
|
+
const rows = toolBatchRows(runs, columns, false);
|
|
763
|
+
// Keep the current file diff visible while the rest of this batch settles.
|
|
764
|
+
if (!this.previewTransient && this.previewLines.length)
|
|
765
|
+
rows.push(...treePreview(this.previewLines, this.previewLanguage));
|
|
766
|
+
this.renderRunCard(rows, false, 'activity');
|
|
767
|
+
}
|
|
768
|
+
/** Render a completed tool batch. Ctrl+O toggles its bounded output in place. */
|
|
769
|
+
finishToolRuns(runs) {
|
|
770
|
+
if (!runs.length)
|
|
771
|
+
return;
|
|
772
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
773
|
+
const snapshot = runs.map((run) => ({ ...run, subjects: run.subjects?.slice(), results: run.results?.map((r) => ({ ...r, lines: r.lines.slice() })) }));
|
|
774
|
+
const rows = toolBatchRows(snapshot, columns, false);
|
|
775
|
+
if (!this.dock) {
|
|
776
|
+
for (const row of rows)
|
|
777
|
+
this.say(row);
|
|
778
|
+
this.say('');
|
|
779
|
+
return;
|
|
780
|
+
}
|
|
781
|
+
if (this.lastToolCard)
|
|
782
|
+
this.viewport.release(this.lastToolCard.start, this.lastToolCard.end);
|
|
783
|
+
this.renderRunCard([...rows, ''], false, 'activity');
|
|
784
|
+
if (this.cardStart && this.cardEnd) {
|
|
785
|
+
this.lastToolCard = { start: this.cardStart, end: this.cardEnd, runs: snapshot };
|
|
786
|
+
this.cardStart = this.cardEnd = null;
|
|
787
|
+
this.cardKind = null;
|
|
788
|
+
this.toolDetailsExpanded = false;
|
|
789
|
+
}
|
|
790
|
+
}
|
|
791
|
+
toggleToolDetails() {
|
|
792
|
+
if (!this.dock || !this.lastToolCard)
|
|
793
|
+
return false;
|
|
794
|
+
this.toolDetailsExpanded = !this.toolDetailsExpanded;
|
|
795
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
796
|
+
const { start, end, runs } = this.lastToolCard;
|
|
797
|
+
// Expanded cards must not consume the whole visible transcript. Share the
|
|
798
|
+
// available rows across calls, keeping every header and a continuation hint.
|
|
799
|
+
const perCall = Math.max(1, Math.floor(Math.max(1, this.chatRows - runs.length * 5 - 3) / runs.length));
|
|
800
|
+
const visibleRuns = this.toolDetailsExpanded ? runs.map((run) => ({ ...run, results: run.results?.map((result) => {
|
|
801
|
+
if (result.lines.length <= perCall)
|
|
802
|
+
return result;
|
|
803
|
+
return { ...result, lines: [...result.lines.slice(0, perCall), `… ${result.lines.length - perCall} more lines in session transcript`] };
|
|
804
|
+
}) })) : runs;
|
|
805
|
+
this.viewport.replace(start, end, toolBatchRows(visibleRuns, columns, this.toolDetailsExpanded).join('\n') + '\n\n', columns);
|
|
806
|
+
this.paintChat();
|
|
807
|
+
return true;
|
|
808
|
+
}
|
|
809
|
+
/** Live thinking card: spinner frame, elapsed time, and active output-token estimate. */
|
|
810
|
+
setThinking(elapsedSeconds, outputTokens, frame, phase = 'waiting for model') {
|
|
811
|
+
this.thinkingSnapshot = [elapsedSeconds, outputTokens, frame, phase];
|
|
812
|
+
this.setBoostThinking(true);
|
|
813
|
+
if (!this.dock)
|
|
814
|
+
return;
|
|
815
|
+
const elapsed = elapsedSeconds < 60 ? `${elapsedSeconds}s`
|
|
816
|
+
: `${Math.floor(elapsedSeconds / 60)}m ${String(elapsedSeconds % 60).padStart(2, '0')}s`;
|
|
817
|
+
const tok = outputTokens >= 1000
|
|
818
|
+
? `${(outputTokens / 1000).toFixed(outputTokens >= 10000 ? 0 : 1).replace(/\.0$/, '')}k`
|
|
819
|
+
: String(outputTokens);
|
|
820
|
+
const steer = this.queuedSteering ? `${this.queuedSteering} steering queued` : 'Enter to steer';
|
|
821
|
+
this.renderRunCard([dim(` ${frame} thinking · ${elapsed}${outputTokens ? ` · ${tok} tok` : ''} · ${steer}`)], false, 'thinking');
|
|
822
|
+
}
|
|
823
|
+
/** Remove the live thinking card when the model starts responding or calls a tool. */
|
|
824
|
+
endThinking() {
|
|
825
|
+
this.thinkingSnapshot = null;
|
|
826
|
+
this.setBoostThinking(false);
|
|
827
|
+
if (this.cardKind === 'thinking')
|
|
828
|
+
this.discardLiveCard();
|
|
829
|
+
}
|
|
830
|
+
discardLiveCard() {
|
|
831
|
+
if (this.cardStart && this.cardEnd) {
|
|
832
|
+
this.viewport.remove(this.cardStart, this.cardEnd);
|
|
833
|
+
this.viewport.release(this.cardStart, this.cardEnd);
|
|
834
|
+
}
|
|
835
|
+
this.cardStart = this.cardEnd = null;
|
|
836
|
+
this.cardKind = null;
|
|
837
|
+
this.agentActivity = '';
|
|
838
|
+
if (this.dock)
|
|
839
|
+
this.paintChat();
|
|
840
|
+
}
|
|
841
|
+
commandNotice(text) {
|
|
842
|
+
if (!this.dock) {
|
|
843
|
+
this.info(text);
|
|
844
|
+
return;
|
|
845
|
+
}
|
|
846
|
+
this.footerNotice = text;
|
|
847
|
+
this.drawDock();
|
|
848
|
+
}
|
|
849
|
+
openApproval(title, lines, hint) {
|
|
850
|
+
this.approvalTitle = clean(title);
|
|
851
|
+
this.approvalLines = lines.map(clean);
|
|
852
|
+
this.approvalHint = clean(hint);
|
|
853
|
+
this.approvalChoice = '';
|
|
854
|
+
this.approvalCursor = 0;
|
|
855
|
+
this.approvalTop = 0;
|
|
856
|
+
if (!this.dock)
|
|
857
|
+
return;
|
|
858
|
+
this.reflow();
|
|
859
|
+
this.drawDock();
|
|
860
|
+
}
|
|
861
|
+
hasApproval() { return this.dock && !!this.approvalTitle; }
|
|
862
|
+
setApprovalChoice(text, cursor) {
|
|
863
|
+
if (!this.hasApproval())
|
|
864
|
+
return false;
|
|
865
|
+
this.approvalChoice = clean(text);
|
|
866
|
+
this.approvalCursor = cursor;
|
|
867
|
+
this.drawDock();
|
|
868
|
+
return true;
|
|
869
|
+
}
|
|
870
|
+
scrollApproval(delta) {
|
|
871
|
+
if (!this.hasApproval())
|
|
872
|
+
return;
|
|
873
|
+
const visible = this.approvalPageSize();
|
|
874
|
+
this.approvalTop = Math.max(0, Math.min(Math.max(0, this.approvalRows().length - visible), this.approvalTop + delta));
|
|
875
|
+
this.drawDock();
|
|
876
|
+
}
|
|
877
|
+
closeApproval() {
|
|
878
|
+
if (!this.approvalTitle)
|
|
879
|
+
return;
|
|
880
|
+
this.approvalTitle = '';
|
|
881
|
+
this.approvalLines = [];
|
|
882
|
+
this.approvalHint = '';
|
|
883
|
+
this.approvalChoice = '';
|
|
884
|
+
this.approvalCursor = 0;
|
|
885
|
+
this.approvalTop = 0;
|
|
886
|
+
if (!this.dock)
|
|
887
|
+
return;
|
|
888
|
+
this.reflow();
|
|
889
|
+
this.drawDock();
|
|
890
|
+
}
|
|
891
|
+
panel(lines, cancel) {
|
|
892
|
+
if (!this.dock)
|
|
893
|
+
return false;
|
|
894
|
+
if (cancel)
|
|
895
|
+
this.panelCancel = cancel;
|
|
896
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
897
|
+
this.panelLines = lines.flatMap((line) => {
|
|
898
|
+
const rows = [];
|
|
899
|
+
let text = clean(line);
|
|
900
|
+
while (width(text) > columns) {
|
|
901
|
+
const part = fit(text, columns);
|
|
902
|
+
rows.push(part);
|
|
903
|
+
text = text.slice(part.length);
|
|
904
|
+
}
|
|
905
|
+
rows.push(text);
|
|
906
|
+
return rows;
|
|
907
|
+
});
|
|
908
|
+
this.reflow();
|
|
909
|
+
this.drawDock();
|
|
910
|
+
return true;
|
|
911
|
+
}
|
|
912
|
+
scrollPanel(delta) {
|
|
913
|
+
if (!this.panelLines)
|
|
914
|
+
return;
|
|
915
|
+
this.panelTop = Math.max(0, Math.min(this.panelLines.length - 1, this.panelTop + delta));
|
|
916
|
+
this.drawDock();
|
|
917
|
+
}
|
|
918
|
+
closePanel() {
|
|
919
|
+
if (!this.panelLines)
|
|
920
|
+
return;
|
|
921
|
+
this.panelLines = null;
|
|
922
|
+
this.panelCancel = null;
|
|
923
|
+
this.panelTop = 0;
|
|
924
|
+
this.reflow();
|
|
925
|
+
this.drawDock();
|
|
926
|
+
}
|
|
927
|
+
welcome(model, cwd) {
|
|
928
|
+
if (!process.stdout.isTTY) {
|
|
929
|
+
this.say(`flashlite · model ${model} · cwd ${cwd} · type / for commands`);
|
|
930
|
+
return;
|
|
931
|
+
}
|
|
932
|
+
this.model = model;
|
|
933
|
+
this.openDock();
|
|
934
|
+
// Header text sits beside the glyph: cwd and the hint align to its top rows.
|
|
935
|
+
const beside = [dim(cwd), dim('What are we building? / commands · ctrl+v image')];
|
|
936
|
+
for (let row = 0; row < WELCOME_GLYPH.length; row++) {
|
|
937
|
+
const glyph = accent(WELCOME_GLYPH[row]);
|
|
938
|
+
this.say(beside[row] ? ` ${glyph} ${beside[row]}` : ` ${glyph}`);
|
|
939
|
+
}
|
|
940
|
+
this.say('');
|
|
941
|
+
}
|
|
942
|
+
/** Reserve five bottom rows; keep shell history in scrollback (no alternate screen). */
|
|
943
|
+
openDock() {
|
|
944
|
+
if (this.dock || !process.stdin.isTTY || !process.stdout.isTTY || (process.stdout.rows || 24) < 10)
|
|
945
|
+
return;
|
|
946
|
+
this.dock = true;
|
|
947
|
+
this.dockRows.clear();
|
|
948
|
+
process.stdout.write('\n'.repeat(process.stdout.rows || 24));
|
|
949
|
+
this.chatRows = (process.stdout.rows || 24) - this.dockHeight();
|
|
950
|
+
this.chatRow = 1;
|
|
951
|
+
this.chatColumn = 1;
|
|
952
|
+
// Leave clicks and drags to the terminal for native text selection. Page keys
|
|
953
|
+
// navigate retained chat; the mouse wheel remains native terminal scrolling.
|
|
954
|
+
process.stdout.write(`\x1b[?1000l\x1b[?1002l\x1b[?1003l\x1b[?1006l\x1b[1;${this.chatRows}r\x1b[1;1H\x1b7`);
|
|
955
|
+
process.stdout.off('resize', this.onResize);
|
|
956
|
+
process.stdout.on('resize', this.onResize);
|
|
957
|
+
process.once('exit', this.onExit);
|
|
958
|
+
this.drawDock();
|
|
959
|
+
this.startBoostSweep();
|
|
960
|
+
}
|
|
961
|
+
closeDock() {
|
|
962
|
+
this.stopBoostSweep();
|
|
963
|
+
process.stdout.off('resize', this.onResize);
|
|
964
|
+
process.off('exit', this.onExit);
|
|
965
|
+
if (!this.dock)
|
|
966
|
+
return;
|
|
967
|
+
this.dock = false;
|
|
968
|
+
this.dockRows.clear();
|
|
969
|
+
process.stdout.write(`\x1b[?1000l\x1b[?1006l\x1b[${this.chatRows + 1};1H\x1b[J\x1b[r\x1b[${process.stdout.rows || 24};1H\x1b[?25h`);
|
|
970
|
+
this.menuItems = null;
|
|
971
|
+
this.menuCancel = null;
|
|
972
|
+
this.panelLines = null;
|
|
973
|
+
this.panelCancel = null;
|
|
974
|
+
this.lastToolCard = null;
|
|
975
|
+
}
|
|
976
|
+
onExit = () => this.closeDock();
|
|
977
|
+
onResize = () => {
|
|
978
|
+
this.dockRows.clear();
|
|
979
|
+
if ((process.stdout.rows || 24) < 10) {
|
|
980
|
+
if (this.dock) {
|
|
981
|
+
const cancel = this.menuCancel ?? this.panelCancel;
|
|
982
|
+
this.closeDock();
|
|
983
|
+
cancel?.();
|
|
984
|
+
process.stdout.on('resize', this.onResize);
|
|
985
|
+
process.once('exit', this.onExit);
|
|
986
|
+
}
|
|
987
|
+
this.composer(this.model, this.prompt, this.text, this.cursor);
|
|
988
|
+
return;
|
|
989
|
+
}
|
|
990
|
+
if (!this.dock) {
|
|
991
|
+
this.clearComposer();
|
|
992
|
+
this.openDock();
|
|
993
|
+
this.paintChat();
|
|
994
|
+
return;
|
|
995
|
+
}
|
|
996
|
+
this.reflow();
|
|
997
|
+
this.paintChat();
|
|
998
|
+
};
|
|
999
|
+
menuHeight() {
|
|
1000
|
+
const lines = this.menuItems ?? this.panelLines;
|
|
1001
|
+
return lines ? Math.min(lines.length, 8, Math.max(1, (process.stdout.rows || 24) - this.dockHeight() - 3)) + 1 : 0;
|
|
1002
|
+
}
|
|
1003
|
+
/** Scroll output clear of a newly opened popup before reducing its region. */
|
|
1004
|
+
reflow() {
|
|
1005
|
+
const next = Math.max(2, (process.stdout.rows || 24) - this.dockHeight() - this.menuHeight());
|
|
1006
|
+
if (next === this.chatRows)
|
|
1007
|
+
return; // width-only resize preserves the saved cursor
|
|
1008
|
+
this.dockRows.clear();
|
|
1009
|
+
process.stdout.write(`\x1b[${Math.min(this.chatRows + 1, process.stdout.rows || 24)};1H\x1b[J`);
|
|
1010
|
+
if (this.chatRow > next) {
|
|
1011
|
+
const shift = this.chatRow - next;
|
|
1012
|
+
process.stdout.write(`\x1b[${this.chatRows};1H` + '\n'.repeat(shift));
|
|
1013
|
+
this.chatRow -= shift;
|
|
1014
|
+
}
|
|
1015
|
+
this.chatRows = next;
|
|
1016
|
+
this.chatRow = Math.min(next, this.chatRow);
|
|
1017
|
+
this.chatColumn = Math.min(process.stdout.columns || 80, this.chatColumn);
|
|
1018
|
+
process.stdout.write(`\x1b[1;${next}r\x1b[${this.chatRow};${this.chatColumn}H\x1b7`);
|
|
1019
|
+
this.paintChat();
|
|
1020
|
+
}
|
|
1021
|
+
setStatus(status) {
|
|
1022
|
+
if (status !== 'ready')
|
|
1023
|
+
this.footerNotice = '';
|
|
1024
|
+
if (this.status === status)
|
|
1025
|
+
return;
|
|
1026
|
+
this.status = status;
|
|
1027
|
+
if (this.dock)
|
|
1028
|
+
this.drawDock();
|
|
1029
|
+
}
|
|
1030
|
+
setMode(mode) {
|
|
1031
|
+
this.mode = mode;
|
|
1032
|
+
if (this.dock)
|
|
1033
|
+
this.drawDock();
|
|
1034
|
+
}
|
|
1035
|
+
setEffort(effort) {
|
|
1036
|
+
this.effort = effort;
|
|
1037
|
+
if (this.dock)
|
|
1038
|
+
this.drawDock();
|
|
1039
|
+
}
|
|
1040
|
+
setBoost(enabled) {
|
|
1041
|
+
this.boost = enabled;
|
|
1042
|
+
if (!enabled)
|
|
1043
|
+
this.stopBoostSweep();
|
|
1044
|
+
else
|
|
1045
|
+
this.startBoostSweep();
|
|
1046
|
+
if (this.dock)
|
|
1047
|
+
this.drawDock();
|
|
1048
|
+
}
|
|
1049
|
+
stopBoostSweep() {
|
|
1050
|
+
if (this.boostSweep)
|
|
1051
|
+
clearInterval(this.boostSweep);
|
|
1052
|
+
this.boostSweep = null;
|
|
1053
|
+
this.boostFrame = -1;
|
|
1054
|
+
}
|
|
1055
|
+
/** The sweep runs only during an active model request, never at rest. */
|
|
1056
|
+
setBoostThinking(thinking) {
|
|
1057
|
+
this.boostThinking = thinking;
|
|
1058
|
+
if (thinking)
|
|
1059
|
+
this.startBoostSweep();
|
|
1060
|
+
else
|
|
1061
|
+
this.stopBoostSweep();
|
|
1062
|
+
if (this.dock)
|
|
1063
|
+
this.drawDock();
|
|
1064
|
+
}
|
|
1065
|
+
startBoostSweep() {
|
|
1066
|
+
if (!this.dock || !this.boost || !this.boostThinking || this.boostSweep || !motionEnabled())
|
|
1067
|
+
return;
|
|
1068
|
+
this.boostFrame = 0;
|
|
1069
|
+
this.drawDock();
|
|
1070
|
+
this.boostSweep = setInterval(() => {
|
|
1071
|
+
if (!this.dock || !this.boost || !this.boostThinking || !motionEnabled())
|
|
1072
|
+
this.stopBoostSweep();
|
|
1073
|
+
else
|
|
1074
|
+
this.boostFrame = (this.boostFrame + 1) % 24;
|
|
1075
|
+
if (this.dock)
|
|
1076
|
+
this.drawDock();
|
|
1077
|
+
}, 65);
|
|
1078
|
+
this.boostSweep.unref();
|
|
1079
|
+
}
|
|
1080
|
+
setSteering(queued, messages = []) {
|
|
1081
|
+
const oldHeight = this.dockHeight();
|
|
1082
|
+
this.queuedSteering = queued;
|
|
1083
|
+
this.steeringMessages = queued ? messages.map(clean) : [];
|
|
1084
|
+
if (this.dock) {
|
|
1085
|
+
if (oldHeight !== this.dockHeight())
|
|
1086
|
+
this.reflow();
|
|
1087
|
+
if (this.cardKind === 'thinking' && this.thinkingSnapshot)
|
|
1088
|
+
this.setThinking(...this.thinkingSnapshot);
|
|
1089
|
+
this.drawDock();
|
|
1090
|
+
}
|
|
1091
|
+
}
|
|
1092
|
+
setAttachments(names) {
|
|
1093
|
+
this.attachmentNames = names.map(clean);
|
|
1094
|
+
if (this.dock)
|
|
1095
|
+
this.drawDock();
|
|
1096
|
+
}
|
|
1097
|
+
/** Live todo list from the model's todo_write tool; replaces the previous list. */
|
|
1098
|
+
setTodos(lines) {
|
|
1099
|
+
const oldHeight = this.dockHeight();
|
|
1100
|
+
this.todos = lines.map(clean);
|
|
1101
|
+
if (this.dock) {
|
|
1102
|
+
if (oldHeight !== this.dockHeight())
|
|
1103
|
+
this.reflow();
|
|
1104
|
+
this.drawDock();
|
|
1105
|
+
}
|
|
1106
|
+
}
|
|
1107
|
+
clearTodos() {
|
|
1108
|
+
if (!this.todos.length)
|
|
1109
|
+
return;
|
|
1110
|
+
this.setTodos([]);
|
|
1111
|
+
}
|
|
1112
|
+
/** Persistent agent status above the composer; never inserted into chat history. */
|
|
1113
|
+
setSubagents(lines) {
|
|
1114
|
+
const before = this.dockHeight();
|
|
1115
|
+
this.subagentStatus = lines.map(clean);
|
|
1116
|
+
if (!this.dock)
|
|
1117
|
+
return;
|
|
1118
|
+
if (before !== this.dockHeight())
|
|
1119
|
+
this.reflow();
|
|
1120
|
+
this.drawDock();
|
|
1121
|
+
}
|
|
1122
|
+
/** Show the reverse-search row above the input; rank is the newest-first position. */
|
|
1123
|
+
setSearch(query, match, rank, total) {
|
|
1124
|
+
const wasActive = this.searchActive;
|
|
1125
|
+
this.searchQuery = query;
|
|
1126
|
+
this.searchMatch = match;
|
|
1127
|
+
this.searchRank = rank;
|
|
1128
|
+
this.searchTotal = total;
|
|
1129
|
+
this.searchActive = true;
|
|
1130
|
+
if (this.dock) {
|
|
1131
|
+
if (!wasActive && this.dockHeight() > 0)
|
|
1132
|
+
this.reflow();
|
|
1133
|
+
this.drawDock();
|
|
1134
|
+
}
|
|
1135
|
+
}
|
|
1136
|
+
clearSearch() {
|
|
1137
|
+
if (!this.searchActive)
|
|
1138
|
+
return;
|
|
1139
|
+
this.searchActive = false;
|
|
1140
|
+
this.searchQuery = '';
|
|
1141
|
+
this.searchMatch = '';
|
|
1142
|
+
this.searchRank = 0;
|
|
1143
|
+
this.searchTotal = 0;
|
|
1144
|
+
if (this.dock) {
|
|
1145
|
+
this.reflow();
|
|
1146
|
+
this.drawDock();
|
|
1147
|
+
}
|
|
1148
|
+
}
|
|
1149
|
+
setContextUsage(used, total) {
|
|
1150
|
+
if (used === undefined || !total) {
|
|
1151
|
+
this.contextUsage = '';
|
|
1152
|
+
if (this.dock)
|
|
1153
|
+
this.drawDock();
|
|
1154
|
+
return;
|
|
1155
|
+
}
|
|
1156
|
+
const consumed = Math.max(0, Math.min(total, Math.floor(used)));
|
|
1157
|
+
const percent = Math.round((consumed / total) * 100);
|
|
1158
|
+
// Numbers only — no bar; the percent already carries the pressure color.
|
|
1159
|
+
this.contextUsage = `ctx ${percent}% ${compactCount(consumed)}/${compactCount(total)}`;
|
|
1160
|
+
if (this.dock)
|
|
1161
|
+
this.drawDock();
|
|
1162
|
+
}
|
|
1163
|
+
composer(model, prompt, text, cursor) {
|
|
1164
|
+
if (text !== this.text && text)
|
|
1165
|
+
this.footerNotice = '';
|
|
1166
|
+
this.model = model;
|
|
1167
|
+
this.prompt = prompt;
|
|
1168
|
+
this.text = text;
|
|
1169
|
+
this.cursor = cursor;
|
|
1170
|
+
if (this.dock) {
|
|
1171
|
+
this.drawDock();
|
|
1172
|
+
return;
|
|
1173
|
+
}
|
|
1174
|
+
this.clearComposer();
|
|
1175
|
+
const columns = Math.max(8, (process.stdout.columns || 80) - 1);
|
|
1176
|
+
const status = fit(`─ ${model} `, columns);
|
|
1177
|
+
this.say(dim(status + '─'.repeat(Math.max(0, columns - width(status)))));
|
|
1178
|
+
const available = Math.max(1, columns - 2);
|
|
1179
|
+
const start = Math.max(0, cursor - available + 1);
|
|
1180
|
+
const visible = text.slice(start, start + available);
|
|
1181
|
+
this.line(prompt, visible, cursor - start);
|
|
1182
|
+
this.composerVisible = true;
|
|
1183
|
+
}
|
|
1184
|
+
clearComposer() {
|
|
1185
|
+
if (this.dock) {
|
|
1186
|
+
this.text = '';
|
|
1187
|
+
this.cursor = 0;
|
|
1188
|
+
this.drawDock();
|
|
1189
|
+
return;
|
|
1190
|
+
}
|
|
1191
|
+
if (!this.composerVisible)
|
|
1192
|
+
return;
|
|
1193
|
+
process.stdout.write('\r\x1b[1A\x1b[J');
|
|
1194
|
+
this.composerVisible = false;
|
|
1195
|
+
this.lineOpen = false;
|
|
1196
|
+
}
|
|
1197
|
+
submitInput(prompt, text) {
|
|
1198
|
+
if (this.viewport.offset)
|
|
1199
|
+
this.scrollChat(-Infinity);
|
|
1200
|
+
this.clearComposer();
|
|
1201
|
+
this.say(prompt + text);
|
|
1202
|
+
}
|
|
1203
|
+
/** Command controls live below the input, outside the scrolling conversation. */
|
|
1204
|
+
menu(items, index, top, cancel, hint = '↑/↓ move · enter select · esc/ctrl+c cancel') {
|
|
1205
|
+
if (!this.dock)
|
|
1206
|
+
return false;
|
|
1207
|
+
this.menuItems = items;
|
|
1208
|
+
this.menuCancel = cancel ?? null;
|
|
1209
|
+
this.menuHint = hint;
|
|
1210
|
+
this.menuIndex = index;
|
|
1211
|
+
this.menuTop = top;
|
|
1212
|
+
this.reflow();
|
|
1213
|
+
this.drawDock();
|
|
1214
|
+
return true;
|
|
1215
|
+
}
|
|
1216
|
+
closeMenu() {
|
|
1217
|
+
if (!this.dock || !this.menuItems)
|
|
1218
|
+
return;
|
|
1219
|
+
this.dockRows.clear();
|
|
1220
|
+
process.stdout.write(`\x1b[${this.chatRows + 1};1H\x1b[J`);
|
|
1221
|
+
this.menuItems = null;
|
|
1222
|
+
this.menuCancel = null;
|
|
1223
|
+
this.reflow();
|
|
1224
|
+
this.drawDock();
|
|
1225
|
+
}
|
|
1226
|
+
drawDock() {
|
|
1227
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
1228
|
+
let output = '';
|
|
1229
|
+
const put = (row, text) => {
|
|
1230
|
+
if (this.dockRows.get(row) === text)
|
|
1231
|
+
return;
|
|
1232
|
+
this.dockRows.set(row, text);
|
|
1233
|
+
output += `\x1b[${row};1H` + text + '\x1b[K';
|
|
1234
|
+
};
|
|
1235
|
+
let row = this.chatRows + 1;
|
|
1236
|
+
let approvalInputRow = 0;
|
|
1237
|
+
if (this.approvalTitle) {
|
|
1238
|
+
const available = Math.max(1, columns - 4);
|
|
1239
|
+
const title = fit(` ${this.approvalTitle} `, Math.max(1, columns - 4));
|
|
1240
|
+
put(row++, yellow('╭─') + yellow(bold(title)) + dim('─'.repeat(Math.max(0, columns - width(title) - 3))) + yellow('╮'));
|
|
1241
|
+
const body = (text, marker = '│') => yellow('│ ') + text + ' '.repeat(Math.max(0, columns - width(text) - 3)) + yellow(marker);
|
|
1242
|
+
const details = this.approvalRows();
|
|
1243
|
+
const pageSize = this.approvalPageSize();
|
|
1244
|
+
this.approvalTop = Math.min(this.approvalTop, Math.max(0, details.length - pageSize));
|
|
1245
|
+
const visible = details.slice(this.approvalTop, this.approvalTop + pageSize);
|
|
1246
|
+
for (let i = 0; i < visible.length; i++) {
|
|
1247
|
+
const above = i === 0 && this.approvalTop > 0;
|
|
1248
|
+
const below = i === visible.length - 1 && this.approvalTop + pageSize < details.length;
|
|
1249
|
+
const marker = above && below ? '↕' : above ? '↑' : below ? '↓' : '│';
|
|
1250
|
+
put(row++, body(visible[i], marker));
|
|
1251
|
+
}
|
|
1252
|
+
approvalInputRow = row;
|
|
1253
|
+
const choice = fit(this.approvalChoice, Math.max(1, columns - 6));
|
|
1254
|
+
const hintRoom = Math.max(0, columns - 8 - width(choice));
|
|
1255
|
+
const hint = details.length > pageSize
|
|
1256
|
+
? `${fit(this.approvalHint, hintRoom)} · PgUp/PgDn details`
|
|
1257
|
+
: fit(this.approvalHint, hintRoom);
|
|
1258
|
+
const answer = `${choice}${hint ? dim(' · ') + dim(hint) : ''}`;
|
|
1259
|
+
put(row++, yellow('╰─ ') + accent('› ') + answer + ' '.repeat(Math.max(0, columns - width(answer) - 6)) + yellow('╯'));
|
|
1260
|
+
}
|
|
1261
|
+
const pendingRows = this.steeringHeight();
|
|
1262
|
+
if (pendingRows) {
|
|
1263
|
+
const count = pendingRows === 1 ? 1 : pendingRows - 1;
|
|
1264
|
+
for (const message of this.steeringMessages.slice(-count)) {
|
|
1265
|
+
const raw = '› ' + message;
|
|
1266
|
+
const text = width(raw) > columns ? fit(raw, columns - 1) + '…' : raw;
|
|
1267
|
+
put(row++, mutedRow(text, columns));
|
|
1268
|
+
}
|
|
1269
|
+
if (pendingRows > 1) {
|
|
1270
|
+
const hidden = this.steeringMessages.length - count;
|
|
1271
|
+
put(row++, dim(fit(` ${hidden ? `${hidden} earlier · ` : ''}pending · sent at next model call`, columns)));
|
|
1272
|
+
}
|
|
1273
|
+
}
|
|
1274
|
+
const todoRows = this.todosHeight();
|
|
1275
|
+
if (todoRows) {
|
|
1276
|
+
put(row++, dim(fit(' todo', columns)));
|
|
1277
|
+
const count = todoRows - 1;
|
|
1278
|
+
for (const todo of this.todos.slice(-count)) {
|
|
1279
|
+
put(row++, dim(fit(` ${todo}`, columns)));
|
|
1280
|
+
}
|
|
1281
|
+
if (this.todos.length > count)
|
|
1282
|
+
put(row++, dim(fit(` +${this.todos.length - count} more`, columns)));
|
|
1283
|
+
}
|
|
1284
|
+
for (const text of this.subagentStatus.slice(-this.agentStatusHeight()))
|
|
1285
|
+
put(row++, mutedRow(` ◇ ${text}`, columns));
|
|
1286
|
+
if (this.searchActive) {
|
|
1287
|
+
const match = fit(this.searchMatch.replace(/\s+/g, ' '), Math.max(4, columns - 2));
|
|
1288
|
+
const rank = this.searchTotal > 1 ? dim(` (${this.searchRank}/${this.searchTotal})`) : '';
|
|
1289
|
+
put(row++, dim('(reverse-i-search`') + (this.searchQuery ? yellow(this.searchQuery) : '') + dim("': ") + match + rank + dim(')'));
|
|
1290
|
+
}
|
|
1291
|
+
const unguarded = this.mode === 'unguarded auto';
|
|
1292
|
+
const modelLabel = unguarded ? ` ${this.mode} · ${this.model}` : ` ${this.model}`;
|
|
1293
|
+
if (this.boost) {
|
|
1294
|
+
const content = fit(`${modelLabel} ϟ`, Math.max(0, columns - 3));
|
|
1295
|
+
const left = '─'.repeat(Math.max(1, columns - width(content) - 2));
|
|
1296
|
+
const plain = `${left}${content} ─`;
|
|
1297
|
+
const modelStart = width(left) + (content.startsWith(' ') ? 1 : 0);
|
|
1298
|
+
const boltStart = width(left) + Math.max(0, content.lastIndexOf('ϟ'));
|
|
1299
|
+
const sweep = this.boostFrame < 0 ? -1 : Math.floor(this.boostFrame / 23 * (width(plain) + 12)) - 6;
|
|
1300
|
+
put(row++, boostBar(plain, modelStart, boltStart, sweep));
|
|
1301
|
+
}
|
|
1302
|
+
else {
|
|
1303
|
+
const label = fit(`${modelLabel} ─`, Math.max(0, columns - 2));
|
|
1304
|
+
put(row++, dim('─'.repeat(Math.max(1, columns - width(label)))) + accent(bold(label)));
|
|
1305
|
+
}
|
|
1306
|
+
const inputRow = row;
|
|
1307
|
+
const prefix = fit(this.prompt, Math.max(2, columns - 8));
|
|
1308
|
+
const available = Math.max(1, columns - width(prefix));
|
|
1309
|
+
// Slice by cells around the caret, preserving the editor's UTF-16 cursor contract.
|
|
1310
|
+
let start = this.cursor;
|
|
1311
|
+
let before = '';
|
|
1312
|
+
for (const c of [...this.text.slice(0, this.cursor)].reverse()) {
|
|
1313
|
+
if (width(c + before) >= available)
|
|
1314
|
+
break;
|
|
1315
|
+
before = c + before;
|
|
1316
|
+
start -= c.length;
|
|
1317
|
+
}
|
|
1318
|
+
const visible = fit(this.text.slice(start), available);
|
|
1319
|
+
const promptText = this.boost ? wrap(prefix, '1;38;2;255;115;0') : orange(prefix);
|
|
1320
|
+
put(row++, promptText + visible);
|
|
1321
|
+
const lowerRule = '─'.repeat(columns);
|
|
1322
|
+
const lowerSweep = this.boostFrame < 0 ? -1 : Math.floor(this.boostFrame / 23 * (columns + 12)) - 6;
|
|
1323
|
+
put(row++, this.boost ? boostBar(lowerRule, columns, columns, lowerSweep) : dim(lowerRule));
|
|
1324
|
+
if (this.menuItems) {
|
|
1325
|
+
const count = this.menuHeight() - 1;
|
|
1326
|
+
if (this.menuIndex < this.menuTop)
|
|
1327
|
+
this.menuTop = this.menuIndex;
|
|
1328
|
+
if (this.menuIndex >= this.menuTop + count)
|
|
1329
|
+
this.menuTop = this.menuIndex - count + 1;
|
|
1330
|
+
for (let i = 0; i < count; i++) {
|
|
1331
|
+
const index = this.menuTop + i;
|
|
1332
|
+
const label = fit(this.menuItems[index] ?? '', columns - 2);
|
|
1333
|
+
const styled = label.replace(/^(\/\w+)(\s+)(.*)$/, (_, command, gap, description) => accent(command) + gap + dim(description));
|
|
1334
|
+
put(row++, index === this.menuIndex ? orange('› ') + accent(bold(label)) : ' ' + styled);
|
|
1335
|
+
}
|
|
1336
|
+
put(row++, dim(fit(this.menuHint, columns)));
|
|
1337
|
+
}
|
|
1338
|
+
else if (this.panelLines) {
|
|
1339
|
+
const count = this.menuHeight() - 1;
|
|
1340
|
+
this.panelTop = Math.min(this.panelTop, Math.max(0, this.panelLines.length - count));
|
|
1341
|
+
for (let i = 0; i < count; i++) {
|
|
1342
|
+
const index = this.panelTop + i;
|
|
1343
|
+
const text = fit(this.panelLines[index] ?? '', columns);
|
|
1344
|
+
put(row++, index === 0 ? accent(bold(text)) : text.startsWith(' ') ? text : usageStyle(text));
|
|
1345
|
+
}
|
|
1346
|
+
put(row++, dim(fit('↑/↓ scroll · r refresh · enter/esc close', columns)));
|
|
1347
|
+
}
|
|
1348
|
+
if (this.menuItems || this.panelLines) {
|
|
1349
|
+
const caretRow = approvalInputRow || inputRow;
|
|
1350
|
+
const caretColumn = approvalInputRow ? Math.min(columns, 5 + Math.min(this.approvalCursor, width(this.approvalChoice)) + 1)
|
|
1351
|
+
: Math.min(columns, width(prefix) + width(before) + 1);
|
|
1352
|
+
process.stdout.write(output + `\x1b[${caretRow};${caretColumn}H`);
|
|
1353
|
+
return;
|
|
1354
|
+
}
|
|
1355
|
+
const modelWorking = this.status === 'waiting for model' || this.status === 'generating';
|
|
1356
|
+
const footerStatus = modelWorking ? 'ready' : this.status.startsWith('thinking') ? 'thinking · ctrl+c to stop' : this.status;
|
|
1357
|
+
const activity = this.viewport.offset ? 'history · PgDn to live' : footerStatus;
|
|
1358
|
+
const attachments = this.attachmentNames.length ? `${this.attachmentNames.length} image${this.attachmentNames.length === 1 ? '' : 's'} attached · ${this.attachmentNames.join(' · ')}` : '';
|
|
1359
|
+
const steering = this.queuedSteering === null || modelWorking ? '' : `${this.queuedSteering ? `${this.queuedSteering} steering queued` : 'Enter to steer'} · ${activity}`;
|
|
1360
|
+
const modeCore = this.mode;
|
|
1361
|
+
const modeLabel = modeCore === 'ask before writes' ? 'ask' : modeCore;
|
|
1362
|
+
const effortLabel = this.effort === 'medium' ? 'med' : this.effort;
|
|
1363
|
+
// While the model reasons, the chat card carries the frame/elapsed/tokens; the footer
|
|
1364
|
+
// narrows to a compact "thinking" marker plus the cancellation shortcut so the mode and
|
|
1365
|
+
// effort tokens do not crowd it out on narrow terminals.
|
|
1366
|
+
const thinking = !this.viewport.offset && !this.footerNotice && this.status.startsWith('thinking');
|
|
1367
|
+
let renderedStatus;
|
|
1368
|
+
if (this.footerNotice) {
|
|
1369
|
+
renderedStatus = accent(fit(this.footerNotice, columns));
|
|
1370
|
+
}
|
|
1371
|
+
else if (attachments && !this.commandMode) {
|
|
1372
|
+
renderedStatus = accent(fit(attachments, columns));
|
|
1373
|
+
}
|
|
1374
|
+
else if (thinking) {
|
|
1375
|
+
const head = yellow('thinking');
|
|
1376
|
+
const longHint = ' · ctrl+c to stop';
|
|
1377
|
+
const hint = width(head) + width(longHint) <= columns - 1 ? longHint : ' · ^C stop';
|
|
1378
|
+
renderedStatus = head + dim(fit(hint, Math.max(0, columns - width(head))));
|
|
1379
|
+
}
|
|
1380
|
+
else {
|
|
1381
|
+
// Every token names itself: a dim short label plus a colored value, so mode and
|
|
1382
|
+
// effort are never ambiguous. The effort value warms toward red as the level rises.
|
|
1383
|
+
// A resting `ready` status is omitted — the prompt already says the CLI is idle.
|
|
1384
|
+
const statusSource = this.commandMode ? this.status : steering || activity;
|
|
1385
|
+
const modeColor = modeCore === 'unguarded auto' || modeCore === 'guarded auto' || modeCore === 'auto mode on' ? yellow : dim;
|
|
1386
|
+
const head = [
|
|
1387
|
+
labeled('mode', modeLabel, modeColor),
|
|
1388
|
+
labeled('effort', effortLabel, effortColor(this.effort)),
|
|
1389
|
+
].join(dim(' · '));
|
|
1390
|
+
renderedStatus = statusSource === 'ready'
|
|
1391
|
+
? head
|
|
1392
|
+
: head + dim(' · ') + yellow(fit(statusSource, Math.max(0, columns - width(head) - 3)));
|
|
1393
|
+
}
|
|
1394
|
+
// The context amount is never hidden: reserve space for it first, then trim the
|
|
1395
|
+
// status around it when the line is tight.
|
|
1396
|
+
const contextAmount = this.contextUsage;
|
|
1397
|
+
const ctxWidth = contextAmount ? width(contextAmount) : 0;
|
|
1398
|
+
let statusText = renderedStatus;
|
|
1399
|
+
if (ctxWidth && columns - width(statusText) < 3 + ctxWidth) {
|
|
1400
|
+
statusText = fit(statusText, Math.max(0, columns - 3 - ctxWidth));
|
|
1401
|
+
}
|
|
1402
|
+
const footer = contextAmount ? `${statusText}${dim(' · ')}${usageStyle(contextAmount)}` : statusText;
|
|
1403
|
+
// Footer separators stay muted in every mode; effort retains its semantic color
|
|
1404
|
+
// instead of inheriting the Boost bar's orange accent.
|
|
1405
|
+
put(row, footer);
|
|
1406
|
+
const caretRow = approvalInputRow || inputRow;
|
|
1407
|
+
const caretColumn = approvalInputRow ? Math.min(columns, 5 + Math.min(this.approvalCursor, width(this.approvalChoice)) + 1)
|
|
1408
|
+
: Math.min(columns, width(prefix) + width(before) + 1);
|
|
1409
|
+
process.stdout.write(output + `\x1b[${caretRow};${caretColumn}H`);
|
|
1410
|
+
}
|
|
1411
|
+
scrollChat(delta) {
|
|
1412
|
+
if (!this.dock)
|
|
1413
|
+
return;
|
|
1414
|
+
this.viewport.scroll(delta, process.stdout.columns || 80, this.chatRows);
|
|
1415
|
+
this.paintChat();
|
|
1416
|
+
}
|
|
1417
|
+
paintChat() {
|
|
1418
|
+
if (!this.dock)
|
|
1419
|
+
return;
|
|
1420
|
+
const columns = process.stdout.columns || 80;
|
|
1421
|
+
const lines = this.viewport.visible(columns, this.chatRows);
|
|
1422
|
+
// A dock modal (approval, menu, panel) layers over the conversation; faint it so the
|
|
1423
|
+
// modal reads on top. Plain text is unchanged — only intensity is added.
|
|
1424
|
+
const modal = this.modalFocused();
|
|
1425
|
+
for (let row = 1; row <= this.chatRows; row++) {
|
|
1426
|
+
const line = lines[row - 1] ?? '';
|
|
1427
|
+
process.stdout.write(`\x1b[${row};1H\x1b[2K` + (modal ? faint(line) : line));
|
|
1428
|
+
}
|
|
1429
|
+
const caret = this.viewport.caret(columns, this.chatRows);
|
|
1430
|
+
this.chatRow = caret.row;
|
|
1431
|
+
this.chatColumn = caret.column;
|
|
1432
|
+
this.restoredWrap = caret.column > columns;
|
|
1433
|
+
process.stdout.write(`\x1b[${this.chatRow};${Math.min(columns, this.chatColumn)}H\x1b7`);
|
|
1434
|
+
this.drawDock();
|
|
1435
|
+
}
|
|
1436
|
+
chatPosition() { return this.viewport.position(); }
|
|
1437
|
+
removeChat(start, end) {
|
|
1438
|
+
this.viewport.remove(start, end);
|
|
1439
|
+
this.viewport.release(start, end);
|
|
1440
|
+
if (this.dock)
|
|
1441
|
+
this.paintChat();
|
|
1442
|
+
}
|
|
1443
|
+
clearChat() {
|
|
1444
|
+
this.cardStart = this.cardEnd = null;
|
|
1445
|
+
this.lastToolCard = null;
|
|
1446
|
+
this.toolDetailsExpanded = false;
|
|
1447
|
+
this.agentActivity = '';
|
|
1448
|
+
this.viewport.clear();
|
|
1449
|
+
if (!this.dock)
|
|
1450
|
+
return;
|
|
1451
|
+
for (let row = 1; row <= this.chatRows; row++)
|
|
1452
|
+
process.stdout.write(`\x1b[${row};1H\x1b[2K`);
|
|
1453
|
+
this.chatRow = this.chatColumn = 1;
|
|
1454
|
+
this.restoredWrap = false;
|
|
1455
|
+
this.lineOpen = false;
|
|
1456
|
+
process.stdout.write('\x1b[1;1H\x1b7');
|
|
1457
|
+
this.drawDock();
|
|
1458
|
+
}
|
|
1459
|
+
/** DEC save/restore tracks the chat cursor independently of the input caret. */
|
|
1460
|
+
writeChat(text) {
|
|
1461
|
+
this.viewport.append(text, process.stdout.columns || 80);
|
|
1462
|
+
if (this.dock && this.viewport.offset) {
|
|
1463
|
+
this.paintChat();
|
|
1464
|
+
return;
|
|
1465
|
+
}
|
|
1466
|
+
if (!this.dock) {
|
|
1467
|
+
process.stdout.write(text);
|
|
1468
|
+
return;
|
|
1469
|
+
}
|
|
1470
|
+
const first = stripVTControlCharacters(text)[0];
|
|
1471
|
+
const wrap = this.restoredWrap && first && first !== '\n' && first !== '\r' ? '\r\n' : '';
|
|
1472
|
+
if (first)
|
|
1473
|
+
this.restoredWrap = false;
|
|
1474
|
+
process.stdout.write('\x1b8' + wrap + text + '\x1b7');
|
|
1475
|
+
const columns = process.stdout.columns || 80;
|
|
1476
|
+
for (const c of stripVTControlCharacters(wrap + text)) {
|
|
1477
|
+
if (c === '\r')
|
|
1478
|
+
this.chatColumn = 1;
|
|
1479
|
+
else if (c === '\n') {
|
|
1480
|
+
this.chatRow = Math.min(this.chatRows, this.chatRow + 1);
|
|
1481
|
+
this.chatColumn = 1;
|
|
1482
|
+
}
|
|
1483
|
+
else if (c >= ' ') {
|
|
1484
|
+
if (this.chatColumn > columns) {
|
|
1485
|
+
this.chatRow = Math.min(this.chatRows, this.chatRow + 1);
|
|
1486
|
+
this.chatColumn = 1;
|
|
1487
|
+
}
|
|
1488
|
+
this.chatColumn += cells(c);
|
|
1489
|
+
}
|
|
1490
|
+
}
|
|
1491
|
+
this.drawDock();
|
|
1492
|
+
}
|
|
1493
|
+
streamText(text) {
|
|
1494
|
+
this.writeChat(text);
|
|
1495
|
+
this.lineOpen = true;
|
|
1496
|
+
}
|
|
1497
|
+
streamReasoning(text) {
|
|
1498
|
+
this.writeChat(dim(text));
|
|
1499
|
+
this.lineOpen = true;
|
|
1500
|
+
}
|
|
1501
|
+
endTurn() {
|
|
1502
|
+
if (this.lineOpen)
|
|
1503
|
+
this.writeChat('\n');
|
|
1504
|
+
this.lineOpen = false;
|
|
1505
|
+
}
|
|
1506
|
+
clearLine() {
|
|
1507
|
+
if (!this.dock)
|
|
1508
|
+
this.clearComposer();
|
|
1509
|
+
this.writeChat('\r\x1b[K');
|
|
1510
|
+
this.lineOpen = false;
|
|
1511
|
+
}
|
|
1512
|
+
rewrite(text) {
|
|
1513
|
+
this.clearLine();
|
|
1514
|
+
this.writeChat(text);
|
|
1515
|
+
this.lineOpen = !text.endsWith('\n');
|
|
1516
|
+
}
|
|
1517
|
+
line(prompt, text, cursor) {
|
|
1518
|
+
if (this.dock) {
|
|
1519
|
+
this.prompt = prompt;
|
|
1520
|
+
this.text = text;
|
|
1521
|
+
this.cursor = cursor;
|
|
1522
|
+
this.drawDock();
|
|
1523
|
+
return;
|
|
1524
|
+
}
|
|
1525
|
+
this.clearLine();
|
|
1526
|
+
process.stdout.write(prompt + text);
|
|
1527
|
+
const back = text.length - cursor;
|
|
1528
|
+
if (back > 0)
|
|
1529
|
+
process.stdout.write(`\x1b[${back}D`);
|
|
1530
|
+
}
|
|
1531
|
+
say(line) {
|
|
1532
|
+
if (!this.dock)
|
|
1533
|
+
this.clearComposer();
|
|
1534
|
+
this.writeChat(line + '\n');
|
|
1535
|
+
this.lineOpen = false;
|
|
1536
|
+
}
|
|
1537
|
+
warn(line) { this.say(yellow('! ') + line); }
|
|
1538
|
+
error(line) { this.say(red('× ') + line); }
|
|
1539
|
+
info(line) { this.say(dim('· ') + line); }
|
|
1540
|
+
notice(line) {
|
|
1541
|
+
if (!this.dock) {
|
|
1542
|
+
this.info(line);
|
|
1543
|
+
return;
|
|
1544
|
+
}
|
|
1545
|
+
const columns = Math.max(4, (process.stdout.columns || 80) - 1);
|
|
1546
|
+
this.say(noticeRow(line, columns));
|
|
1547
|
+
}
|
|
1548
|
+
spinner(text) { return new Spinner(text, this); }
|
|
1549
|
+
}
|
|
1550
|
+
const SPINNER_FRAMES = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
1551
|
+
/** Delayed activity indicator; never animates in pipes or under NO_COLOR. */
|
|
1552
|
+
export class Spinner {
|
|
1553
|
+
text;
|
|
1554
|
+
sink;
|
|
1555
|
+
timer = null;
|
|
1556
|
+
frame = 0;
|
|
1557
|
+
shown = false;
|
|
1558
|
+
done = false;
|
|
1559
|
+
live = false;
|
|
1560
|
+
constructor(text, sink) {
|
|
1561
|
+
this.text = text;
|
|
1562
|
+
this.sink = sink;
|
|
1563
|
+
}
|
|
1564
|
+
start(delayMs = 300) {
|
|
1565
|
+
this.live = this.sink.activity(this.text, this.text);
|
|
1566
|
+
if (!animated()) {
|
|
1567
|
+
if (this.text && !this.live)
|
|
1568
|
+
this.sink.info(this.text);
|
|
1569
|
+
return this;
|
|
1570
|
+
}
|
|
1571
|
+
this.arm(delayMs);
|
|
1572
|
+
return this;
|
|
1573
|
+
}
|
|
1574
|
+
update(text) {
|
|
1575
|
+
if (this.done)
|
|
1576
|
+
return;
|
|
1577
|
+
this.text = text;
|
|
1578
|
+
this.live = this.sink.activity(this.text, this.text);
|
|
1579
|
+
}
|
|
1580
|
+
pause() {
|
|
1581
|
+
if (this.timer) {
|
|
1582
|
+
clearTimeout(this.timer);
|
|
1583
|
+
clearInterval(this.timer);
|
|
1584
|
+
this.timer = null;
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1587
|
+
resume() {
|
|
1588
|
+
if (!animated() || this.done || this.timer)
|
|
1589
|
+
return;
|
|
1590
|
+
this.arm(150);
|
|
1591
|
+
}
|
|
1592
|
+
arm(delayMs) {
|
|
1593
|
+
this.timer = setTimeout(() => {
|
|
1594
|
+
this.shown = true;
|
|
1595
|
+
this.tick();
|
|
1596
|
+
this.timer = setInterval(() => this.tick(), 100);
|
|
1597
|
+
}, delayMs);
|
|
1598
|
+
}
|
|
1599
|
+
tick() {
|
|
1600
|
+
const text = SPINNER_FRAMES[this.frame++ % SPINNER_FRAMES.length] + ' ' + this.text;
|
|
1601
|
+
if (this.live)
|
|
1602
|
+
this.sink.activity(this.text, text);
|
|
1603
|
+
else
|
|
1604
|
+
this.sink.rewrite(text);
|
|
1605
|
+
}
|
|
1606
|
+
stop(final, keepActivity = false) {
|
|
1607
|
+
if (this.done)
|
|
1608
|
+
return;
|
|
1609
|
+
this.done = true;
|
|
1610
|
+
this.pause();
|
|
1611
|
+
if (keepActivity)
|
|
1612
|
+
return;
|
|
1613
|
+
if (this.live) {
|
|
1614
|
+
this.sink.finishActivity(this.text, final);
|
|
1615
|
+
return;
|
|
1616
|
+
}
|
|
1617
|
+
if (!animated()) {
|
|
1618
|
+
if (final !== '')
|
|
1619
|
+
this.sink.say(final);
|
|
1620
|
+
return;
|
|
1621
|
+
}
|
|
1622
|
+
if (final === '')
|
|
1623
|
+
this.sink.clearLine();
|
|
1624
|
+
else if (this.shown)
|
|
1625
|
+
this.sink.rewrite(final + '\n');
|
|
1626
|
+
else
|
|
1627
|
+
this.sink.say(final);
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
export const ui = new UiImpl();
|