@ryuu-reinzz/ryuu-cli 0.0.0-stage → 0.3.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +560 -0
- package/LICENSE +21 -0
- package/README.md +1213 -2
- package/package.json +45 -4
- package/src/agent/agent.js +1194 -0
- package/src/agent/approvals.js +76 -0
- package/src/agent/context.js +106 -0
- package/src/agent/planner.js +237 -0
- package/src/agent/worklog.js +318 -0
- package/src/cli/app.js +463 -0
- package/src/cli/commands.js +1150 -0
- package/src/cli/input.js +287 -0
- package/src/cli/markdown.js +527 -0
- package/src/cli/prompts.js +255 -0
- package/src/cli/renderer.js +2155 -0
- package/src/cli/settings-editor.js +280 -0
- package/src/cli/settings-tui.js +669 -0
- package/src/cli/tui.js +1737 -0
- package/src/cli/width.js +48 -0
- package/src/config/load.js +132 -0
- package/src/config/scaffold.js +50 -0
- package/src/config/settings-registry.js +495 -0
- package/src/config/settings.js +304 -0
- package/src/config/validate.js +451 -0
- package/src/index.js +449 -0
- package/src/providers/catalog.js +282 -0
- package/src/providers/index.js +84 -0
- package/src/providers/nvidia.js +645 -0
- package/src/providers/registry.js +104 -0
- package/src/services/clipboard.js +38 -0
- package/src/services/diff.js +153 -0
- package/src/services/doctor.js +126 -0
- package/src/services/logger.js +190 -0
- package/src/services/preferences.js +249 -0
- package/src/services/processes.js +345 -0
- package/src/services/provider-settings.js +226 -0
- package/src/services/sessions.js +279 -0
- package/src/services/undo.js +133 -0
- package/src/services/ushell.js +146 -0
- package/src/services/websearch/manager.js +616 -0
- package/src/services/websearch/mcp.js +233 -0
- package/src/services/websearch/normalize.js +284 -0
- package/src/services/websearch/providers.js +178 -0
- package/src/services/websearch/serper.js +263 -0
- package/src/services/workspace.js +589 -0
- package/src/tools/filesystem.js +304 -0
- package/src/tools/git.js +114 -0
- package/src/tools/index.js +48 -0
- package/src/tools/project.js +160 -0
- package/src/tools/registry.js +118 -0
- package/src/tools/search.js +210 -0
- package/src/tools/shell.js +279 -0
- package/src/tools/url.js +542 -0
- package/src/tools/web.js +67 -0
- package/src/utils/errors.js +160 -0
- package/src/utils/network.js +129 -0
- package/src/utils/redaction.js +73 -0
- package/src/utils/validation.js +370 -0
- package/src/utils/version.js +24 -0
package/src/cli/tui.js
ADDED
|
@@ -0,0 +1,1737 @@
|
|
|
1
|
+
import { Chalk } from 'chalk';
|
|
2
|
+
import { PASTE_END, PASTE_START, PasteRegistry } from './input.js';
|
|
3
|
+
import { charWidth, displayWidth } from './width.js';
|
|
4
|
+
import { renderMarkdown } from './markdown.js';
|
|
5
|
+
import { readVersion } from '../utils/version.js';
|
|
6
|
+
import { copyToClipboard } from '../services/clipboard.js';
|
|
7
|
+
import {
|
|
8
|
+
cleanModelTokens,
|
|
9
|
+
cleanReasoning,
|
|
10
|
+
commandOutputLines,
|
|
11
|
+
formatSeconds,
|
|
12
|
+
formatTokens,
|
|
13
|
+
wrapText,
|
|
14
|
+
} from './renderer.js';
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Full-screen, retained-mode UI (`--tui`).
|
|
18
|
+
*
|
|
19
|
+
* The classic Renderer appends lines to the terminal's scrollback and forgets
|
|
20
|
+
* them. Here the conversation is a data structure: every task, reasoning pass,
|
|
21
|
+
* tool output and answer is an item with a stable id, and each frame is drawn
|
|
22
|
+
* from that model. Expanding a block is a flag on the item, so any number of
|
|
23
|
+
* blocks can be open at once and old ones stay openable for the whole session.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
const PREVIEW_LINES = 5;
|
|
27
|
+
/** Kept code for a copy target; OSC 52 payloads are capped at the same size. */
|
|
28
|
+
const MAX_CODE_COPY_CHARS = 100_000;
|
|
29
|
+
const SPINNER = ['⠋', '⠙', '⠹', '⠸', '⠼', '⠴', '⠦', '⠧', '⠇', '⠏'];
|
|
30
|
+
|
|
31
|
+
/**
|
|
32
|
+
* One colour per kind of line, so a glance tells blocks apart:
|
|
33
|
+
* prose white, Markdown accents and tables sky blue, tool names yellow,
|
|
34
|
+
* reasoning green, failures red. This palette is the only place colours live.
|
|
35
|
+
*/
|
|
36
|
+
const PALETTE = {
|
|
37
|
+
white: '#e2e8f0',
|
|
38
|
+
sky: '#38bdf8',
|
|
39
|
+
yellow: '#fbbf24',
|
|
40
|
+
green: '#4ade80',
|
|
41
|
+
red: '#ef4444',
|
|
42
|
+
redSoft: '#fca5a5',
|
|
43
|
+
gray: '#9ca3af',
|
|
44
|
+
violet: '#a78bfa',
|
|
45
|
+
amber: '#f59e0b',
|
|
46
|
+
orange: '#fb923c',
|
|
47
|
+
pink: '#f472b6',
|
|
48
|
+
blue: '#60a5fa',
|
|
49
|
+
};
|
|
50
|
+
|
|
51
|
+
/** Status bar colours: every phase reads differently. */
|
|
52
|
+
const PHASE_COLORS = {
|
|
53
|
+
thinking: PALETTE.violet,
|
|
54
|
+
working: PALETTE.yellow,
|
|
55
|
+
retrying: PALETTE.amber,
|
|
56
|
+
reconnecting: PALETTE.orange,
|
|
57
|
+
};
|
|
58
|
+
|
|
59
|
+
/** `ryuu` in the composer: pink → violet → blue. */
|
|
60
|
+
const PROMPT_GRADIENT = [
|
|
61
|
+
[244, 114, 182],
|
|
62
|
+
[167, 139, 250],
|
|
63
|
+
[96, 165, 250],
|
|
64
|
+
];
|
|
65
|
+
|
|
66
|
+
/** The banner wordmark keeps the classic cyan → violet → pink sweep. */
|
|
67
|
+
const WORDMARK_GRADIENT = [
|
|
68
|
+
[34, 211, 238],
|
|
69
|
+
[167, 139, 250],
|
|
70
|
+
[244, 114, 182],
|
|
71
|
+
];
|
|
72
|
+
|
|
73
|
+
/** Dividers between blocks: pink → violet → sky blue. */
|
|
74
|
+
const RULE_GRADIENT = [
|
|
75
|
+
[244, 114, 182],
|
|
76
|
+
[167, 139, 250],
|
|
77
|
+
[56, 189, 248],
|
|
78
|
+
];
|
|
79
|
+
|
|
80
|
+
const WORDMARK = [
|
|
81
|
+
'██████╗ ██╗ ██╗██╗ ██╗██╗ ██╗',
|
|
82
|
+
'██╔══██╗╚██╗ ██╔╝██║ ██║██║ ██║',
|
|
83
|
+
'██████╔╝ ╚████╔╝ ██║ ██║██║ ██║',
|
|
84
|
+
'██╔══██╗ ╚██╔╝ ██║ ██║██║ ██║',
|
|
85
|
+
'██║ ██║ ██║ ╚██████╔╝╚██████╔╝',
|
|
86
|
+
'╚═╝ ╚═╝ ╚═╝ ╚═════╝ ╚═════╝',
|
|
87
|
+
];
|
|
88
|
+
|
|
89
|
+
const WORDMARK_COMPACT = ['╦═╗ ╦ ╦ ╦ ╦', '╠╦╝ ╚╦╝ ╚╦╝', '╩╚═ ╩ ╩'];
|
|
90
|
+
|
|
91
|
+
/** Cut a styled line to `width` columns without splitting an ANSI sequence. */
|
|
92
|
+
export function clipLine(text, width) {
|
|
93
|
+
const value = String(text ?? '');
|
|
94
|
+
if (displayWidth(value, { ansi: true }) <= width) return value;
|
|
95
|
+
let output = '';
|
|
96
|
+
let used = 0;
|
|
97
|
+
let index = 0;
|
|
98
|
+
while (index < value.length) {
|
|
99
|
+
if (value[index] === '\x1b') {
|
|
100
|
+
const match = /^\x1b\[[0-?]*[ -/]*[@-~]/.exec(value.slice(index));
|
|
101
|
+
if (match) {
|
|
102
|
+
output += match[0];
|
|
103
|
+
index += match[0].length;
|
|
104
|
+
continue;
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
const code = value.codePointAt(index);
|
|
108
|
+
const char = String.fromCodePoint(code);
|
|
109
|
+
const size = charWidth(code);
|
|
110
|
+
if (used + size > Math.max(0, width - 1)) break;
|
|
111
|
+
output += char;
|
|
112
|
+
used += size;
|
|
113
|
+
index += char.length;
|
|
114
|
+
}
|
|
115
|
+
return `${output}\x1b[0m…`;
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
/** Paint `text` with a multi-stop colour gradient, one colour per character. */
|
|
119
|
+
export function gradient(c, text, stops) {
|
|
120
|
+
const chars = [...String(text ?? '')];
|
|
121
|
+
if (chars.length === 0) return '';
|
|
122
|
+
const span = Math.max(1, chars.length - 1);
|
|
123
|
+
return chars
|
|
124
|
+
.map((char, index) => {
|
|
125
|
+
const position = (index / span) * (stops.length - 1);
|
|
126
|
+
const from = Math.min(stops.length - 2, Math.floor(position));
|
|
127
|
+
const local = position - from;
|
|
128
|
+
const rgb = stops[from].map((start, channel) =>
|
|
129
|
+
Math.round(start + (stops[from + 1][channel] - start) * local),
|
|
130
|
+
);
|
|
131
|
+
return c.hex(`#${rgb.map((value) => value.toString(16).padStart(2, '0')).join('')}`)(char);
|
|
132
|
+
})
|
|
133
|
+
.join('');
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
const ANSI_TOKEN = /\x1b\[[0-?]*[ -/]*[@-~]/y;
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Wrap styled text to `width` columns without cutting escape sequences, and
|
|
140
|
+
* re-open the colours that were active at the break so wrapped rows keep them.
|
|
141
|
+
*/
|
|
142
|
+
export function wrapAnsi(text, width) {
|
|
143
|
+
const max = Math.max(8, Math.floor(width));
|
|
144
|
+
const output = [];
|
|
145
|
+
for (const rawLine of String(text ?? '').split('\n')) {
|
|
146
|
+
if (!rawLine) {
|
|
147
|
+
output.push('');
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
const tokens = [];
|
|
151
|
+
for (let index = 0; index < rawLine.length; ) {
|
|
152
|
+
if (rawLine[index] === '\x1b') {
|
|
153
|
+
ANSI_TOKEN.lastIndex = index;
|
|
154
|
+
const match = ANSI_TOKEN.exec(rawLine);
|
|
155
|
+
if (match) {
|
|
156
|
+
tokens.push({ ansi: match[0] });
|
|
157
|
+
index = ANSI_TOKEN.lastIndex;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
const code = rawLine.codePointAt(index);
|
|
162
|
+
const char = String.fromCodePoint(code);
|
|
163
|
+
tokens.push({ char, width: Math.max(1, charWidth(code)) });
|
|
164
|
+
index += char.length;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
let line = [];
|
|
168
|
+
let used = 0;
|
|
169
|
+
let style = '';
|
|
170
|
+
let lineStyle = '';
|
|
171
|
+
|
|
172
|
+
const flush = () => {
|
|
173
|
+
if (line.length === 0) return;
|
|
174
|
+
let end = line.length;
|
|
175
|
+
while (end > 0 && line[end - 1].char === ' ') end -= 1;
|
|
176
|
+
const body = line
|
|
177
|
+
.slice(0, end)
|
|
178
|
+
.map((token) => token.ansi ?? token.char)
|
|
179
|
+
.join('');
|
|
180
|
+
output.push(`${lineStyle}${body}${style ? '\x1b[0m' : ''}`);
|
|
181
|
+
line = [];
|
|
182
|
+
used = 0;
|
|
183
|
+
lineStyle = style;
|
|
184
|
+
};
|
|
185
|
+
|
|
186
|
+
for (const token of tokens) {
|
|
187
|
+
if (token.ansi) {
|
|
188
|
+
line.push(token);
|
|
189
|
+
style = /^\x1b\[(?:0|)m$/.test(token.ansi) ? '' : `${style}${token.ansi}`.slice(-320);
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
if (used + token.width > max) {
|
|
193
|
+
let breakAt = -1;
|
|
194
|
+
for (let index = line.length - 1; index >= 0; index -= 1) {
|
|
195
|
+
if (line[index].char === ' ') {
|
|
196
|
+
breakAt = index;
|
|
197
|
+
break;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
if (breakAt > 0 && token.char !== ' ') {
|
|
201
|
+
const carry = line.slice(breakAt + 1);
|
|
202
|
+
line = line.slice(0, breakAt);
|
|
203
|
+
flush();
|
|
204
|
+
line = carry;
|
|
205
|
+
used = carry.reduce((sum, item) => sum + (item.width || 0), 0);
|
|
206
|
+
} else {
|
|
207
|
+
flush();
|
|
208
|
+
if (token.char === ' ') continue;
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
line.push(token);
|
|
212
|
+
used += token.width;
|
|
213
|
+
}
|
|
214
|
+
flush();
|
|
215
|
+
}
|
|
216
|
+
return output;
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function bannerTop(title, meta, width, c) {
|
|
220
|
+
let label = title;
|
|
221
|
+
let metaText = meta;
|
|
222
|
+
let head = ` ╭─ ${label} `;
|
|
223
|
+
let tail = metaText ? ` ${metaText} ─╮` : '─╮';
|
|
224
|
+
// Narrow terminals drop the version, then shrink the title, instead of
|
|
225
|
+
// spilling past the right edge.
|
|
226
|
+
if (width - displayWidth(head) - displayWidth(tail) < 1) {
|
|
227
|
+
metaText = '';
|
|
228
|
+
tail = '─╮';
|
|
229
|
+
}
|
|
230
|
+
if (width - displayWidth(head) - displayWidth(tail) < 1) {
|
|
231
|
+
label = clipLine(label, Math.max(4, width - 9)).replace(/\x1b\[[0-9;?]*[a-zA-Z]/g, '');
|
|
232
|
+
head = ` ╭─ ${label} `;
|
|
233
|
+
}
|
|
234
|
+
const fill = Math.max(1, width - displayWidth(head) - displayWidth(tail));
|
|
235
|
+
return (
|
|
236
|
+
` ${c.hex(PALETTE.sky)('╭─')} ${c.bold(c.hex(PALETTE.white)(label))} ` +
|
|
237
|
+
`${c.dim('─'.repeat(fill))}` +
|
|
238
|
+
`${metaText ? c.hex(PALETTE.gray)(` ${metaText} `) : ''}${c.hex(PALETTE.sky)('─╮')}`
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
function bannerMid(title, width, c) {
|
|
243
|
+
const head = ` ├─ ${title} `;
|
|
244
|
+
const fill = Math.max(1, width - displayWidth(head) - 2);
|
|
245
|
+
return (
|
|
246
|
+
` ${c.hex(PALETTE.sky)('├─')} ${c.bold(c.hex(PALETTE.white)(title))} ` +
|
|
247
|
+
`${c.dim('─'.repeat(fill))}${c.hex(PALETTE.sky)('─┤')}`
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function bannerBottom(width, c) {
|
|
252
|
+
return ` ${c.hex(PALETTE.sky)(`╰${'─'.repeat(Math.max(1, width - 3))}╯`)}`;
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function bannerRow(content, width, c) {
|
|
256
|
+
const inner = Math.max(4, width - 4);
|
|
257
|
+
const clipped = displayWidth(content, { ansi: true }) > inner ? clipLine(content, inner) : content;
|
|
258
|
+
const pad = Math.max(0, inner - displayWidth(clipped, { ansi: true }));
|
|
259
|
+
return ` ${c.hex(PALETTE.sky)('│')} ${clipped}${' '.repeat(pad)}${c.hex(PALETTE.sky)('│')}`;
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
/** The opening frame: wordmark, tagline and telemetry, all width-aware. */
|
|
263
|
+
export function bannerRows(header = {}, width, { c } = {}) {
|
|
264
|
+
const rows = [];
|
|
265
|
+
const inner = Math.max(6, width - 4);
|
|
266
|
+
const paint = (name, text) => c.hex(PALETTE[name])(String(text));
|
|
267
|
+
rows.push(bannerTop('◢ RYUU // AGENT OS', `v${header.version}`, width, c));
|
|
268
|
+
rows.push(bannerRow('', width, c));
|
|
269
|
+
|
|
270
|
+
const art = inner >= 44 ? WORDMARK : inner >= 16 ? WORDMARK_COMPACT : null;
|
|
271
|
+
if (art) {
|
|
272
|
+
const artWidth = Math.max(...art.map((line) => line.length));
|
|
273
|
+
const pad = Math.max(0, Math.floor((inner - artWidth) / 2));
|
|
274
|
+
for (const line of art) {
|
|
275
|
+
rows.push(bannerRow(`${' '.repeat(pad)}${gradient(c, line, WORDMARK_GRADIENT)}`, width, c));
|
|
276
|
+
}
|
|
277
|
+
} else {
|
|
278
|
+
rows.push(bannerRow(` ${c.bold(gradient(c, 'RYUU', WORDMARK_GRADIENT))}`, width, c));
|
|
279
|
+
}
|
|
280
|
+
rows.push(bannerRow('', width, c));
|
|
281
|
+
|
|
282
|
+
const tagline = 'autonomous coding intelligence · NVIDIA';
|
|
283
|
+
if (inner >= tagline.length) {
|
|
284
|
+
rows.push(
|
|
285
|
+
bannerRow(c.dim(`${' '.repeat(Math.floor((inner - tagline.length) / 2))}${tagline}`), width, c),
|
|
286
|
+
);
|
|
287
|
+
rows.push(bannerRow('', width, c));
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
rows.push(bannerMid('◆ TELEMETRY', width, c));
|
|
291
|
+
const fields = [
|
|
292
|
+
['model', header.model || '—'],
|
|
293
|
+
['workspace', header.workspace || '—'],
|
|
294
|
+
['runtime', header.node ? `Node.js ${header.node} · ${process.platform}` : '—'],
|
|
295
|
+
['access', `${header.approvalPolicy || 'ask'} · shell is not a sandbox`],
|
|
296
|
+
];
|
|
297
|
+
if (header.logFile) fields.push(['log', header.logFile]);
|
|
298
|
+
const valueWidth = Math.max(8, inner - 11);
|
|
299
|
+
for (const [label, value] of fields) {
|
|
300
|
+
const wrapped = wrapText(String(value ?? ''), valueWidth);
|
|
301
|
+
rows.push(bannerRow(`${paint('sky', label.padEnd(10))} ${wrapped[0] || ''}`, width, c));
|
|
302
|
+
for (const extra of wrapped.slice(1)) rows.push(bannerRow(`${' '.repeat(11)}${extra}`, width, c));
|
|
303
|
+
}
|
|
304
|
+
rows.push(bannerBottom(width, c));
|
|
305
|
+
const hint =
|
|
306
|
+
'type a task · /help commands · !cmd shell · click a prompt or code block to copy · PgUp/PgDn scroll';
|
|
307
|
+
for (const line of wrapText(hint, Math.max(10, width - 2))) rows.push(` ${paint('gray', line)}`);
|
|
308
|
+
return rows;
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
/** The conversation model: ordered items, each with a stable id. */
|
|
312
|
+
export class Transcript {
|
|
313
|
+
constructor() {
|
|
314
|
+
this.items = [];
|
|
315
|
+
this.byId = new Map();
|
|
316
|
+
this.nextId = 1;
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
add(entry) {
|
|
320
|
+
const item = { id: this.nextId, expanded: false, version: 0, collapsible: false, ...entry };
|
|
321
|
+
this.nextId += 1;
|
|
322
|
+
this.items.push(item);
|
|
323
|
+
this.byId.set(item.id, item);
|
|
324
|
+
return item;
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
get(id) {
|
|
328
|
+
return this.byId.get(Number(id)) || null;
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
list() {
|
|
332
|
+
return this.items.slice();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
toggle(id) {
|
|
336
|
+
const item = this.get(id);
|
|
337
|
+
if (!item || !item.collapsible) return false;
|
|
338
|
+
item.expanded = !item.expanded;
|
|
339
|
+
item.version += 1;
|
|
340
|
+
return true;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
expand(id, open = true) {
|
|
344
|
+
const item = this.get(id);
|
|
345
|
+
if (!item || !item.collapsible) return false;
|
|
346
|
+
item.expanded = Boolean(open);
|
|
347
|
+
item.version += 1;
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
collapseAll() {
|
|
352
|
+
let changed = false;
|
|
353
|
+
for (const item of this.items) {
|
|
354
|
+
if (!item.expanded) continue;
|
|
355
|
+
item.expanded = false;
|
|
356
|
+
item.version += 1;
|
|
357
|
+
changed = true;
|
|
358
|
+
}
|
|
359
|
+
return changed;
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
message(text, kind = 'info') {
|
|
363
|
+
return this.add({ kind, text: String(text ?? ''), version: 0 });
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
task(task, meta = '') {
|
|
367
|
+
return this.add({ kind: 'task', text: String(task ?? ''), meta, version: 0 });
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
banner(header = {}) {
|
|
371
|
+
return this.add({ kind: 'banner', header: { ...header }, version: 0 });
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
plan(steps = []) {
|
|
375
|
+
return this.add({ kind: 'plan', steps: steps.slice(0, 12), version: 0 });
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
reasoningStart() {
|
|
379
|
+
return this.add({ kind: 'reasoning', text: '', chars: 0, active: true, collapsible: true });
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
reasoningAppend(item, text) {
|
|
383
|
+
if (!item || !text) return;
|
|
384
|
+
item.text += text;
|
|
385
|
+
item.chars = item.text.length;
|
|
386
|
+
item.version += 1;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
reasoningEnd(item, { partial = false } = {}) {
|
|
390
|
+
if (!item) return;
|
|
391
|
+
item.active = false;
|
|
392
|
+
item.partial = Boolean(partial);
|
|
393
|
+
item.version += 1;
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
answerStart() {
|
|
397
|
+
return this.add({ kind: 'answer', text: '', version: 0 });
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
answerAppend(item, text) {
|
|
401
|
+
if (!item || !text) return;
|
|
402
|
+
item.text += text;
|
|
403
|
+
item.version += 1;
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
command({ argv = [], stdout = '', stderr = '', exitCode = null, status = '', processId = null, timedOut = false, truncated = false, expanded = false } = {}) {
|
|
407
|
+
const command = Array.isArray(argv) ? argv.join(' ') : String(argv ?? '');
|
|
408
|
+
const { lines, dropped } = commandOutputLines(stdout, stderr);
|
|
409
|
+
return this.add({
|
|
410
|
+
kind: 'command',
|
|
411
|
+
command,
|
|
412
|
+
output: lines,
|
|
413
|
+
dropped,
|
|
414
|
+
exitCode,
|
|
415
|
+
status,
|
|
416
|
+
processId,
|
|
417
|
+
timedOut: Boolean(timedOut),
|
|
418
|
+
truncated: Boolean(truncated),
|
|
419
|
+
expanded: Boolean(expanded),
|
|
420
|
+
collapsible: true,
|
|
421
|
+
});
|
|
422
|
+
}
|
|
423
|
+
|
|
424
|
+
shell({ command, stdout = '', stderr = '', exitCode = null, durationMs = 0, truncated = false, signal = null, error = null } = {}) {
|
|
425
|
+
const { lines, dropped } = commandOutputLines(stdout, stderr);
|
|
426
|
+
return this.add({
|
|
427
|
+
kind: 'shell',
|
|
428
|
+
command,
|
|
429
|
+
output: lines,
|
|
430
|
+
dropped,
|
|
431
|
+
exitCode,
|
|
432
|
+
durationMs,
|
|
433
|
+
truncated: Boolean(truncated),
|
|
434
|
+
signal,
|
|
435
|
+
error,
|
|
436
|
+
expanded: true,
|
|
437
|
+
collapsible: true,
|
|
438
|
+
});
|
|
439
|
+
}
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
function exitLabel(item) {
|
|
443
|
+
if (Number.isInteger(item.exitCode)) return `exit ${item.exitCode}`;
|
|
444
|
+
if (item.timedOut) return 'timed out';
|
|
445
|
+
if (item.signal) return `signal ${item.signal}`;
|
|
446
|
+
return item.status || 'no exit';
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/** Rows of one item, cached until its content or the width changes. */
|
|
450
|
+
export function itemRows(item, width, { c, color }) {
|
|
451
|
+
const key = `${item.version}|${width}|${item.expanded ? 1 : 0}`;
|
|
452
|
+
if (item._cache?.key === key) return item._cache.rows;
|
|
453
|
+
const rows = buildRows(item, width, { c, color });
|
|
454
|
+
item._cache = { key, rows };
|
|
455
|
+
return rows;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
function buildRows(item, width, { c, color }) {
|
|
459
|
+
const rows = [];
|
|
460
|
+
const id = item.id;
|
|
461
|
+
const paint = (name, text) => c.hex(PALETTE[name] || PALETTE.white)(String(text));
|
|
462
|
+
const chevron = item.collapsible ? (item.expanded ? '▾' : '▸') : '·';
|
|
463
|
+
const inner = Math.max(10, width - 2);
|
|
464
|
+
const dim = (text) => paint('gray', text);
|
|
465
|
+
// Headers wrap too: a long session id or summary must never be cut off.
|
|
466
|
+
const pushHeader = (text) => {
|
|
467
|
+
for (const line of wrapAnsi(text, width - 2)) rows.push(` ${line}`);
|
|
468
|
+
};
|
|
469
|
+
const pushWrapped = (text, prefix, paintName, { dimmed = false } = {}) => {
|
|
470
|
+
for (const line of wrapAnsi(text, inner - prefix.trimEnd().length)) {
|
|
471
|
+
rows.push(`${prefix}${dimmed ? c.dim(paint(paintName, line)) : paint(paintName, line)}`);
|
|
472
|
+
}
|
|
473
|
+
};
|
|
474
|
+
|
|
475
|
+
if (item.kind === 'banner') return bannerRows(item.header, width, { c, color });
|
|
476
|
+
|
|
477
|
+
if (item.kind === 'task') {
|
|
478
|
+
const head =
|
|
479
|
+
`${paint('violet', '▸')} ${c.bold(paint('violet', 'TASK'))}` +
|
|
480
|
+
`${item.meta ? ` ${dim(item.meta)}` : ''}`;
|
|
481
|
+
// The chip is the visible button; the whole task block is the hit target.
|
|
482
|
+
// It shrinks to the glyph alone before the header is allowed to wrap.
|
|
483
|
+
const headWidth = displayWidth(head, { ansi: true });
|
|
484
|
+
const chipLabel = [' ⧉ copy ', ' ⧉ '].find((label) => width - 1 - headWidth - displayWidth(label) >= 1);
|
|
485
|
+
if (chipLabel) {
|
|
486
|
+
const room = width - 1 - headWidth - displayWidth(chipLabel);
|
|
487
|
+
rows.push(` ${head}${' '.repeat(room)}${c.bgHex('#1e293b')(paint('gray', chipLabel))}`);
|
|
488
|
+
} else {
|
|
489
|
+
for (const line of wrapAnsi(head, width - 1)) rows.push(` ${line}`);
|
|
490
|
+
}
|
|
491
|
+
for (const line of wrapText(cleanModelTokens(item.text), inner - 2)) {
|
|
492
|
+
rows.push(` ${c.bold(paint('white', line))}`);
|
|
493
|
+
}
|
|
494
|
+
return rows;
|
|
495
|
+
}
|
|
496
|
+
|
|
497
|
+
if (item.kind === 'plan') {
|
|
498
|
+
pushHeader(`${paint('violet', '◆')} ${c.bold(paint('violet', 'PLAN'))}`);
|
|
499
|
+
for (const step of item.steps || []) pushWrapped(step, ' ', 'sky');
|
|
500
|
+
return rows;
|
|
501
|
+
}
|
|
502
|
+
|
|
503
|
+
if (item.kind === 'reasoning') {
|
|
504
|
+
const wrapped = wrapText(cleanReasoning(item.text || '').trim(), inner - 2);
|
|
505
|
+
const hidden = Math.max(0, wrapped.length - PREVIEW_LINES);
|
|
506
|
+
const shown = item.expanded ? wrapped : wrapped.slice(-PREVIEW_LINES);
|
|
507
|
+
const status = [
|
|
508
|
+
`${formatTokens(item.chars || 0)} chars`,
|
|
509
|
+
item.active ? 'live' : null,
|
|
510
|
+
item.partial ? 'partial' : null,
|
|
511
|
+
item.expanded ? 'click to collapse' : 'click to expand',
|
|
512
|
+
]
|
|
513
|
+
.filter(Boolean)
|
|
514
|
+
.join(' · ');
|
|
515
|
+
pushHeader(
|
|
516
|
+
`${paint('white', chevron)} ${paint('green', '◈')} ${c.bold(paint('white', `reasoning #${id}`))} ` +
|
|
517
|
+
paint('white', `· ${status}`),
|
|
518
|
+
);
|
|
519
|
+
if (shown.length === 0) rows.push(` ${dim('(no reasoning text)')}`);
|
|
520
|
+
for (const line of shown) rows.push(` ${paint('green', line)}`);
|
|
521
|
+
if (!item.expanded && hidden > 0) {
|
|
522
|
+
rows.push(` ${dim(`… ${hidden} more line(s) · click or /expand ${id}`)}`);
|
|
523
|
+
}
|
|
524
|
+
return rows;
|
|
525
|
+
}
|
|
526
|
+
|
|
527
|
+
if (item.kind === 'command' || item.kind === 'shell') {
|
|
528
|
+
const head = item.kind === 'shell' ? 'shell' : 'run_command';
|
|
529
|
+
const failed =
|
|
530
|
+
Number.isInteger(item.exitCode) && item.exitCode !== 0
|
|
531
|
+
? true
|
|
532
|
+
: Boolean(item.error || item.signal || item.timedOut);
|
|
533
|
+
const meta = [exitLabel(item), `${item.output.length} line(s)`];
|
|
534
|
+
if (item.dropped > 0) meta.push(`${item.dropped} dropped`);
|
|
535
|
+
const name = failed ? c.bold(paint('red', head)) : c.bold(paint('yellow', head));
|
|
536
|
+
pushHeader(`${paint('sky', chevron)} ${name} ${dim(`#${id} · ${meta.join(' · ')}`)}`);
|
|
537
|
+
const commandLines = wrapAnsi(item.command, inner - 4);
|
|
538
|
+
rows.push(` ${paint('sky', '$')} ${paint('sky', commandLines[0] || '')}`);
|
|
539
|
+
for (const extra of commandLines.slice(1)) rows.push(` ${paint('sky', extra)}`);
|
|
540
|
+
if (item.output.length === 0) rows.push(` ${dim('(no output)')}`);
|
|
541
|
+
const body = item.expanded ? item.output : item.output.slice(-PREVIEW_LINES);
|
|
542
|
+
for (const row of body) {
|
|
543
|
+
const tone = row.stream === 'stderr' || failed ? 'redSoft' : 'gray';
|
|
544
|
+
for (const line of wrapAnsi(row.text, inner - 4)) {
|
|
545
|
+
rows.push(
|
|
546
|
+
` ${row.stream === 'stderr' ? paint('redSoft', '! ') : ''}${c.dim(paint(tone, line))}`,
|
|
547
|
+
);
|
|
548
|
+
}
|
|
549
|
+
}
|
|
550
|
+
const hidden = item.output.length - body.length;
|
|
551
|
+
if (!item.expanded && hidden > 0) {
|
|
552
|
+
rows.push(` ${dim(`… ${hidden} more line(s) · click or /expand ${id}`)}`);
|
|
553
|
+
}
|
|
554
|
+
if (item.truncated) rows.push(` ${dim('capture truncated')}`);
|
|
555
|
+
if (item.error) for (const line of wrapAnsi(`error: ${item.error}`, inner - 2)) rows.push(` ${paint('redSoft', line)}`);
|
|
556
|
+
return rows;
|
|
557
|
+
}
|
|
558
|
+
|
|
559
|
+
if (item.kind === 'answer') {
|
|
560
|
+
// Every block carries a small label, like reasoning does, so white response
|
|
561
|
+
// text is never mistaken for the tail of a tool or a notice.
|
|
562
|
+
pushHeader(`${paint('violet', '◆')} ${c.bold(paint('white', 'response'))}`);
|
|
563
|
+
// Assistant prose: white text, sky-blue Markdown accents and tables.
|
|
564
|
+
// Every rendered row of a fenced code block is remembered so a click on its
|
|
565
|
+
// `⧉ copy` chip (or anywhere in the block) copies exactly that code.
|
|
566
|
+
const copies = new Map();
|
|
567
|
+
const lines = renderMarkdown(item.text || '', {
|
|
568
|
+
width: Math.max(20, width - 2),
|
|
569
|
+
c,
|
|
570
|
+
color,
|
|
571
|
+
base: (text) => paint('white', text),
|
|
572
|
+
accent: (text) => paint('sky', text),
|
|
573
|
+
onCodeBlock: ({ lang, code, start, end }) => {
|
|
574
|
+
const target = { lang, code: String(code ?? '').slice(0, MAX_CODE_COPY_CHARS) };
|
|
575
|
+
for (let row = start; row <= end; row += 1) copies.set(row, target);
|
|
576
|
+
},
|
|
577
|
+
});
|
|
578
|
+
for (const line of lines) rows.push(line ? ` ${line}` : '');
|
|
579
|
+
if (copies.size > 0) rows.copies = copies;
|
|
580
|
+
return rows;
|
|
581
|
+
}
|
|
582
|
+
|
|
583
|
+
if (item.kind === 'tool') {
|
|
584
|
+
const failed = item.ok === false;
|
|
585
|
+
const mark = failed ? paint('red', '✕') : paint('green', '✓');
|
|
586
|
+
const name = failed ? c.bold(paint('red', item.name)) : paint('yellow', item.name);
|
|
587
|
+
if (failed) {
|
|
588
|
+
pushHeader(`${mark} ${name}${item.durationMs ? ` ${dim(`· ${formatSeconds(item.durationMs)}`)}` : ''}`);
|
|
589
|
+
pushWrapped(item.summary || 'tool failed', ' ', 'redSoft', { dimmed: true });
|
|
590
|
+
} else {
|
|
591
|
+
const detail = [formatSeconds(item.durationMs), item.summary].filter(Boolean).join(' · ');
|
|
592
|
+
pushHeader(`${mark} ${name}${detail ? ` ${dim(`· ${detail}`)}` : ''}`);
|
|
593
|
+
}
|
|
594
|
+
return rows;
|
|
595
|
+
}
|
|
596
|
+
|
|
597
|
+
if (item.kind === 'error') {
|
|
598
|
+
pushHeader(`${paint('red', '✕')} ${c.bold(paint('red', 'runtime fault'))}${item.code ? paint('red', ` · ${item.code}`) : ''}`);
|
|
599
|
+
pushWrapped(item.text || '', ' ', 'redSoft');
|
|
600
|
+
return rows;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
// Info, notices and raw command output arrive pre-styled; wrap, never clip.
|
|
604
|
+
for (const line of wrapAnsi(String(item.text ?? ''), inner)) rows.push(line ? ` ${line}` : '');
|
|
605
|
+
return rows;
|
|
606
|
+
}
|
|
607
|
+
|
|
608
|
+
/**
|
|
609
|
+
* The divider drawn between two blocks of a different kind: a full-width rule in
|
|
610
|
+
* the pink → violet → sky-blue sweep, so it reads as a boundary rather than text.
|
|
611
|
+
*/
|
|
612
|
+
export function blockRule(width, c) {
|
|
613
|
+
return ` ${gradient(c, '─'.repeat(Math.max(4, width - 2)), RULE_GRADIENT)}`;
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
/** The label that opens a run of tool calls, e.g. `⚙ Using tools · 3 calls`. */
|
|
617
|
+
export function toolHeader(count, { c } = {}) {
|
|
618
|
+
const label = c.bold(gradient(c, '⚙ Using tools', RULE_GRADIENT));
|
|
619
|
+
return ` ${label} ${c.dim(`· ${count} call${count === 1 ? '' : 's'}`)}`;
|
|
620
|
+
}
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Flatten every item into screen rows, each tagged with its item id.
|
|
624
|
+
*
|
|
625
|
+
* Blocks are separated by a blank row plus a gradient rule so a change of kind
|
|
626
|
+
* (white answer, green reasoning, yellow tool) is obvious. Consecutive tool calls
|
|
627
|
+
* are one block: they share a single `Using tools` header and no divider.
|
|
628
|
+
*/
|
|
629
|
+
export function layoutTranscript(transcript, width, opts) {
|
|
630
|
+
const rows = [];
|
|
631
|
+
let previous = null;
|
|
632
|
+
for (let index = 0; index < transcript.items.length; index += 1) {
|
|
633
|
+
const item = transcript.items[index];
|
|
634
|
+
const itemLines = itemRows(item, width, opts);
|
|
635
|
+
if (itemLines.length === 0) continue;
|
|
636
|
+
const continuesToolBlock = previous?.kind === 'tool' && item.kind === 'tool';
|
|
637
|
+
if (rows.length > 0 && !continuesToolBlock) {
|
|
638
|
+
rows.push({ text: '', itemId: null });
|
|
639
|
+
rows.push({ text: blockRule(width, opts.c), itemId: null });
|
|
640
|
+
}
|
|
641
|
+
if (item.kind === 'tool' && !continuesToolBlock) {
|
|
642
|
+
let calls = 0;
|
|
643
|
+
for (let look = index; look < transcript.items.length && transcript.items[look].kind === 'tool'; look += 1) {
|
|
644
|
+
calls += 1;
|
|
645
|
+
}
|
|
646
|
+
rows.push({ text: toolHeader(calls, opts), itemId: null });
|
|
647
|
+
}
|
|
648
|
+
for (let row = 0; row < itemLines.length; row += 1) {
|
|
649
|
+
rows.push({ text: itemLines[row], itemId: item.id, copy: itemLines.copies?.get(row) ?? null });
|
|
650
|
+
}
|
|
651
|
+
previous = item;
|
|
652
|
+
}
|
|
653
|
+
return rows;
|
|
654
|
+
}
|
|
655
|
+
|
|
656
|
+
class AltScreen {
|
|
657
|
+
constructor({ output }) {
|
|
658
|
+
this.output = output;
|
|
659
|
+
this.previous = [];
|
|
660
|
+
}
|
|
661
|
+
|
|
662
|
+
enter() {
|
|
663
|
+
this.output.write('\x1b[?1049h\x1b[2J');
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
leave() {
|
|
667
|
+
this.output.write('\x1b[?1049l');
|
|
668
|
+
}
|
|
669
|
+
|
|
670
|
+
clear() {
|
|
671
|
+
this.previous = [];
|
|
672
|
+
this.output.write('\x1b[2J');
|
|
673
|
+
}
|
|
674
|
+
|
|
675
|
+
/** Write only the rows that changed, then park the cursor in the composer. */
|
|
676
|
+
draw(lines, cursor) {
|
|
677
|
+
let out = '\x1b[?25l';
|
|
678
|
+
for (let index = 0; index < lines.length; index += 1) {
|
|
679
|
+
const line = lines[index] ?? '';
|
|
680
|
+
if (this.previous[index] === line) continue;
|
|
681
|
+
out += `\x1b[${index + 1};1H${line}\x1b[K`;
|
|
682
|
+
this.previous[index] = line;
|
|
683
|
+
}
|
|
684
|
+
// A shrinking frame (a one-row composer after a three-row one) must not
|
|
685
|
+
// leave stale rows behind.
|
|
686
|
+
if (this.previous.length > lines.length) {
|
|
687
|
+
for (let index = lines.length; index < this.previous.length; index += 1) {
|
|
688
|
+
out += `\x1b[${index + 1};1H\x1b[K`;
|
|
689
|
+
}
|
|
690
|
+
this.previous.length = lines.length;
|
|
691
|
+
}
|
|
692
|
+
out += `\x1b[${cursor.row + 1};${cursor.col + 1}H\x1b[?25h`;
|
|
693
|
+
this.output.write(out);
|
|
694
|
+
}
|
|
695
|
+
}
|
|
696
|
+
|
|
697
|
+
/**
|
|
698
|
+
* The `--tui` app: renderer + prompt + input loop in one object.
|
|
699
|
+
*
|
|
700
|
+
* It implements the same interface as Renderer + PromptSession, so `createApp`
|
|
701
|
+
* only has to choose which pair to build.
|
|
702
|
+
*/
|
|
703
|
+
export class Tui {
|
|
704
|
+
constructor({ input, output, version = readVersion(), color = null, onSigint = null } = {}) {
|
|
705
|
+
this.input = input;
|
|
706
|
+
this.output = output;
|
|
707
|
+
this.version = version;
|
|
708
|
+
this.onSigint = onSigint;
|
|
709
|
+
this.terminal = Boolean(input?.isTTY && output?.isTTY);
|
|
710
|
+
this.color = color === false ? false : Boolean(this.terminal);
|
|
711
|
+
const depth = typeof output?.getColorDepth === 'function' ? output.getColorDepth() : 1;
|
|
712
|
+
// Truecolor when the terminal says so, so the palette keeps its exact hues.
|
|
713
|
+
this.c = new Chalk({ level: this.color ? (depth >= 24 ? 3 : depth >= 8 ? 2 : 1) : 0 });
|
|
714
|
+
this.transcript = new Transcript();
|
|
715
|
+
this.registry = new PasteRegistry();
|
|
716
|
+
this.screen = new AltScreen({ output: this.output });
|
|
717
|
+
this.header = { version, model: '', workspace: '', provider: '', approvalPolicy: '', node: '', logFile: null };
|
|
718
|
+
|
|
719
|
+
this.questionText = '';
|
|
720
|
+
this.composer = { text: '', cursor: 0 };
|
|
721
|
+
this.history = [];
|
|
722
|
+
this.historyIndex = 0;
|
|
723
|
+
this.historyDraft = '';
|
|
724
|
+
this.modal = null;
|
|
725
|
+
this.askWaiter = null;
|
|
726
|
+
this.lineQueue = [];
|
|
727
|
+
this.status = {
|
|
728
|
+
phase: null,
|
|
729
|
+
startedAt: 0,
|
|
730
|
+
step: null,
|
|
731
|
+
maxSteps: null,
|
|
732
|
+
tool: '',
|
|
733
|
+
promptTokens: 0,
|
|
734
|
+
completionTokens: 0,
|
|
735
|
+
frame: 0,
|
|
736
|
+
output: '',
|
|
737
|
+
};
|
|
738
|
+
this.taskActive = false;
|
|
739
|
+
this.toast = null;
|
|
740
|
+
this.streamedAny = false;
|
|
741
|
+
this.reasoningItem = null;
|
|
742
|
+
this.answerItem = null;
|
|
743
|
+
this.scroll = 0;
|
|
744
|
+
this.maxScroll = 0;
|
|
745
|
+
this.lastTotal = 0;
|
|
746
|
+
this.viewport = 1;
|
|
747
|
+
this.clickMap = [];
|
|
748
|
+
this.copyMap = [];
|
|
749
|
+
this.closed = false;
|
|
750
|
+
this.frameTimer = null;
|
|
751
|
+
this.buffer = '';
|
|
752
|
+
this.paste = null;
|
|
753
|
+
this.escTimer = null;
|
|
754
|
+
|
|
755
|
+
this.onData = (chunk) => this.push(chunk);
|
|
756
|
+
this.onResize = () => {
|
|
757
|
+
this.screen.clear();
|
|
758
|
+
this.forceRender();
|
|
759
|
+
};
|
|
760
|
+
this.ticker = setInterval(() => {
|
|
761
|
+
if (this.closed) return;
|
|
762
|
+
if (this.toast && Date.now() > this.toast.until) {
|
|
763
|
+
this.toast = null;
|
|
764
|
+
this.requestRender();
|
|
765
|
+
return;
|
|
766
|
+
}
|
|
767
|
+
if (!this.status.phase) return;
|
|
768
|
+
this.status.frame += 1;
|
|
769
|
+
this.requestRender();
|
|
770
|
+
}, 120);
|
|
771
|
+
this.ticker.unref?.();
|
|
772
|
+
|
|
773
|
+
if (this.terminal) {
|
|
774
|
+
this.screen.enter();
|
|
775
|
+
this.output.write('\x1b[?2004h\x1b[?1000h\x1b[?1006h');
|
|
776
|
+
try {
|
|
777
|
+
this.input.setRawMode?.(true);
|
|
778
|
+
this.input.resume?.();
|
|
779
|
+
} catch {
|
|
780
|
+
// A non-standard stream may refuse raw mode; rendering still works.
|
|
781
|
+
}
|
|
782
|
+
this.input.on('data', this.onData);
|
|
783
|
+
this.output.on('resize', this.onResize);
|
|
784
|
+
this.forceRender();
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
|
|
788
|
+
// ── prompt / ask ─────────────────────────────────────────────────────────
|
|
789
|
+
|
|
790
|
+
/** Painter: every colour in the TUI comes from PALETTE through here. */
|
|
791
|
+
paint(name, text) {
|
|
792
|
+
return this.c.hex(PALETTE[name] || PALETTE.white)(String(text ?? ''));
|
|
793
|
+
}
|
|
794
|
+
|
|
795
|
+
prompt() {
|
|
796
|
+
return 'ryuu > ';
|
|
797
|
+
}
|
|
798
|
+
|
|
799
|
+
async ask(question) {
|
|
800
|
+
this.questionText = String(question ?? '');
|
|
801
|
+
this.requestRender();
|
|
802
|
+
if (this.lineQueue.length > 0) return this.lineQueue.shift();
|
|
803
|
+
return new Promise((resolve) => {
|
|
804
|
+
this.askWaiter = resolve;
|
|
805
|
+
});
|
|
806
|
+
}
|
|
807
|
+
|
|
808
|
+
async confirm(question) {
|
|
809
|
+
const previous = this.questionText;
|
|
810
|
+
this.questionText = String(question ?? '');
|
|
811
|
+
this.modal = { resolve: null };
|
|
812
|
+
this.requestRender();
|
|
813
|
+
const answer = await new Promise((resolve) => {
|
|
814
|
+
this.modal.resolve = resolve;
|
|
815
|
+
});
|
|
816
|
+
this.modal = null;
|
|
817
|
+
this.questionText = previous;
|
|
818
|
+
this.requestRender();
|
|
819
|
+
const text = String(answer ?? '').trim().toLowerCase();
|
|
820
|
+
if (text === 'y' || text === 'yes') return { answer: text, value: true };
|
|
821
|
+
if (text === 'a') return { answer: text, value: true, all: true };
|
|
822
|
+
return { answer: text || 'n', value: false };
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
// Unused by the TUI (it is the prompt), kept for interface parity.
|
|
826
|
+
metrics() {
|
|
827
|
+
return { active: false, rows: 0, cursorRow: 0 };
|
|
828
|
+
}
|
|
829
|
+
|
|
830
|
+
repaint() {}
|
|
831
|
+
|
|
832
|
+
resetCursor() {}
|
|
833
|
+
|
|
834
|
+
setPromptController() {}
|
|
835
|
+
|
|
836
|
+
reset() {
|
|
837
|
+
if (this.modal) {
|
|
838
|
+
const modal = this.modal;
|
|
839
|
+
this.modal = null;
|
|
840
|
+
modal.resolve('');
|
|
841
|
+
}
|
|
842
|
+
this.composer = { text: '', cursor: 0 };
|
|
843
|
+
this.requestRender();
|
|
844
|
+
}
|
|
845
|
+
|
|
846
|
+
close() {
|
|
847
|
+
if (this.closed) return;
|
|
848
|
+
this.closed = true;
|
|
849
|
+
if (this.frameTimer) clearTimeout(this.frameTimer);
|
|
850
|
+
if (this.ticker) clearInterval(this.ticker);
|
|
851
|
+
if (this.escTimer) clearTimeout(this.escTimer);
|
|
852
|
+
this.screen.clear();
|
|
853
|
+
this.output.write('\x1b[?1006l\x1b[?1000l\x1b[?2004l\x1b[?25h');
|
|
854
|
+
this.screen.leave();
|
|
855
|
+
this.input?.off?.('data', this.onData);
|
|
856
|
+
this.output?.off?.('resize', this.onResize);
|
|
857
|
+
try {
|
|
858
|
+
this.input?.setRawMode?.(false);
|
|
859
|
+
} catch {
|
|
860
|
+
// ignore
|
|
861
|
+
}
|
|
862
|
+
// The stream was resumed in the constructor; pausing it lets the process
|
|
863
|
+
// exit on its own after /exit instead of hanging on a blank screen.
|
|
864
|
+
this.input?.pause?.();
|
|
865
|
+
if (this.modal) {
|
|
866
|
+
const modal = this.modal;
|
|
867
|
+
this.modal = null;
|
|
868
|
+
modal.resolve(null);
|
|
869
|
+
}
|
|
870
|
+
if (this.askWaiter) {
|
|
871
|
+
const waiter = this.askWaiter;
|
|
872
|
+
this.askWaiter = null;
|
|
873
|
+
waiter(null);
|
|
874
|
+
}
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
// ── renderer interface ───────────────────────────────────────────────────
|
|
878
|
+
|
|
879
|
+
banner({ version, model, provider, workspace, node, approvalPolicy, logFile = null } = {}) {
|
|
880
|
+
this.header = {
|
|
881
|
+
version: version || this.version,
|
|
882
|
+
model,
|
|
883
|
+
provider,
|
|
884
|
+
workspace,
|
|
885
|
+
node,
|
|
886
|
+
approvalPolicy,
|
|
887
|
+
logFile,
|
|
888
|
+
};
|
|
889
|
+
this.transcript.banner(this.header);
|
|
890
|
+
this.requestRender();
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
lines(text = '') {
|
|
894
|
+
this.transcript.message(text);
|
|
895
|
+
this.requestRender();
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
info(message) {
|
|
899
|
+
this.transcript.message(` ${this.paint('sky', '◇')} ${this.paint('white', message)}`);
|
|
900
|
+
this.requestRender();
|
|
901
|
+
}
|
|
902
|
+
|
|
903
|
+
success(message) {
|
|
904
|
+
this.transcript.message(` ${this.paint('green', '✓')} ${this.paint('green', message)}`);
|
|
905
|
+
this.requestRender();
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
warn(message) {
|
|
909
|
+
this.transcript.message(` ${this.paint('amber', '▲')} ${this.paint('amber', message)}`);
|
|
910
|
+
this.requestRender();
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
warning({ message } = {}) {
|
|
914
|
+
this.warn(message || '');
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
muted(message) {
|
|
918
|
+
this.transcript.message(` ${this.paint('gray', message)}`);
|
|
919
|
+
this.requestRender();
|
|
920
|
+
}
|
|
921
|
+
|
|
922
|
+
error({ error } = {}) {
|
|
923
|
+
this.transcript.add({
|
|
924
|
+
kind: 'error',
|
|
925
|
+
code: error?.code || 'ERROR',
|
|
926
|
+
text: error?.message || String(error || 'Unknown error'),
|
|
927
|
+
version: 0,
|
|
928
|
+
});
|
|
929
|
+
this.status.phase = null;
|
|
930
|
+
this.requestRender();
|
|
931
|
+
}
|
|
932
|
+
|
|
933
|
+
taskStart({ task, sessionId, maxSteps }) {
|
|
934
|
+
this.taskActive = true;
|
|
935
|
+
this.answerItem = null;
|
|
936
|
+
this.streamedAny = false;
|
|
937
|
+
this.reasoningItem = null;
|
|
938
|
+
this.status.startedAt = Date.now();
|
|
939
|
+
this.status.step = 0;
|
|
940
|
+
this.status.maxSteps = maxSteps || null;
|
|
941
|
+
this.status.promptTokens = 0;
|
|
942
|
+
this.status.completionTokens = 0;
|
|
943
|
+
this.status.tool = '';
|
|
944
|
+
this.transcript.task(task, `session ${sessionId} · ${maxSteps ? `max ${maxSteps} steps` : '∞ autonomous'}`);
|
|
945
|
+
this.requestRender();
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
stepStart({ step, maxSteps }) {
|
|
949
|
+
this.status.step = step;
|
|
950
|
+
this.status.maxSteps = maxSteps || null;
|
|
951
|
+
this.requestRender();
|
|
952
|
+
}
|
|
953
|
+
|
|
954
|
+
plan({ plan } = {}) {
|
|
955
|
+
if (!plan) return;
|
|
956
|
+
this.transcript.plan(String(plan).split('\n').filter(Boolean));
|
|
957
|
+
this.requestRender();
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
thinkingStart({ step = null, maxSteps = null } = {}) {
|
|
961
|
+
if (step !== null) this.status.step = step;
|
|
962
|
+
if (maxSteps !== null) this.status.maxSteps = maxSteps;
|
|
963
|
+
this.setPhase('thinking');
|
|
964
|
+
}
|
|
965
|
+
|
|
966
|
+
thinkingEnd({ durationMs = 0, firstTokenMs = null, usage = null } = {}) {
|
|
967
|
+
if (usage) {
|
|
968
|
+
this.status.promptTokens = usage.prompt ?? this.status.promptTokens;
|
|
969
|
+
this.status.completionTokens = usage.completion ?? this.status.completionTokens;
|
|
970
|
+
}
|
|
971
|
+
const bits = [`${formatSeconds(durationMs)}`];
|
|
972
|
+
if (firstTokenMs !== null && firstTokenMs !== undefined) bits.push(`first token ${formatSeconds(firstTokenMs)}`);
|
|
973
|
+
if (usage) bits.push(`${formatTokens(usage.prompt)}↑ ${formatTokens(usage.completion)}↓`);
|
|
974
|
+
this.transcript.message(` ${this.paint('sky', '◇')} ${this.paint('gray', `model · ${bits.join(' · ')}`)}`);
|
|
975
|
+
this.requestRender();
|
|
976
|
+
}
|
|
977
|
+
|
|
978
|
+
workingStart({ tool = '', step = null, maxSteps = null } = {}) {
|
|
979
|
+
if (step !== null) this.status.step = step;
|
|
980
|
+
if (maxSteps !== null) this.status.maxSteps = maxSteps;
|
|
981
|
+
this.status.tool = tool || '';
|
|
982
|
+
this.setPhase('working');
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
workingEnd() {}
|
|
986
|
+
|
|
987
|
+
progress({ step = null, maxSteps = null, promptTokens = null, completionTokens = null } = {}) {
|
|
988
|
+
if (step !== null) this.status.step = step;
|
|
989
|
+
if (maxSteps !== null) this.status.maxSteps = maxSteps;
|
|
990
|
+
if (Number.isFinite(promptTokens)) this.status.promptTokens = promptTokens;
|
|
991
|
+
if (Number.isFinite(completionTokens)) this.status.completionTokens = completionTokens;
|
|
992
|
+
this.requestRender();
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
processOutput({ text = '' } = {}) {
|
|
996
|
+
const line = String(text).trim().split('\n').pop();
|
|
997
|
+
if (line) this.status.output = line.slice(-120);
|
|
998
|
+
this.requestRender();
|
|
999
|
+
}
|
|
1000
|
+
|
|
1001
|
+
heartbeat({ phase = 'retry', attempt = 0, maxAttempts = 0, delayMs = 0, code = '', message = '' } = {}) {
|
|
1002
|
+
// Heartbeat notices are bold sky blue: transport news, not a fault.
|
|
1003
|
+
const beat = (text) => this.c.bold(this.paint('sky', text));
|
|
1004
|
+
if (phase === 'restored') {
|
|
1005
|
+
this.transcript.message(` ${beat('♥ link restored — the run continues')}`);
|
|
1006
|
+
this.setPhase('working');
|
|
1007
|
+
return;
|
|
1008
|
+
}
|
|
1009
|
+
if (phase === 'degraded') {
|
|
1010
|
+
this.transcript.message(` ${beat(`♥ ${code || 'degraded'}${message ? ` · ${message}` : ''}`)}`);
|
|
1011
|
+
this.setPhase('working');
|
|
1012
|
+
return;
|
|
1013
|
+
}
|
|
1014
|
+
const cap = Number(maxAttempts) > 0 ? `/${maxAttempts}` : ' (unlimited)';
|
|
1015
|
+
const what = code === 'EMPTY_RESPONSE' ? 'empty response' : 'reconnecting';
|
|
1016
|
+
this.transcript.message(
|
|
1017
|
+
` ${beat(`♥ heartbeat · ${what} · attempt ${attempt}${cap} in ${formatSeconds(delayMs)}`)}`,
|
|
1018
|
+
);
|
|
1019
|
+
this.setPhase(code === 'EMPTY_RESPONSE' ? 'retrying' : 'reconnecting');
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/** True when `item` is still the newest block (nothing was added after it). */
|
|
1023
|
+
isTail(item) {
|
|
1024
|
+
return Boolean(item) && this.transcript.items[this.transcript.items.length - 1] === item;
|
|
1025
|
+
}
|
|
1026
|
+
|
|
1027
|
+
reasoningDelta({ text } = {}) {
|
|
1028
|
+
if (!text) return;
|
|
1029
|
+
if (this.reasoningItem && !this.isTail(this.reasoningItem)) this.reasoningItem = null;
|
|
1030
|
+
if (!this.reasoningItem) this.reasoningItem = this.transcript.reasoningStart();
|
|
1031
|
+
this.transcript.reasoningAppend(this.reasoningItem, text);
|
|
1032
|
+
this.requestRender();
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
reasoningEnd({ partial = false } = {}) {
|
|
1036
|
+
if (!this.reasoningItem) return;
|
|
1037
|
+
this.transcript.reasoningEnd(this.reasoningItem, { partial });
|
|
1038
|
+
this.reasoningItem = null;
|
|
1039
|
+
this.requestRender();
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
contentDelta({ text } = {}) {
|
|
1043
|
+
if (!text) return;
|
|
1044
|
+
// Another block (tool, plan, heartbeat note…) may have been added after the
|
|
1045
|
+
// open answer, which happens on every step that streams text and then calls
|
|
1046
|
+
// tools. Appending to the old block made the answer grow *above* those
|
|
1047
|
+
// blocks and look buried; a fresh block keeps the transcript chronological.
|
|
1048
|
+
if (this.answerItem && !this.isTail(this.answerItem)) this.answerItem = null;
|
|
1049
|
+
if (!this.answerItem) this.answerItem = this.transcript.answerStart();
|
|
1050
|
+
this.streamedAny = true;
|
|
1051
|
+
this.transcript.answerAppend(this.answerItem, text);
|
|
1052
|
+
this.requestRender();
|
|
1053
|
+
}
|
|
1054
|
+
|
|
1055
|
+
answerEnd() {
|
|
1056
|
+
this.answerItem = null;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
/** True once any text streamed during this task (survives a closed block). */
|
|
1060
|
+
streamedResponse() {
|
|
1061
|
+
return this.streamedAny;
|
|
1062
|
+
}
|
|
1063
|
+
|
|
1064
|
+
note({ text } = {}) {
|
|
1065
|
+
if (!text) return;
|
|
1066
|
+
this.transcript.message(` ${this.paint('gray', text)}`);
|
|
1067
|
+
this.requestRender();
|
|
1068
|
+
}
|
|
1069
|
+
|
|
1070
|
+
toolStart({ name = '', detail = '' } = {}) {
|
|
1071
|
+
this.status.tool = name;
|
|
1072
|
+
this.requestRender();
|
|
1073
|
+
}
|
|
1074
|
+
|
|
1075
|
+
toolEnd({ name = '', ok = true, durationMs = 0, summary = '' } = {}) {
|
|
1076
|
+
this.status.tool = '';
|
|
1077
|
+
this.transcript.add({ kind: 'tool', name, ok, durationMs, summary, version: 0 });
|
|
1078
|
+
this.requestRender();
|
|
1079
|
+
}
|
|
1080
|
+
|
|
1081
|
+
toolDenied({ name = '', reason = '' } = {}) {
|
|
1082
|
+
this.transcript.message(
|
|
1083
|
+
` ${this.paint('amber', '⛔')} ${this.paint('amber', `${name} denied${reason ? ` · ${reason}` : ''}`)}`,
|
|
1084
|
+
);
|
|
1085
|
+
this.requestRender();
|
|
1086
|
+
}
|
|
1087
|
+
|
|
1088
|
+
fileDiff({ kind = 'edit', path = '', diff = '', added = 0, removed = 0 } = {}) {
|
|
1089
|
+
const lines = [` ${this.paint('sky', '▲')} ${this.paint('gray', `${kind} ${path} · +${added} −${removed}`)}`];
|
|
1090
|
+
for (const line of String(diff).split('\n').slice(0, 30)) {
|
|
1091
|
+
const tone = line.startsWith('+') ? this.paint('green', line) : line.startsWith('-') ? this.paint('redSoft', line) : this.paint('gray', line);
|
|
1092
|
+
lines.push(` ${tone}`);
|
|
1093
|
+
}
|
|
1094
|
+
this.transcript.message(lines.join('\n'));
|
|
1095
|
+
this.requestRender();
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
approvalRequest({ tool = '', risk = '', preview = '', reasons = [] } = {}) {
|
|
1099
|
+
const lines = [
|
|
1100
|
+
` ${this.paint('amber', '▲')} ${this.c.bold(this.paint('amber', `approval needed · ${tool}`))} ${this.paint('gray', `(${risk})`)}`,
|
|
1101
|
+
];
|
|
1102
|
+
for (const line of String(preview || '').split('\n').slice(0, 12)) lines.push(` ${this.paint('gray', line)}`);
|
|
1103
|
+
if (reasons?.length) lines.push(` ${this.paint('gray', reasons.join(' · '))}`);
|
|
1104
|
+
this.transcript.message(lines.join('\n'));
|
|
1105
|
+
this.requestRender();
|
|
1106
|
+
}
|
|
1107
|
+
|
|
1108
|
+
commandOutput(payload = {}) {
|
|
1109
|
+
this.transcript.command(payload);
|
|
1110
|
+
this.requestRender();
|
|
1111
|
+
}
|
|
1112
|
+
|
|
1113
|
+
shellResult({ command, cwd = '', stdout = '', stderr = '', exitCode = null, durationMs = 0, truncated = false, signal = null, error = null } = {}) {
|
|
1114
|
+
this.transcript.shell({ command, cwd, stdout, stderr, exitCode, durationMs, truncated, signal, error });
|
|
1115
|
+
this.requestRender();
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
webSearchStatus() {}
|
|
1119
|
+
|
|
1120
|
+
stopActivity() {
|
|
1121
|
+
this.status.phase = null;
|
|
1122
|
+
this.status.tool = '';
|
|
1123
|
+
this.requestRender();
|
|
1124
|
+
}
|
|
1125
|
+
|
|
1126
|
+
final(summary = {}) {
|
|
1127
|
+
this.taskActive = false;
|
|
1128
|
+
this.status.phase = null;
|
|
1129
|
+
this.status.tool = '';
|
|
1130
|
+
if (summary.finalText && !this.streamedResponse()) {
|
|
1131
|
+
this.transcript.add({ kind: 'answer', text: summary.finalText, version: 0 });
|
|
1132
|
+
}
|
|
1133
|
+
const tokens = summary.tokenUsage || {};
|
|
1134
|
+
const done = String(summary.status || 'completed') === 'completed';
|
|
1135
|
+
const tone = done ? 'green' : 'red';
|
|
1136
|
+
const lines = [
|
|
1137
|
+
` ${this.paint(tone, done ? '◆' : '✕')} ${this.c.bold(this.paint(tone, String(summary.status || 'completed')))} ` +
|
|
1138
|
+
this.paint(
|
|
1139
|
+
'gray',
|
|
1140
|
+
`· ${summary.steps ?? 0} step(s) · ${formatSeconds(summary.durationMs)} · ` +
|
|
1141
|
+
`${formatTokens(tokens.prompt)}↑ ${formatTokens(tokens.completion)}↓`,
|
|
1142
|
+
),
|
|
1143
|
+
` ${this.paint('gray', `changed: ${summary.changedFiles?.length ? summary.changedFiles.slice(0, 8).join(', ') : 'none'}`)}`,
|
|
1144
|
+
];
|
|
1145
|
+
if (summary.error) lines.push(` ${this.paint('redSoft', `${summary.error.code}: ${summary.error.message}`)}`);
|
|
1146
|
+
this.transcript.message(lines.join('\n'));
|
|
1147
|
+
this.requestRender();
|
|
1148
|
+
}
|
|
1149
|
+
|
|
1150
|
+
goodbye() {
|
|
1151
|
+
this.transcript.message(` ${this.paint('gray', 'session closed · see you in the next run')}`);
|
|
1152
|
+
this.requestRender();
|
|
1153
|
+
}
|
|
1154
|
+
|
|
1155
|
+
// ── clipboard ────────────────────────────────────────────────────────────
|
|
1156
|
+
|
|
1157
|
+
/** Short-lived confirmation shown in the status bar. */
|
|
1158
|
+
notify(text) {
|
|
1159
|
+
this.toast = { text, until: Date.now() + 2600 };
|
|
1160
|
+
this.requestRender();
|
|
1161
|
+
}
|
|
1162
|
+
|
|
1163
|
+
/** Copy one prompt block to the terminal clipboard (OSC 52). */
|
|
1164
|
+
copyItem(item) {
|
|
1165
|
+
if (!item) return { ok: false, reason: 'no-task', chars: 0 };
|
|
1166
|
+
const result = copyToClipboard(item.text, { output: this.output });
|
|
1167
|
+
this.notify(
|
|
1168
|
+
result.ok
|
|
1169
|
+
? `⧉ copied prompt #${item.id} · ${result.chars} chars`
|
|
1170
|
+
: 'clipboard blocked by this terminal (OSC 52)',
|
|
1171
|
+
);
|
|
1172
|
+
return result;
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
/** Copy one rendered fenced code block to the terminal clipboard. */
|
|
1176
|
+
copyCode({ lang = '', code = '' } = {}) {
|
|
1177
|
+
const result = copyToClipboard(String(code ?? ''), { output: this.output });
|
|
1178
|
+
const label = lang ? `${lang} code block` : 'code block';
|
|
1179
|
+
this.notify(
|
|
1180
|
+
result.ok
|
|
1181
|
+
? `⧉ copied ${label} · ${result.chars} chars`
|
|
1182
|
+
: 'clipboard blocked by this terminal (OSC 52)',
|
|
1183
|
+
);
|
|
1184
|
+
return result;
|
|
1185
|
+
}
|
|
1186
|
+
|
|
1187
|
+
/** Copy a sent prompt; `which` counts back from the newest (0 = latest). */
|
|
1188
|
+
copyTask(which = 0) {
|
|
1189
|
+
const tasks = this.transcript.items.filter((item) => item.kind === 'task');
|
|
1190
|
+
if (tasks.length === 0) {
|
|
1191
|
+
this.notify('no prompt to copy yet');
|
|
1192
|
+
return { ok: false, reason: 'no-task', chars: 0 };
|
|
1193
|
+
}
|
|
1194
|
+
const depth = Math.max(0, Math.min(Math.trunc(Number(which) || 0), tasks.length - 1));
|
|
1195
|
+
return this.copyItem(tasks[tasks.length - 1 - depth]);
|
|
1196
|
+
}
|
|
1197
|
+
|
|
1198
|
+
// ── expandable blocks (commands use these) ───────────────────────────────
|
|
1199
|
+
|
|
1200
|
+
hasBlock(id) {
|
|
1201
|
+
return Boolean(this.transcript.get(id));
|
|
1202
|
+
}
|
|
1203
|
+
|
|
1204
|
+
listBlocks() {
|
|
1205
|
+
return this.transcript.list();
|
|
1206
|
+
}
|
|
1207
|
+
|
|
1208
|
+
isBlockViewOpen() {
|
|
1209
|
+
return this.transcript.items.some((item) => item.expanded);
|
|
1210
|
+
}
|
|
1211
|
+
|
|
1212
|
+
blockListText() {
|
|
1213
|
+
const items = this.transcript.items.filter((item) => item.collapsible);
|
|
1214
|
+
if (items.length === 0) return ' No blocks yet. Reasoning and shell/run_command output are listed here.';
|
|
1215
|
+
return [
|
|
1216
|
+
' Expandable blocks (click one, or /expand <id>):',
|
|
1217
|
+
...items.map((item) =>
|
|
1218
|
+
item.kind === 'reasoning'
|
|
1219
|
+
? ` #${String(item.id).padEnd(3)} reasoning ${item.chars || item.text.length} chars`
|
|
1220
|
+
: ` #${String(item.id).padEnd(3)} ${item.kind.padEnd(12)} ${item.command} · ${exitLabel(item)} · ${item.output.length} line(s)`,
|
|
1221
|
+
),
|
|
1222
|
+
].join('\n');
|
|
1223
|
+
}
|
|
1224
|
+
|
|
1225
|
+
openBlockView(id) {
|
|
1226
|
+
const ok = this.transcript.expand(id, true);
|
|
1227
|
+
if (ok) this.requestRender();
|
|
1228
|
+
return ok;
|
|
1229
|
+
}
|
|
1230
|
+
|
|
1231
|
+
toggleBlock(id) {
|
|
1232
|
+
const exists = Boolean(this.transcript.get(id));
|
|
1233
|
+
const ok = this.transcript.toggle(id);
|
|
1234
|
+
if (ok) this.requestRender();
|
|
1235
|
+
return { exists, open: Boolean(this.transcript.get(id)?.expanded) };
|
|
1236
|
+
}
|
|
1237
|
+
|
|
1238
|
+
closeBlockView() {
|
|
1239
|
+
const changed = this.transcript.collapseAll();
|
|
1240
|
+
if (changed) this.requestRender();
|
|
1241
|
+
return changed;
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
scrollBlockView() {
|
|
1245
|
+
return false;
|
|
1246
|
+
}
|
|
1247
|
+
|
|
1248
|
+
handleBlockViewKey() {
|
|
1249
|
+
return false;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
handleMouse() {
|
|
1253
|
+
return false;
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ── input ────────────────────────────────────────────────────────────────
|
|
1257
|
+
|
|
1258
|
+
setPhase(phase) {
|
|
1259
|
+
if (!this.status.phase) this.status.startedAt = Date.now();
|
|
1260
|
+
this.status.phase = phase;
|
|
1261
|
+
this.requestRender();
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
requestRender() {
|
|
1265
|
+
if (!this.terminal || this.closed || this.frameTimer) return;
|
|
1266
|
+
this.frameTimer = setTimeout(() => {
|
|
1267
|
+
this.frameTimer = null;
|
|
1268
|
+
this.render();
|
|
1269
|
+
}, 40);
|
|
1270
|
+
this.frameTimer.unref?.();
|
|
1271
|
+
}
|
|
1272
|
+
|
|
1273
|
+
forceRender() {
|
|
1274
|
+
if (this.frameTimer) clearTimeout(this.frameTimer);
|
|
1275
|
+
this.frameTimer = null;
|
|
1276
|
+
this.render();
|
|
1277
|
+
}
|
|
1278
|
+
|
|
1279
|
+
render() {
|
|
1280
|
+
if (!this.terminal || this.closed) return;
|
|
1281
|
+
const width = Math.max(24, Number(this.output.columns) || 80);
|
|
1282
|
+
const height = Math.max(8, Number(this.output.rows) || 24);
|
|
1283
|
+
const headerRows = 1;
|
|
1284
|
+
const statusRows = 1;
|
|
1285
|
+
const composer = this.composerLines(width);
|
|
1286
|
+
const composerRows = composer.rows.length;
|
|
1287
|
+
const viewport = Math.max(1, height - headerRows - composerRows - statusRows);
|
|
1288
|
+
const rows = layoutTranscript(this.transcript, Math.max(10, width - 1), { c: this.c, color: this.color });
|
|
1289
|
+
const total = rows.length;
|
|
1290
|
+
if (this.scroll > 0 && total !== this.lastTotal) this.scroll += total - this.lastTotal;
|
|
1291
|
+
const maxScroll = Math.max(0, total - viewport);
|
|
1292
|
+
this.scroll = Math.max(0, Math.min(this.scroll, maxScroll));
|
|
1293
|
+
this.maxScroll = maxScroll;
|
|
1294
|
+
this.lastTotal = total;
|
|
1295
|
+
this.viewport = viewport;
|
|
1296
|
+
|
|
1297
|
+
const end = total - this.scroll;
|
|
1298
|
+
const start = Math.max(0, end - viewport);
|
|
1299
|
+
const visible = rows.slice(start, end);
|
|
1300
|
+
this.clickMap = visible.map((row) => row.itemId);
|
|
1301
|
+
this.copyMap = visible.map((row) => row.copy ?? null);
|
|
1302
|
+
|
|
1303
|
+
const frame = [clipLine(this.headerLine(width), width)];
|
|
1304
|
+
for (let index = 0; index < viewport; index += 1) {
|
|
1305
|
+
const row = visible[index];
|
|
1306
|
+
frame.push(row ? clipLine(row.text, width) : '');
|
|
1307
|
+
}
|
|
1308
|
+
for (const line of composer.rows) frame.push(clipLine(line, width));
|
|
1309
|
+
frame.push(clipLine(this.statusLine(width), width));
|
|
1310
|
+
|
|
1311
|
+
this.screen.draw(frame, { row: headerRows + viewport + composer.row, col: composer.col });
|
|
1312
|
+
}
|
|
1313
|
+
|
|
1314
|
+
headerLine(width) {
|
|
1315
|
+
const left =
|
|
1316
|
+
` ${gradient(this.c, '◈', PROMPT_GRADIENT)} ` +
|
|
1317
|
+
`${this.c.bold(gradient(this.c, 'ryuu', PROMPT_GRADIENT))} ${this.paint('gray', `v${this.header.version}`)}`;
|
|
1318
|
+
const right = this.paint(
|
|
1319
|
+
'gray',
|
|
1320
|
+
[this.header.model, this.header.workspace].filter(Boolean).join(' · '),
|
|
1321
|
+
);
|
|
1322
|
+
const room = width - displayWidth(left, { ansi: true }) - displayWidth(right, { ansi: true }) - 2;
|
|
1323
|
+
// A narrow terminal keeps the identity and drops the meta instead of clipping.
|
|
1324
|
+
if (room < 12) return `${left} `;
|
|
1325
|
+
return `${left}${' '.repeat(room)}${right} `;
|
|
1326
|
+
}
|
|
1327
|
+
|
|
1328
|
+
/**
|
|
1329
|
+
* The input line: a pink → blue `ryuu` prompt, gray text, and soft wrapping
|
|
1330
|
+
* so a long task stays readable across rows instead of scrolling off.
|
|
1331
|
+
*/
|
|
1332
|
+
composerLines(width) {
|
|
1333
|
+
const rows = [];
|
|
1334
|
+
const question =
|
|
1335
|
+
this.questionText && this.questionText !== this.prompt() ? String(this.questionText).trim() : '';
|
|
1336
|
+
if (question) {
|
|
1337
|
+
for (const line of wrapAnsi(this.paint('white', question), Math.max(10, width - 2))) {
|
|
1338
|
+
rows.push(` ${line}`);
|
|
1339
|
+
}
|
|
1340
|
+
}
|
|
1341
|
+
|
|
1342
|
+
const promptPlain = 'ryuu > ';
|
|
1343
|
+
const promptWidth = displayWidth(promptPlain);
|
|
1344
|
+
const prompt = `${gradient(this.c, 'ryuu', PROMPT_GRADIENT)} ${this.paint('gray', '>')} `;
|
|
1345
|
+
const avail = Math.max(8, width - promptWidth - 1);
|
|
1346
|
+
const chars = Array.from(this.composer.text);
|
|
1347
|
+
const cursor = Math.max(0, Math.min(this.composer.cursor, chars.length));
|
|
1348
|
+
|
|
1349
|
+
// Greedy word wrap: break at the last space when the row has one, so a
|
|
1350
|
+
// task reads as words instead of being cut mid-token.
|
|
1351
|
+
const spans = [];
|
|
1352
|
+
let start = 0;
|
|
1353
|
+
let used = 0;
|
|
1354
|
+
let space = -1;
|
|
1355
|
+
for (let index = 0; index < chars.length; index += 1) {
|
|
1356
|
+
const size = Math.max(1, charWidth(chars[index].codePointAt(0)));
|
|
1357
|
+
if (used + size > avail) {
|
|
1358
|
+
if (space > start) {
|
|
1359
|
+
spans.push({ start, end: space });
|
|
1360
|
+
start = space + 1;
|
|
1361
|
+
used = 0;
|
|
1362
|
+
space = -1;
|
|
1363
|
+
for (let inner = start; inner < index; inner += 1) {
|
|
1364
|
+
if (chars[inner] === ' ') space = inner;
|
|
1365
|
+
used += Math.max(1, charWidth(chars[inner].codePointAt(0)));
|
|
1366
|
+
}
|
|
1367
|
+
} else {
|
|
1368
|
+
spans.push({ start, end: index });
|
|
1369
|
+
start = index;
|
|
1370
|
+
used = 0;
|
|
1371
|
+
space = -1;
|
|
1372
|
+
}
|
|
1373
|
+
}
|
|
1374
|
+
if (chars[index] === ' ') space = index;
|
|
1375
|
+
used += size;
|
|
1376
|
+
}
|
|
1377
|
+
spans.push({ start, end: chars.length });
|
|
1378
|
+
|
|
1379
|
+
const cellAt = (target) => {
|
|
1380
|
+
for (let index = 0; index < spans.length; index += 1) {
|
|
1381
|
+
const span = spans[index];
|
|
1382
|
+
if (target <= span.end) {
|
|
1383
|
+
let col = 0;
|
|
1384
|
+
for (let at = span.start; at < Math.min(target, span.end); at += 1) {
|
|
1385
|
+
col += Math.max(1, charWidth(chars[at].codePointAt(0)));
|
|
1386
|
+
}
|
|
1387
|
+
return { row: index, col };
|
|
1388
|
+
}
|
|
1389
|
+
}
|
|
1390
|
+
const last = spans[spans.length - 1];
|
|
1391
|
+
let col = 0;
|
|
1392
|
+
for (let at = last.start; at < chars.length; at += 1) {
|
|
1393
|
+
col += Math.max(1, charWidth(chars[at].codePointAt(0)));
|
|
1394
|
+
}
|
|
1395
|
+
return { row: spans.length - 1, col };
|
|
1396
|
+
};
|
|
1397
|
+
|
|
1398
|
+
const empty = chars.length === 0;
|
|
1399
|
+
const lines = spans.map((span) =>
|
|
1400
|
+
empty ? 'describe a task · /help · !cmd' : chars.slice(span.start, span.end).join(''),
|
|
1401
|
+
);
|
|
1402
|
+
lines.forEach((line, index) => {
|
|
1403
|
+
const prefix = index === 0 ? prompt : ' '.repeat(promptWidth);
|
|
1404
|
+
const body = this.paint('gray', line);
|
|
1405
|
+
rows.push(`${prefix}${empty ? this.c.dim(body) : body}`);
|
|
1406
|
+
});
|
|
1407
|
+
|
|
1408
|
+
const cell = empty ? { row: 0, col: 0 } : cellAt(cursor);
|
|
1409
|
+
return {
|
|
1410
|
+
rows,
|
|
1411
|
+
row: rows.length - lines.length + cell.row,
|
|
1412
|
+
col: Math.min(width - 1, promptWidth + cell.col),
|
|
1413
|
+
};
|
|
1414
|
+
}
|
|
1415
|
+
|
|
1416
|
+
statusLine(width) {
|
|
1417
|
+
const sep = this.paint('gray', ' · ');
|
|
1418
|
+
const parts = [];
|
|
1419
|
+
if (this.modal) {
|
|
1420
|
+
parts.push(this.paint('pink', 'approval · y/n/a'));
|
|
1421
|
+
} else if (this.status.phase) {
|
|
1422
|
+
const glyph = SPINNER[this.status.frame % SPINNER.length];
|
|
1423
|
+
const tone = PHASE_COLORS[this.status.phase] || PALETTE.sky;
|
|
1424
|
+
parts.push(
|
|
1425
|
+
`${this.c.hex(tone)(glyph)} ${this.c.hex(tone)(this.status.phase)} ` +
|
|
1426
|
+
`${this.paint('gray', formatSeconds(Date.now() - this.status.startedAt))}`,
|
|
1427
|
+
);
|
|
1428
|
+
} else {
|
|
1429
|
+
parts.push(this.paint('sky', '● ready'));
|
|
1430
|
+
}
|
|
1431
|
+
if (this.status.step) {
|
|
1432
|
+
parts.push(
|
|
1433
|
+
this.paint('blue', `step ${this.status.step}${this.status.maxSteps ? `/${this.status.maxSteps}` : ''}`),
|
|
1434
|
+
);
|
|
1435
|
+
}
|
|
1436
|
+
if (this.status.tool) parts.push(this.paint('yellow', this.status.tool));
|
|
1437
|
+
if (this.toast && Date.now() <= this.toast.until) parts.push(this.paint('green', this.toast.text));
|
|
1438
|
+
if (this.status.promptTokens || this.status.completionTokens) {
|
|
1439
|
+
parts.push(
|
|
1440
|
+
this.paint(
|
|
1441
|
+
'green',
|
|
1442
|
+
`${formatTokens(this.status.promptTokens)}↑ ${formatTokens(this.status.completionTokens)}↓`,
|
|
1443
|
+
),
|
|
1444
|
+
);
|
|
1445
|
+
}
|
|
1446
|
+
const left = ` ${parts.join(sep)}`;
|
|
1447
|
+
const rightBits = [];
|
|
1448
|
+
if (this.scroll > 0) rightBits.push(this.paint('white', `scrolled ${this.scroll} · PgDn`));
|
|
1449
|
+
rightBits.push(this.paint('gray', 'Enter send · !cmd shell · PgUp scroll'));
|
|
1450
|
+
const right = `${rightBits.join(sep)} `;
|
|
1451
|
+
const room = width - displayWidth(left, { ansi: true }) - displayWidth(right, { ansi: true });
|
|
1452
|
+
if (room < 2) return clipLine(`${left} `, width);
|
|
1453
|
+
return `${left}${' '.repeat(room)}${right}`;
|
|
1454
|
+
}
|
|
1455
|
+
|
|
1456
|
+
push(chunk) {
|
|
1457
|
+
this.buffer += typeof chunk === 'string' ? chunk : chunk.toString('utf8');
|
|
1458
|
+
this.drain();
|
|
1459
|
+
}
|
|
1460
|
+
|
|
1461
|
+
drain() {
|
|
1462
|
+
for (;;) {
|
|
1463
|
+
if (!this.buffer) return;
|
|
1464
|
+
if (this.paste !== null) {
|
|
1465
|
+
const end = this.buffer.indexOf(PASTE_END);
|
|
1466
|
+
if (end === -1) {
|
|
1467
|
+
this.paste += this.buffer;
|
|
1468
|
+
this.buffer = '';
|
|
1469
|
+
return;
|
|
1470
|
+
}
|
|
1471
|
+
this.paste += this.buffer.slice(0, end);
|
|
1472
|
+
this.buffer = this.buffer.slice(end + PASTE_END.length);
|
|
1473
|
+
const text = this.paste;
|
|
1474
|
+
this.paste = null;
|
|
1475
|
+
this.insertPaste(text);
|
|
1476
|
+
continue;
|
|
1477
|
+
}
|
|
1478
|
+
if (this.buffer.startsWith(PASTE_START)) {
|
|
1479
|
+
this.buffer = this.buffer.slice(PASTE_START.length);
|
|
1480
|
+
this.paste = '';
|
|
1481
|
+
continue;
|
|
1482
|
+
}
|
|
1483
|
+
if (this.buffer[0] === '\x1b') {
|
|
1484
|
+
if (!this.handleEscape()) {
|
|
1485
|
+
this.armEscape();
|
|
1486
|
+
return;
|
|
1487
|
+
}
|
|
1488
|
+
continue;
|
|
1489
|
+
}
|
|
1490
|
+
const special = this.buffer.search(/[\x00-\x1f\x7f]/);
|
|
1491
|
+
if (special === -1) {
|
|
1492
|
+
const text = this.buffer;
|
|
1493
|
+
this.buffer = '';
|
|
1494
|
+
this.insertText(text);
|
|
1495
|
+
continue;
|
|
1496
|
+
}
|
|
1497
|
+
if (special > 0) {
|
|
1498
|
+
const text = this.buffer.slice(0, special);
|
|
1499
|
+
this.buffer = this.buffer.slice(special);
|
|
1500
|
+
this.insertText(text);
|
|
1501
|
+
continue;
|
|
1502
|
+
}
|
|
1503
|
+
const char = this.buffer[0];
|
|
1504
|
+
this.buffer = this.buffer.slice(1);
|
|
1505
|
+
this.handleControl(char);
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
armEscape() {
|
|
1510
|
+
if (this.escTimer) return;
|
|
1511
|
+
this.escTimer = setTimeout(() => {
|
|
1512
|
+
this.escTimer = null;
|
|
1513
|
+
if (this.buffer === '\x1b') {
|
|
1514
|
+
this.buffer = '';
|
|
1515
|
+
this.handleKey('escape');
|
|
1516
|
+
}
|
|
1517
|
+
}, 40);
|
|
1518
|
+
this.escTimer.unref?.();
|
|
1519
|
+
}
|
|
1520
|
+
|
|
1521
|
+
handleEscape() {
|
|
1522
|
+
const mouse = /^\x1b\[<(\d+);(\d+);(\d+)([Mm])/.exec(this.buffer);
|
|
1523
|
+
if (mouse) {
|
|
1524
|
+
this.buffer = this.buffer.slice(mouse[0].length);
|
|
1525
|
+
this.handleMouseReport(mouse);
|
|
1526
|
+
return true;
|
|
1527
|
+
}
|
|
1528
|
+
const keys = [
|
|
1529
|
+
['\x1b[A', 'up'],
|
|
1530
|
+
['\x1b[B', 'down'],
|
|
1531
|
+
['\x1b[C', 'right'],
|
|
1532
|
+
['\x1b[D', 'left'],
|
|
1533
|
+
['\x1b[H', 'home'],
|
|
1534
|
+
['\x1b[F', 'end'],
|
|
1535
|
+
['\x1b[1~', 'home'],
|
|
1536
|
+
['\x1b[4~', 'end'],
|
|
1537
|
+
['\x1b[5~', 'pageup'],
|
|
1538
|
+
['\x1b[6~', 'pagedown'],
|
|
1539
|
+
['\x1b[3~', 'delete'],
|
|
1540
|
+
];
|
|
1541
|
+
for (const [sequence, name] of keys) {
|
|
1542
|
+
if (!this.buffer.startsWith(sequence)) continue;
|
|
1543
|
+
this.buffer = this.buffer.slice(sequence.length);
|
|
1544
|
+
this.handleKey(name);
|
|
1545
|
+
return true;
|
|
1546
|
+
}
|
|
1547
|
+
const csi = /^\x1b\[[0-9;?]*[ -/]*[@-~]/.exec(this.buffer);
|
|
1548
|
+
if (csi) {
|
|
1549
|
+
this.buffer = this.buffer.slice(csi[0].length);
|
|
1550
|
+
return true;
|
|
1551
|
+
}
|
|
1552
|
+
if (this.buffer.length === 1) return false;
|
|
1553
|
+
this.buffer = this.buffer.slice(2); // Alt+key: ignored
|
|
1554
|
+
return true;
|
|
1555
|
+
}
|
|
1556
|
+
|
|
1557
|
+
handleMouseReport(match) {
|
|
1558
|
+
const button = Number(match[1]);
|
|
1559
|
+
const y = Number(match[3]);
|
|
1560
|
+
const release = match[4] === 'm';
|
|
1561
|
+
if ((button & 64) === 64) {
|
|
1562
|
+
this.scrollBy((button & 1) === 0 ? 3 : -3);
|
|
1563
|
+
return;
|
|
1564
|
+
}
|
|
1565
|
+
if (release || (button & 3) !== 0) return;
|
|
1566
|
+
const index = y - 2; // one header row, 1-based screen row
|
|
1567
|
+
if (index < 0 || index >= this.viewport) return;
|
|
1568
|
+
// A code block is a copy target, exactly like a sent prompt: the chip is
|
|
1569
|
+
// only a label, the whole block is the hit area.
|
|
1570
|
+
const copyTarget = this.copyMap?.[index];
|
|
1571
|
+
if (copyTarget) {
|
|
1572
|
+
this.copyCode(copyTarget);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
const id = this.clickMap[index];
|
|
1576
|
+
if (!id) return;
|
|
1577
|
+
const item = this.transcript.get(id);
|
|
1578
|
+
// A sent prompt is a copy target, not a foldable block.
|
|
1579
|
+
if (item?.kind === 'task') {
|
|
1580
|
+
this.copyItem(item);
|
|
1581
|
+
return;
|
|
1582
|
+
}
|
|
1583
|
+
this.transcript.toggle(id);
|
|
1584
|
+
this.requestRender();
|
|
1585
|
+
}
|
|
1586
|
+
|
|
1587
|
+
handleControl(char) {
|
|
1588
|
+
if (char === '\r' || char === '\n') return this.submit();
|
|
1589
|
+
if (char === '\x7f' || char === '\x08') return this.backspace();
|
|
1590
|
+
if (char === '\x03') {
|
|
1591
|
+
this.composer = { text: '', cursor: 0 };
|
|
1592
|
+
this.onSigint?.();
|
|
1593
|
+
return this.requestRender();
|
|
1594
|
+
}
|
|
1595
|
+
if (char === '\x04') {
|
|
1596
|
+
if (!this.composer.text) this.onSigint?.();
|
|
1597
|
+
return this.requestRender();
|
|
1598
|
+
}
|
|
1599
|
+
if (char === '\x01') return this.setCursor(0);
|
|
1600
|
+
if (char === '\x05') return this.setCursor(this.composerChars().length);
|
|
1601
|
+
if (char === '\x0b') {
|
|
1602
|
+
this.composer.text = this.composerChars().slice(0, this.composer.cursor).join('');
|
|
1603
|
+
return this.requestRender();
|
|
1604
|
+
}
|
|
1605
|
+
if (char === '\x15') {
|
|
1606
|
+
this.composer = { text: '', cursor: 0 };
|
|
1607
|
+
return this.requestRender();
|
|
1608
|
+
}
|
|
1609
|
+
if (char === '\x19') return this.copyTask(0); // Ctrl+Y: copy the last prompt
|
|
1610
|
+
if (char === '\x17') return this.deleteWord();
|
|
1611
|
+
if (char === '\x0c') return this.forceRender();
|
|
1612
|
+
if (char === '\t') return this.insertText(' ');
|
|
1613
|
+
return undefined;
|
|
1614
|
+
}
|
|
1615
|
+
|
|
1616
|
+
handleKey(name) {
|
|
1617
|
+
const chars = this.composerChars();
|
|
1618
|
+
if (name === 'left') return this.setCursor(this.composer.cursor - 1);
|
|
1619
|
+
if (name === 'right') return this.setCursor(this.composer.cursor + 1);
|
|
1620
|
+
if (name === 'home') return this.setCursor(0);
|
|
1621
|
+
if (name === 'end') return this.setCursor(chars.length);
|
|
1622
|
+
if (name === 'delete') {
|
|
1623
|
+
if (this.composer.cursor >= chars.length) return this.requestRender();
|
|
1624
|
+
this.composer.text = [...chars.slice(0, this.composer.cursor), ...chars.slice(this.composer.cursor + 1)].join('');
|
|
1625
|
+
return this.requestRender();
|
|
1626
|
+
}
|
|
1627
|
+
if (name === 'up') return this.historyPrev();
|
|
1628
|
+
if (name === 'down') return this.historyNext();
|
|
1629
|
+
if (name === 'pageup') return this.scrollBy(this.viewport - 2);
|
|
1630
|
+
if (name === 'pagedown') return this.scrollBy(-(this.viewport - 2));
|
|
1631
|
+
if (name === 'escape') {
|
|
1632
|
+
if (this.isBlockViewOpen()) {
|
|
1633
|
+
this.transcript.collapseAll();
|
|
1634
|
+
return this.requestRender();
|
|
1635
|
+
}
|
|
1636
|
+
this.scroll = 0;
|
|
1637
|
+
return this.requestRender();
|
|
1638
|
+
}
|
|
1639
|
+
return undefined;
|
|
1640
|
+
}
|
|
1641
|
+
|
|
1642
|
+
scrollBy(delta) {
|
|
1643
|
+
this.scroll = Math.max(0, Math.min(this.scroll + delta, this.maxScroll));
|
|
1644
|
+
this.requestRender();
|
|
1645
|
+
}
|
|
1646
|
+
|
|
1647
|
+
composerChars() {
|
|
1648
|
+
return Array.from(this.composer.text);
|
|
1649
|
+
}
|
|
1650
|
+
|
|
1651
|
+
setCursor(index) {
|
|
1652
|
+
this.composer.cursor = Math.max(0, Math.min(index, this.composerChars().length));
|
|
1653
|
+
this.requestRender();
|
|
1654
|
+
}
|
|
1655
|
+
|
|
1656
|
+
insertText(text) {
|
|
1657
|
+
if (!text) return;
|
|
1658
|
+
const clean = String(text).replace(/\r\n?/g, '\n').replace(/\n/g, ' ');
|
|
1659
|
+
const chars = this.composerChars();
|
|
1660
|
+
const cursor = this.composer.cursor;
|
|
1661
|
+
this.composer.text = [...chars.slice(0, cursor), ...Array.from(clean), ...chars.slice(cursor)].join('');
|
|
1662
|
+
this.composer.cursor = cursor + Array.from(clean).length;
|
|
1663
|
+
this.requestRender();
|
|
1664
|
+
}
|
|
1665
|
+
|
|
1666
|
+
insertPaste(text) {
|
|
1667
|
+
const value = String(text ?? '');
|
|
1668
|
+
if ([...value].length >= 1000) {
|
|
1669
|
+
const entry = this.registry.add(value);
|
|
1670
|
+
this.insertText(entry.placeholder);
|
|
1671
|
+
return;
|
|
1672
|
+
}
|
|
1673
|
+
this.insertText(value);
|
|
1674
|
+
}
|
|
1675
|
+
|
|
1676
|
+
backspace() {
|
|
1677
|
+
const chars = this.composerChars();
|
|
1678
|
+
if (this.composer.cursor <= 0) return;
|
|
1679
|
+
this.composer.text = [...chars.slice(0, this.composer.cursor - 1), ...chars.slice(this.composer.cursor)].join('');
|
|
1680
|
+
this.composer.cursor -= 1;
|
|
1681
|
+
this.requestRender();
|
|
1682
|
+
}
|
|
1683
|
+
|
|
1684
|
+
deleteWord() {
|
|
1685
|
+
const chars = this.composerChars();
|
|
1686
|
+
let index = this.composer.cursor;
|
|
1687
|
+
while (index > 0 && /\s/.test(chars[index - 1])) index -= 1;
|
|
1688
|
+
while (index > 0 && !/\s/.test(chars[index - 1])) index -= 1;
|
|
1689
|
+
this.composer.text = [...chars.slice(0, index), ...chars.slice(this.composer.cursor)].join('');
|
|
1690
|
+
this.composer.cursor = index;
|
|
1691
|
+
this.requestRender();
|
|
1692
|
+
}
|
|
1693
|
+
|
|
1694
|
+
historyPrev() {
|
|
1695
|
+
if (this.history.length === 0) return this.requestRender();
|
|
1696
|
+
if (this.historyIndex === this.history.length) this.historyDraft = this.composer.text;
|
|
1697
|
+
this.historyIndex = Math.max(0, this.historyIndex - 1);
|
|
1698
|
+
this.composer = { text: this.history[this.historyIndex] || '', cursor: Array.from(this.history[this.historyIndex] || '').length };
|
|
1699
|
+
this.requestRender();
|
|
1700
|
+
}
|
|
1701
|
+
|
|
1702
|
+
historyNext() {
|
|
1703
|
+
if (this.history.length === 0) return this.requestRender();
|
|
1704
|
+
this.historyIndex = Math.min(this.history.length, this.historyIndex + 1);
|
|
1705
|
+
const text = this.historyIndex === this.history.length ? this.historyDraft : this.history[this.historyIndex];
|
|
1706
|
+
this.composer = { text: text || '', cursor: Array.from(text || '').length };
|
|
1707
|
+
this.requestRender();
|
|
1708
|
+
}
|
|
1709
|
+
|
|
1710
|
+
submit() {
|
|
1711
|
+
const raw = this.composer.text;
|
|
1712
|
+
const trimmed = raw.trim();
|
|
1713
|
+
if (!trimmed && !this.modal) return this.requestRender();
|
|
1714
|
+
if (trimmed) {
|
|
1715
|
+
this.history.push(raw);
|
|
1716
|
+
this.historyIndex = this.history.length;
|
|
1717
|
+
this.historyDraft = '';
|
|
1718
|
+
}
|
|
1719
|
+
this.composer = { text: '', cursor: 0 };
|
|
1720
|
+
const line = this.registry.resolve(raw);
|
|
1721
|
+
if (this.modal) {
|
|
1722
|
+
const modal = this.modal;
|
|
1723
|
+
this.modal = null;
|
|
1724
|
+
modal.resolve(line);
|
|
1725
|
+
this.requestRender();
|
|
1726
|
+
return;
|
|
1727
|
+
}
|
|
1728
|
+
if (this.askWaiter) {
|
|
1729
|
+
const waiter = this.askWaiter;
|
|
1730
|
+
this.askWaiter = null;
|
|
1731
|
+
waiter(line);
|
|
1732
|
+
} else {
|
|
1733
|
+
this.lineQueue.push(line);
|
|
1734
|
+
}
|
|
1735
|
+
this.requestRender();
|
|
1736
|
+
}
|
|
1737
|
+
}
|