@semalt-ai/code 1.19.0 → 1.20.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/.claude/settings.local.json +2 -1
- package/ARCHITECTURE.md +6 -95
- package/CLAUDE.md +196 -1874
- package/README.md +1 -1
- package/docs/ARCHITECTURE.md +1321 -0
- package/docs/CONFIG.md +340 -0
- package/docs/HISTORY.md +245 -0
- package/index.js +1 -1
- package/lib/agent.js +145 -16
- package/lib/api.js +28 -3
- package/lib/commands/chat-session.js +188 -4
- package/lib/commands/chat-slash.js +16 -0
- package/lib/commands/chat-turn.js +319 -52
- package/lib/commands/chat.js +12 -8
- package/lib/config.js +27 -0
- package/lib/constants.js +30 -1
- package/lib/headless.js +36 -1
- package/lib/images.js +8 -2
- package/lib/permissions.js +23 -16
- package/lib/prompts.js +15 -3
- package/lib/tool_registry.js +357 -53
- package/lib/tool_specs.js +42 -8
- package/lib/tools.js +80 -19
- package/lib/ui/anim.js +86 -0
- package/lib/ui/ansi.js +17 -27
- package/lib/ui/chat-history.js +253 -71
- package/lib/ui/create-ui.js +67 -24
- package/lib/ui/diff.js +90 -25
- package/lib/ui/file-activity.js +229 -0
- package/lib/ui/format.js +173 -28
- package/lib/ui/input-field.js +5 -4
- package/lib/ui/md-stream.js +234 -0
- package/lib/ui/render-operation.js +113 -0
- package/lib/ui/select.js +1 -4
- package/lib/ui/status-bar.js +99 -57
- package/lib/ui/stream.js +20 -13
- package/lib/ui/theme.js +190 -45
- package/lib/ui/tool-operation.js +190 -0
- package/lib/ui/utils.js +9 -5
- package/lib/ui/web-activity.js +58 -6
- package/lib/ui/writer.js +159 -45
- package/lib/ui.js +1 -1
- package/package.json +1 -1
- package/test/anim-driver.test.js +153 -0
- package/test/ask-user-display.test.js +226 -0
- package/test/ask-user-gate.test.js +231 -0
- package/test/chat-history-nocolor.test.js +155 -0
- package/test/chat-relogin.test.js +207 -0
- package/test/defer-detail-band.test.js +403 -0
- package/test/detail-band-tab-flatten.test.js +242 -0
- package/test/exec-diff.test.js +268 -0
- package/test/executors.test.js +250 -13
- package/test/extract-tool-calls.test.js +37 -3
- package/test/file-activity.test.js +542 -0
- package/test/grep-path-target.test.js +227 -0
- package/test/harness/chat-harness.js +2 -1
- package/test/headless.test.js +146 -1
- package/test/input-field-ctrl-o.test.js +37 -0
- package/test/live-height-physical.test.js +281 -0
- package/test/max-iterations.test.js +9 -7
- package/test/md-stream.test.js +183 -0
- package/test/narration-ordering.test.js +309 -0
- package/test/native-dispatch.test.js +53 -0
- package/test/native-live-narration.test.js +254 -0
- package/test/output-heredoc-leak.test.js +195 -0
- package/test/output-preview.test.js +245 -0
- package/test/permission-flush.test.js +302 -0
- package/test/permissions.test.js +199 -0
- package/test/read-paginate.test.js +1 -1
- package/test/render-operation.test.js +317 -0
- package/test/replay-descriptor-xml.test.js +216 -0
- package/test/replay-descriptor.test.js +189 -0
- package/test/replay-web-aggregate.test.js +291 -0
- package/test/replay-web-persist.test.js +241 -0
- package/test/running-glyph-anim.test.js +111 -0
- package/test/status-bar-driver.test.js +93 -0
- package/test/status-bar-resync.test.js +188 -0
- package/test/stream-parser.test.js +24 -0
- package/test/theme-palette.test.js +166 -0
- package/test/truncate-visible.test.js +78 -0
- package/test/view-image.test.js +199 -0
- package/test/web-activity-ordering.test.js +12 -3
- package/path +0 -1
package/lib/ui/chat-history.js
CHANGED
|
@@ -1,11 +1,55 @@
|
|
|
1
1
|
'use strict';
|
|
2
2
|
|
|
3
|
-
const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED,
|
|
3
|
+
const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_GRAY } = require('./ansi');
|
|
4
4
|
const { getCols, stripAnsi } = require('./utils');
|
|
5
|
-
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
|
|
6
|
-
const {
|
|
5
|
+
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES, colorize, colorEnabled } = require('./theme');
|
|
6
|
+
const { StreamMarkdown, renderBlock } = require('./md-stream');
|
|
7
|
+
const { summarizeToolResult, formatOutputPreview } = require('./format');
|
|
8
|
+
const { descriptorFromStored } = require('./tool-operation');
|
|
9
|
+
const { isWebCore } = require('./web-activity');
|
|
10
|
+
const { renderOperation } = require('./render-operation');
|
|
7
11
|
const writer = require('./writer');
|
|
8
12
|
|
|
13
|
+
// Gate a raw SGR code through the single NO_COLOR/non-TTY switch (theme.js).
|
|
14
|
+
// Same idiom as diff.js / md-stream.js — wrap every color-constant interpolation
|
|
15
|
+
// in c(...) so UI chrome emits zero ANSI under NO_COLOR while glyphs/layout stay.
|
|
16
|
+
const c = colorize;
|
|
17
|
+
|
|
18
|
+
// Embedded control chars that would break the "one logical line == one physical
|
|
19
|
+
// row" invariant the detail-band erase/commit math rests on. Mirrors the
|
|
20
|
+
// writer's _CONTROL_CHARS (writer.js): \t (TAB) advances to the next 8-col tab
|
|
21
|
+
// stop, \r/C0/DEL move the cursor unpredictably. ESC (0x1B) is EXCLUDED so SGR
|
|
22
|
+
// color survives — _flattenBandLine strips the non-SGR escapes separately.
|
|
23
|
+
const _BAND_CONTROL_CHARS = /[\x00-\x1A\x1C-\x1F\x7F]/g; // eslint-disable-line no-control-regex
|
|
24
|
+
// A CSI sequence: ESC '[' params final-letter. Used to drop the NON-SGR ones.
|
|
25
|
+
const _CSI_SEQ = /\x1b\[[0-9;?]*[A-Za-z]/g; // eslint-disable-line no-control-regex
|
|
26
|
+
|
|
27
|
+
// Flatten an already-fitted output-preview body line so it occupies EXACTLY one
|
|
28
|
+
// physical terminal row — restoring the invariant the detail band's logical-line
|
|
29
|
+
// clamp and the writer's physicalRows erase math both depend on. formatOutputPreview
|
|
30
|
+
// truncated the line to the render width, but it (via termWidth) counts a TAB as
|
|
31
|
+
// 1 column while the terminal advances it to the next 8-col tab stop, so a
|
|
32
|
+
// "fitted" TAB-bearing line still WRAPS to ≥2 rows. Two flattening steps, both
|
|
33
|
+
// no-ops on plain ASCII (so clean output stays byte-identical):
|
|
34
|
+
// 1. Replace every C0/DEL control (TAB included) with a space — 1:1, so the
|
|
35
|
+
// already-fitted visible width is unchanged. Identical to the writer's
|
|
36
|
+
// _fitOneRow control-char pass that every OTHER live row already gets.
|
|
37
|
+
// 2. Drop stray NON-SGR escapes (e.g. `\x1b[K`): displayRows/termWidth strip
|
|
38
|
+
// only `…m` sequences, so a surviving non-'m' CSI would inflate the measured
|
|
39
|
+
// width past cols and re-introduce a phantom second row. SGR (`…m`) is kept
|
|
40
|
+
// so captured color survives. This closes the stripAnsi-only-'m' gap.
|
|
41
|
+
function _flattenBandLine(line) {
|
|
42
|
+
let s = String(line == null ? '' : line);
|
|
43
|
+
if (_BAND_CONTROL_CHARS.test(s)) {
|
|
44
|
+
_BAND_CONTROL_CHARS.lastIndex = 0; // .test on a /g regex is stateful
|
|
45
|
+
s = s.replace(_BAND_CONTROL_CHARS, ' ');
|
|
46
|
+
}
|
|
47
|
+
if (s.indexOf('\x1b') !== -1) {
|
|
48
|
+
s = s.replace(_CSI_SEQ, (m) => (m.endsWith('m') ? m : ''));
|
|
49
|
+
}
|
|
50
|
+
return s;
|
|
51
|
+
}
|
|
52
|
+
|
|
9
53
|
|
|
10
54
|
function safeContent(text) {
|
|
11
55
|
if (!text) return '';
|
|
@@ -31,9 +75,9 @@ function _dimPaths(text) {
|
|
|
31
75
|
if (trail) {
|
|
32
76
|
const core = m.slice(0, -trail[0].length);
|
|
33
77
|
if (!core) return m;
|
|
34
|
-
return `${UI_THEME.subtle}${core}${UI_THEME.default}${trail[0]}`;
|
|
78
|
+
return `${c(UI_THEME.subtle)}${core}${c(UI_THEME.default)}${trail[0]}`;
|
|
35
79
|
}
|
|
36
|
-
return `${UI_THEME.subtle}${m}${UI_THEME.default}`;
|
|
80
|
+
return `${c(UI_THEME.subtle)}${m}${c(UI_THEME.default)}`;
|
|
37
81
|
});
|
|
38
82
|
}
|
|
39
83
|
|
|
@@ -54,9 +98,13 @@ const BG_USER = '\x1b[48;5;237m';
|
|
|
54
98
|
// atomic chunk.
|
|
55
99
|
function _buildUser(content, ts) {
|
|
56
100
|
const time = _fmtTime(ts);
|
|
57
|
-
let out = `\n${FG_CYAN}▸ You${RST} ${DIM}${time}${RST}\n`;
|
|
101
|
+
let out = `\n${c(FG_CYAN)}▸ You${c(RST)} ${c(DIM)}${time}${c(RST)}\n`;
|
|
58
102
|
for (const line of (content || '').split('\n')) {
|
|
59
|
-
|
|
103
|
+
// The \x1b[K (erase-to-EOL) only exists to fill the bg tint to the screen
|
|
104
|
+
// edge — pointless with no bg, and it's not an SGR so stripAnsi can't reach
|
|
105
|
+
// it. Drop it when color is off so a NO_COLOR bubble carries ZERO escapes.
|
|
106
|
+
const el = colorEnabled() ? '\x1b[K' : '';
|
|
107
|
+
out += `${c(BG_USER)} ${line}${el}${c(RST)}\n`;
|
|
60
108
|
}
|
|
61
109
|
out += '\n';
|
|
62
110
|
return out;
|
|
@@ -64,10 +112,12 @@ function _buildUser(content, ts) {
|
|
|
64
112
|
|
|
65
113
|
function _buildAI(content, ts) {
|
|
66
114
|
const time = _fmtTime(ts);
|
|
67
|
-
let out = `\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`;
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
115
|
+
let out = `\n${colorize(FG_GREEN)}▸ AI-agent${colorize(RST)} ${colorize(DIM)}${time}${colorize(RST)}\n`;
|
|
116
|
+
// Drive the SAME StreamMarkdown the live streaming path uses, fed line-by-line
|
|
117
|
+
// then flushed, so a replayed (--resume / history) assistant turn renders
|
|
118
|
+
// byte-identical to the live one.
|
|
119
|
+
const block = renderBlock(content);
|
|
120
|
+
if (block) out += block + '\n';
|
|
71
121
|
out += '\n';
|
|
72
122
|
return out;
|
|
73
123
|
}
|
|
@@ -82,10 +132,18 @@ class ChatHistory {
|
|
|
82
132
|
this._didStream = false;
|
|
83
133
|
this._msgById = {};
|
|
84
134
|
this._msgLineCount = {};
|
|
85
|
-
this._toolExpanded = {};
|
|
86
|
-
this._lastExpandableId = null;
|
|
87
|
-
this._toolIdCounter = 0;
|
|
88
135
|
this._messages = [];
|
|
136
|
+
// The single deferred output-preview slot. While set, the held detail lives
|
|
137
|
+
// in the writer's redrawable detail band (not scrollback). Shape: { msg,
|
|
138
|
+
// lines }. Committed once at a turn boundary (or auto-flushed before any new
|
|
139
|
+
// scrollback this class emits), then cleared back to null. The band is
|
|
140
|
+
// installed once and committed once — never redrawn in place.
|
|
141
|
+
this._deferred = null;
|
|
142
|
+
// Per-instance Markdown styler for agent narration. Holds the cross-line
|
|
143
|
+
// fenced-code-block state so a ``` block split across streamToken calls is
|
|
144
|
+
// styled correctly; reset at every turn boundary (clearStreamingContent /
|
|
145
|
+
// clearMessages).
|
|
146
|
+
this._md = new StreamMarkdown();
|
|
89
147
|
// Callback into the UI orchestrator to refresh the live region (so the
|
|
90
148
|
// in-progress streaming line and chrome re-render together). Set by
|
|
91
149
|
// createUI; safe to leave null for headless/test contexts.
|
|
@@ -106,6 +164,23 @@ class ChatHistory {
|
|
|
106
164
|
isStreaming() { return this._streamActive; }
|
|
107
165
|
|
|
108
166
|
_commit(text) { writer.scrollback(text); }
|
|
167
|
+
// Phase 7b detail-band seams (overridable in tests, like _commit): install /
|
|
168
|
+
// replace the live detail band, and atomically commit it to scrollback.
|
|
169
|
+
_setDetail(lines) { writer.setDetail(lines); }
|
|
170
|
+
_commitDetail(text) { writer.commitDetail(text); }
|
|
171
|
+
|
|
172
|
+
// Commit an ALREADY-STYLED single line straight to scrollback, with no
|
|
173
|
+
// reformatting and without touching `_messages` / live-region state. Used by
|
|
174
|
+
// replay (Phase 6c-ii) to land an aggregated `✓ web · …` web-activity summary
|
|
175
|
+
// exactly as the live path's `writer.endActivity(id, line)` does — both route
|
|
176
|
+
// the same styled string through `writer.scrollback` (which appends the single
|
|
177
|
+
// trailing newline), so a replayed web summary is byte-identical to the live
|
|
178
|
+
// committed one. Deliberately NOT funnelled through summarizeToolResult.
|
|
179
|
+
addRawLine(line) {
|
|
180
|
+
if (line == null || line === '') return;
|
|
181
|
+
this._flushStream();
|
|
182
|
+
this._commit(line);
|
|
183
|
+
}
|
|
109
184
|
|
|
110
185
|
_notifyLive() {
|
|
111
186
|
if (this._onLiveUpdate) {
|
|
@@ -118,6 +193,11 @@ class ChatHistory {
|
|
|
118
193
|
const content = safeContent(msg.content || '');
|
|
119
194
|
if ((msg.role === 'assistant' || msg.role === 'system') && !content.trim()) return;
|
|
120
195
|
|
|
196
|
+
// Phase 7b — any held detail band is the PREVIOUS op's output; commit it to
|
|
197
|
+
// scrollback before this new bubble so ordering is preserved (tool output
|
|
198
|
+
// above the message that follows it). No-op when nothing is deferred.
|
|
199
|
+
if (this._deferred) this.commitDeferredDetail();
|
|
200
|
+
|
|
121
201
|
// Any in-progress stream must close before we emit a new scrollback
|
|
122
202
|
// block — otherwise the stream's partial line would sit between the
|
|
123
203
|
// header and body of the new bubble.
|
|
@@ -135,10 +215,14 @@ class ChatHistory {
|
|
|
135
215
|
} else if (msg.role === 'shell') {
|
|
136
216
|
const cmd = msg.cmd || '';
|
|
137
217
|
const shellOut = safeContent(msg.content || '');
|
|
138
|
-
|
|
218
|
+
// Replayed shell preview (/history, --resume). Was wholesale-DIM and
|
|
219
|
+
// illegible; now the `$` command line carries the shell category colour
|
|
220
|
+
// and the output is subtle (one step up from dim) so it stays readable.
|
|
221
|
+
const shellColor = (UI_THEME.categories && UI_THEME.categories.shell) || UI_THEME.accent;
|
|
222
|
+
out = `\n ${c(shellColor)}$ ${cmd}${c(RST)}\n`;
|
|
139
223
|
if (shellOut.trim()) {
|
|
140
224
|
for (const line of shellOut.split('\n')) {
|
|
141
|
-
if (line.trim()) out +=
|
|
225
|
+
if (line.trim()) out += ` ${c(UI_THEME.subtle)}${line}${c(RST)}\n`;
|
|
142
226
|
}
|
|
143
227
|
}
|
|
144
228
|
out += '\n';
|
|
@@ -154,23 +238,53 @@ class ChatHistory {
|
|
|
154
238
|
// to a single live-activity-style line. Callers that pass `output`
|
|
155
239
|
// (debug blocks, live-activity error pass-through) keep the legacy
|
|
156
240
|
// header chrome.
|
|
157
|
-
|
|
241
|
+
//
|
|
242
|
+
// Phase 6a — replay fidelity. A saved native tool message may carry a
|
|
243
|
+
// serialized display descriptor (`_display`). When present and its
|
|
244
|
+
// version is known, render the result line (and any diff / output detail)
|
|
245
|
+
// through the SAME renderOperation a fresh turn uses — full fidelity
|
|
246
|
+
// (real diff, real duration, real status), not the lossy summary. A
|
|
247
|
+
// missing / unknown-version core yields null here → the legacy
|
|
248
|
+
// summarizeToolResult fall-through below, unchanged.
|
|
249
|
+
// Phase 6c-i — a web op now persists a {v:1,kind:'web',…} core in `_display`
|
|
250
|
+
// (it used to persist nothing). Treat a web-core as "no descriptor" so it
|
|
251
|
+
// takes the SAME legacy summarizeToolResult fall-through a web message took
|
|
252
|
+
// before 6c-i — byte-identical until 6c-ii flips this to aggregation.
|
|
253
|
+
const restored = (msg._display && !isWebCore(msg._display)) ? descriptorFromStored(msg._display) : null;
|
|
254
|
+
if (restored) {
|
|
255
|
+
out = renderOperation(restored, { mode: 'ansi', phase: 'result' }) + '\n';
|
|
256
|
+
lineCount = 1;
|
|
257
|
+
const detail = restored.detail;
|
|
258
|
+
if (detail && detail.kind === 'diff') {
|
|
259
|
+
// The file-edit diff, expanded exactly as the live path commits it.
|
|
260
|
+
const diffStr = renderOperation(restored, { mode: 'ansi', phase: 'detail', maxLines: msg.diffMaxLines });
|
|
261
|
+
if (diffStr) {
|
|
262
|
+
out += diffStr + '\n';
|
|
263
|
+
lineCount += diffStr.split('\n').length;
|
|
264
|
+
}
|
|
265
|
+
} else if (detail && detail.kind === 'output') {
|
|
266
|
+
// Route the shell/MCP/subagent preview body through the SAME output
|
|
267
|
+
// branch below (identical DIM/indent chrome + collapsed hint).
|
|
268
|
+
msg.output = detail.payload.body;
|
|
269
|
+
if (!Number.isInteger(msg.previewLines) || msg.previewLines <= 0) msg.previewLines = 5;
|
|
270
|
+
}
|
|
271
|
+
} else if (msg.content && !msg.output) {
|
|
158
272
|
const summary = safeContent(summarizeToolResult(msg.content));
|
|
159
273
|
if (summary) {
|
|
160
274
|
const indicator = msg.isError
|
|
161
|
-
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
162
|
-
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
163
|
-
const sep = ` ${DIM}·${RST} `;
|
|
275
|
+
? `${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)}`
|
|
276
|
+
: `${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)}`;
|
|
277
|
+
const sep = ` ${c(DIM)}·${c(RST)} `;
|
|
164
278
|
const styled = summary.split(' · ').map((p) => _dimPaths(p)).join(sep);
|
|
165
279
|
out = ` ${indicator} ${styled}\n`;
|
|
166
280
|
lineCount = 1;
|
|
167
281
|
}
|
|
168
282
|
} else if (content) {
|
|
169
283
|
const indicator = msg.isError
|
|
170
|
-
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
171
|
-
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
284
|
+
? `${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)}`
|
|
285
|
+
: `${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)}`;
|
|
172
286
|
const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
|
|
173
|
-
const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
|
|
287
|
+
const head = `${c(UI_THEME.accent)}${category}${c(UI_THEME.muted)}:${c(RST)}`;
|
|
174
288
|
const desc = _dimPaths(content);
|
|
175
289
|
out = ` ${indicator} ${head} ${desc}\n`;
|
|
176
290
|
lineCount = 1;
|
|
@@ -178,7 +292,18 @@ class ChatHistory {
|
|
|
178
292
|
out = '';
|
|
179
293
|
lineCount = 0;
|
|
180
294
|
}
|
|
181
|
-
if (msg.output) {
|
|
295
|
+
if (msg.output && Number.isInteger(msg.previewLines) && msg.previewLines > 0) {
|
|
296
|
+
// Phase 5 — collapsed output preview (shell/MCP/subagent). The body is
|
|
297
|
+
// committed line-by-line to SCROLLBACK (not the live region), each line
|
|
298
|
+
// fitted to one physical row by formatOutputPreview, so the Phase-4
|
|
299
|
+
// single-row invariant holds. Rendered ONCE, statically: the first
|
|
300
|
+
// previewLines + a `… N more lines` hint, with no interactive affordance.
|
|
301
|
+
const { lines: pvLines } = this._renderOutputPreviewLines(msg);
|
|
302
|
+
for (const ol of pvLines) {
|
|
303
|
+
out += ol + '\n';
|
|
304
|
+
lineCount++;
|
|
305
|
+
}
|
|
306
|
+
} else if (msg.output) {
|
|
182
307
|
const wrapAt = Math.max(60, getCols() - 8);
|
|
183
308
|
const outLines = [];
|
|
184
309
|
for (const raw of msg.output.split('\n')) {
|
|
@@ -192,32 +317,19 @@ class ChatHistory {
|
|
|
192
317
|
}
|
|
193
318
|
}
|
|
194
319
|
const truncatable = outLines.length > MAX_TOOL_DISPLAY;
|
|
195
|
-
|
|
196
|
-
if (truncatable && !msg.id) {
|
|
197
|
-
msg.id = `tool-${++this._toolIdCounter}`;
|
|
198
|
-
}
|
|
199
|
-
if (truncatable && msg.id) {
|
|
200
|
-
this._msgById[msg.id] = msg;
|
|
201
|
-
this._lastExpandableId = msg.id;
|
|
202
|
-
}
|
|
203
|
-
|
|
204
|
-
const expanded = truncatable && msg.id && this._toolExpanded[msg.id];
|
|
205
|
-
const visible = (truncatable && !expanded) ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
|
|
320
|
+
const visible = truncatable ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
|
|
206
321
|
const remaining = outLines.length - visible.length;
|
|
207
322
|
|
|
208
|
-
const outerOpen = msg.tag === 'debug' ? ' ' : `${DIM} `;
|
|
209
|
-
const outerClose = msg.tag === 'debug' ? '' : RST;
|
|
323
|
+
const outerOpen = msg.tag === 'debug' ? ' ' : `${c(DIM)} `;
|
|
324
|
+
const outerClose = msg.tag === 'debug' ? '' : c(RST);
|
|
210
325
|
for (const ol of visible) { out += `${outerOpen}${ol}${outerClose}\n`; lineCount++; }
|
|
211
326
|
if (remaining > 0) {
|
|
212
|
-
out += `${DIM} … ${remaining} more lines
|
|
213
|
-
lineCount++;
|
|
214
|
-
} else if (truncatable && expanded) {
|
|
215
|
-
out += `${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`;
|
|
327
|
+
out += `${c(DIM)} … ${remaining} more lines${c(RST)}\n`;
|
|
216
328
|
lineCount++;
|
|
217
329
|
}
|
|
218
330
|
}
|
|
219
331
|
} else if (msg.role === 'permission') {
|
|
220
|
-
out = `\n ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}Permission required${RST}${UI_THEME.muted}:${RST} ${content}\n`;
|
|
332
|
+
out = `\n ${c(UI_THEME.warning)}${UI_ICONS.warn}${c(RST)} ${c(UI_THEME.warning)}Permission required${c(RST)}${c(UI_THEME.muted)}:${c(RST)} ${content}\n`;
|
|
221
333
|
} else {
|
|
222
334
|
// Fallback for role: 'system' and anything unrecognised.
|
|
223
335
|
const lines = content.split('\n');
|
|
@@ -225,25 +337,25 @@ class ChatHistory {
|
|
|
225
337
|
const stripGlyph = (s) => s.replace(/^[✓✗✕⚠]\s*/, '');
|
|
226
338
|
let rendered;
|
|
227
339
|
if (msg.isError && !msg.isWarning) {
|
|
228
|
-
rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${stripGlyph(first)}${RST}`;
|
|
340
|
+
rendered = ` ${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)} ${c(UI_THEME.error)}${stripGlyph(first)}${c(RST)}`;
|
|
229
341
|
} else if (msg.isWarning) {
|
|
230
|
-
rendered = ` ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}${stripGlyph(first)}${RST}`;
|
|
342
|
+
rendered = ` ${c(UI_THEME.warning)}${UI_ICONS.warn}${c(RST)} ${c(UI_THEME.warning)}${stripGlyph(first)}${c(RST)}`;
|
|
231
343
|
} else if (_isAutoApproveMeta(first)) {
|
|
232
|
-
const body = first.replace(/(auto-approv\w*)/gi, (m) => `${UI_THEME.warning}${m}${UI_THEME.subtle}`);
|
|
233
|
-
rendered = ` ${UI_THEME.subtle}${body}${RST}`;
|
|
344
|
+
const body = first.replace(/(auto-approv\w*)/gi, (m) => `${c(UI_THEME.warning)}${m}${c(UI_THEME.subtle)}`);
|
|
345
|
+
rendered = ` ${c(UI_THEME.subtle)}${body}${c(RST)}`;
|
|
234
346
|
} else if (first.startsWith('✓')) {
|
|
235
|
-
rendered = ` ${UI_THEME.success}${UI_ICONS.success}${RST} ${first.slice(1).trimStart()}`;
|
|
347
|
+
rendered = ` ${c(UI_THEME.success)}${UI_ICONS.success}${c(RST)} ${first.slice(1).trimStart()}`;
|
|
236
348
|
} else if (first.startsWith('✗')) {
|
|
237
|
-
rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${first.slice(1).trimStart()}${RST}`;
|
|
349
|
+
rendered = ` ${c(UI_THEME.error)}${UI_ICONS.error}${c(RST)} ${c(UI_THEME.error)}${first.slice(1).trimStart()}${c(RST)}`;
|
|
238
350
|
} else {
|
|
239
|
-
rendered = ` ${UI_THEME.subtle}${UI_ICONS.bullet}${RST} ${UI_THEME.subtle}${first}${RST}`;
|
|
351
|
+
rendered = ` ${c(UI_THEME.subtle)}${UI_ICONS.bullet}${c(RST)} ${c(UI_THEME.subtle)}${first}${c(RST)}`;
|
|
240
352
|
}
|
|
241
353
|
out = `${rendered}\n`;
|
|
242
354
|
const contColor = (msg.isError && !msg.isWarning) ? UI_THEME.error
|
|
243
355
|
: msg.isWarning ? UI_THEME.warning
|
|
244
356
|
: UI_THEME.subtle;
|
|
245
357
|
for (let i = 1; i < lines.length; i++) {
|
|
246
|
-
out += ` ${contColor}${lines[i]}${RST}\n`;
|
|
358
|
+
out += ` ${c(contColor)}${lines[i]}${c(RST)}\n`;
|
|
247
359
|
}
|
|
248
360
|
lineCount = lines.length;
|
|
249
361
|
}
|
|
@@ -260,12 +372,15 @@ class ChatHistory {
|
|
|
260
372
|
// the live region while it's still in-flight.
|
|
261
373
|
streamToken(token) {
|
|
262
374
|
if (!token) return;
|
|
375
|
+
// Phase 7b — the assistant answer is about to flow into scrollback; commit
|
|
376
|
+
// the prior op's held detail band first so it stays above the answer text.
|
|
377
|
+
if (this._deferred) this.commitDeferredDetail();
|
|
263
378
|
if (!this._streamActive) {
|
|
264
379
|
// Emit header immediately so users see the new AI turn before any
|
|
265
380
|
// tokens arrive. No blank line before the header since scrollback
|
|
266
381
|
// flows continuously.
|
|
267
382
|
const time = _fmtTime(new Date());
|
|
268
|
-
this._commit(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
|
|
383
|
+
this._commit(`\n${colorize(FG_GREEN)}▸ AI-agent${colorize(RST)} ${colorize(DIM)}${time}${colorize(RST)}\n`);
|
|
269
384
|
this._streamActive = true;
|
|
270
385
|
this._streamStart = new Date();
|
|
271
386
|
this._streamPartial = '';
|
|
@@ -274,7 +389,11 @@ class ChatHistory {
|
|
|
274
389
|
let nlIdx;
|
|
275
390
|
while ((nlIdx = this._streamPartial.indexOf('\n')) >= 0) {
|
|
276
391
|
const line = this._streamPartial.slice(0, nlIdx);
|
|
277
|
-
|
|
392
|
+
// Style each COMPLETE line before committing to scrollback. The styler
|
|
393
|
+
// returns null while buffering a fenced code-block body (the box is
|
|
394
|
+
// emitted whole at the closing ```), so nothing is committed then.
|
|
395
|
+
const styled = this._md.feedLine(line);
|
|
396
|
+
if (styled !== null) this._commit(styled + '\n');
|
|
278
397
|
this._streamPartial = this._streamPartial.slice(nlIdx + 1);
|
|
279
398
|
}
|
|
280
399
|
this._notifyLive();
|
|
@@ -283,9 +402,17 @@ class ChatHistory {
|
|
|
283
402
|
_flushStream() {
|
|
284
403
|
if (!this._streamActive) return;
|
|
285
404
|
if (this._streamPartial !== '') {
|
|
286
|
-
|
|
405
|
+
// The trailing partial is the turn's last (newline-less) line — style it
|
|
406
|
+
// as a complete line, mirroring the live per-line path exactly.
|
|
407
|
+
const styled = this._md.feedLine(this._streamPartial);
|
|
408
|
+
if (styled !== null) this._commit(styled + '\n');
|
|
287
409
|
this._streamPartial = '';
|
|
288
410
|
}
|
|
411
|
+
// Close any open inline span / still-buffering fenced code block so no
|
|
412
|
+
// stranded SGR or unclosed box reaches immutable scrollback.
|
|
413
|
+
const tail = this._md.flush();
|
|
414
|
+
if (tail !== null) this._commit(tail + '\n');
|
|
415
|
+
this._md.reset();
|
|
289
416
|
this._commit('\n');
|
|
290
417
|
this._streamActive = false;
|
|
291
418
|
this._streamStart = null;
|
|
@@ -294,10 +421,14 @@ class ChatHistory {
|
|
|
294
421
|
|
|
295
422
|
clearStreamingContent() {
|
|
296
423
|
this._flushStream();
|
|
424
|
+
this._md.reset();
|
|
297
425
|
this._notifyLive();
|
|
298
426
|
}
|
|
299
427
|
|
|
300
428
|
finalizeLastMessage(cleanContent) {
|
|
429
|
+
// Phase 7b — commit any held detail band before the terminal assistant
|
|
430
|
+
// bubble (covers the non-streaming path, where no streamToken fired).
|
|
431
|
+
if (this._deferred) this.commitDeferredDetail();
|
|
301
432
|
const wasStreaming = this._streamActive;
|
|
302
433
|
const streamStart = this._streamStart;
|
|
303
434
|
if (wasStreaming) {
|
|
@@ -329,9 +460,12 @@ class ChatHistory {
|
|
|
329
460
|
this._didStream = false;
|
|
330
461
|
this._msgById = {};
|
|
331
462
|
this._msgLineCount = {};
|
|
332
|
-
this._toolExpanded = {};
|
|
333
|
-
this._lastExpandableId = null;
|
|
334
463
|
this._messages = [];
|
|
464
|
+
this._md.reset();
|
|
465
|
+
// Drop any held detail band (it belongs to the cleared session)
|
|
466
|
+
// and erase it from the writer's detail region before the viewport wipe.
|
|
467
|
+
this._deferred = null;
|
|
468
|
+
this._setDetail([]);
|
|
335
469
|
// Wipe the terminal's visible scrollback and the saved scrollback buffer
|
|
336
470
|
// so the cleared state feels clean. Serialized through the writer so
|
|
337
471
|
// the clear can't land inside a pending scrollback burst, then redraw
|
|
@@ -344,18 +478,69 @@ class ChatHistory {
|
|
|
344
478
|
this._notifyLive();
|
|
345
479
|
}
|
|
346
480
|
|
|
347
|
-
//
|
|
348
|
-
//
|
|
349
|
-
//
|
|
350
|
-
//
|
|
351
|
-
//
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
481
|
+
// Render the chrome for a collapsed output-preview body (the Phase-5 DIM /
|
|
482
|
+
// subtle-indent lines + a static `… N more lines` hint) as an ARRAY of styled
|
|
483
|
+
// lines (no trailing newline each). This is the single source for the immediate
|
|
484
|
+
// addMessage commit and the deferred (live-band) preview. The preview is always
|
|
485
|
+
// collapsed and static — there is no expand affordance; full viewing is deferred
|
|
486
|
+
// to the transcript viewer.
|
|
487
|
+
_renderOutputPreviewLines(msg) {
|
|
488
|
+
// Fit to the width LESS the 5-col indent so indent+content stays within one
|
|
489
|
+
// physical row at the render width (the Phase-4 single-row invariant). Each
|
|
490
|
+
// emitted preview line is therefore exactly one physical row.
|
|
491
|
+
const preview = formatOutputPreview(msg.output, {
|
|
492
|
+
previewLines: msg.previewLines,
|
|
493
|
+
cols: Math.max(1, getCols() - 5),
|
|
494
|
+
});
|
|
495
|
+
// Flatten each fitted line to exactly one physical row, so the band's
|
|
496
|
+
// logical-line count == its physical-row count (the load-bearing invariant the
|
|
497
|
+
// writer's erase/commit math depends on). Without this a TAB-bearing line
|
|
498
|
+
// counts as 1 here but wraps to ≥2 rows on screen, desyncing the writer's
|
|
499
|
+
// physicalRows erase math → duplication on wrapping output. No-op on plain
|
|
500
|
+
// ASCII, so clean output stays byte-identical. Applied here (the single source
|
|
501
|
+
// for the live band, the immediate/committed scrollback, and replay) so every
|
|
502
|
+
// render path is byte-consistent.
|
|
503
|
+
const bodyLines = preview.lines.map(_flattenBandLine);
|
|
504
|
+
const lines = [];
|
|
505
|
+
for (const ol of bodyLines) {
|
|
506
|
+
lines.push(`${c(DIM)} ${c(UI_THEME.subtle)}${ol}${c(RST)}`);
|
|
507
|
+
}
|
|
508
|
+
if (preview.hiddenCount > 0) {
|
|
509
|
+
const plural = preview.hiddenCount === 1 ? 'line' : 'lines';
|
|
510
|
+
lines.push(`${c(DIM)} … ${preview.hiddenCount} more ${plural}${c(RST)}`);
|
|
511
|
+
}
|
|
512
|
+
return { lines, truncatable: preview.truncatable };
|
|
513
|
+
}
|
|
514
|
+
|
|
515
|
+
// DEFER the output-preview detail into the writer's redrawable detail band
|
|
516
|
+
// instead of committing it straight to immutable scrollback. It stays in the
|
|
517
|
+
// band until a turn boundary commits it once. The collapsed lines are rendered
|
|
518
|
+
// once here and committed verbatim — the band is installed once and committed
|
|
519
|
+
// once, never redrawn in place. Only the output-preview detail is deferred —
|
|
520
|
+
// the result line (endActivity) and the diff (scrollback) still commit
|
|
521
|
+
// immediately in chat-turn.js, unchanged.
|
|
522
|
+
deferToolOutput(msg) {
|
|
523
|
+
// Single slot: a new preview commits any previously-held one first (the
|
|
524
|
+
// next-op boundary normally already did this; belt-and-suspenders).
|
|
525
|
+
if (this._deferred) this.commitDeferredDetail();
|
|
526
|
+
const { lines } = this._renderOutputPreviewLines(msg);
|
|
527
|
+
this._deferred = { msg, lines };
|
|
528
|
+
this._setDetail(lines);
|
|
529
|
+
this._notifyLive();
|
|
530
|
+
}
|
|
531
|
+
|
|
532
|
+
// Commit the held detail band to scrollback and clear the slot, atomically via
|
|
533
|
+
// the writer (no duplicate frame, no stranded rows). No-op when nothing is held.
|
|
534
|
+
// Called at each turn boundary (next-op start, assistant answer, turn end) and
|
|
535
|
+
// auto-fired before any new scrollback this class emits, so ordering is always
|
|
536
|
+
// preserved. The held collapsed lines are committed verbatim — they already
|
|
537
|
+
// carry no interactive affordance.
|
|
538
|
+
commitDeferredDetail() {
|
|
539
|
+
if (!this._deferred) return;
|
|
540
|
+
const lines = this._deferred.lines;
|
|
541
|
+
const text = lines && lines.length ? lines.join('\n') + '\n' : '';
|
|
542
|
+
this._deferred = null;
|
|
543
|
+
this._commitDetail(text);
|
|
359
544
|
}
|
|
360
545
|
|
|
361
546
|
rerenderById(id) {
|
|
@@ -365,17 +550,14 @@ class ChatHistory {
|
|
|
365
550
|
}
|
|
366
551
|
|
|
367
552
|
collapseById(id) {
|
|
368
|
-
// Nothing to erase in the append-only model — drop the tracking
|
|
369
|
-
// future expand toggle doesn't target the stale entry.
|
|
553
|
+
// Nothing to erase in the append-only model — drop the tracking.
|
|
370
554
|
delete this._msgById[id];
|
|
371
555
|
delete this._msgLineCount[id];
|
|
372
|
-
if (this._lastExpandableId === id) this._lastExpandableId = null;
|
|
373
556
|
}
|
|
374
557
|
|
|
375
558
|
removeById(id) {
|
|
376
559
|
delete this._msgById[id];
|
|
377
560
|
delete this._msgLineCount[id];
|
|
378
|
-
if (this._lastExpandableId === id) this._lastExpandableId = null;
|
|
379
561
|
}
|
|
380
562
|
|
|
381
563
|
invalidateCache() {}
|
package/lib/ui/create-ui.js
CHANGED
|
@@ -5,8 +5,15 @@ const readline = require('readline');
|
|
|
5
5
|
const { RST, FG_YELLOW, FG_CYAN, FG_GRAY } = require('./ansi');
|
|
6
6
|
const { ChatHistory } = require('./chat-history');
|
|
7
7
|
const writer = require('./writer');
|
|
8
|
+
const { getCols } = require('./utils');
|
|
9
|
+
const { wrapPromptLines } = require('./format');
|
|
8
10
|
const { registerTerminalCleanup } = require('./terminal');
|
|
9
11
|
|
|
12
|
+
// Cap a long ask_user question to this many wrapped lines in the modal header
|
|
13
|
+
// (mirrors the permission picker's MAX_DESC_LINES in permissions.js) so it can
|
|
14
|
+
// never overflow the live modal band; the rest collapses to "… N more lines".
|
|
15
|
+
const MAX_PROMPT_LINES = 12;
|
|
16
|
+
|
|
10
17
|
function _createNoOpUI() {
|
|
11
18
|
const chatHistory = {
|
|
12
19
|
addMessage: (msg) => {
|
|
@@ -19,6 +26,10 @@ function _createNoOpUI() {
|
|
|
19
26
|
clearStreamingContent: () => {},
|
|
20
27
|
finalizeLastMessage: () => {},
|
|
21
28
|
clearMessages: () => {},
|
|
29
|
+
// Phase 7b — no live region in the non-TTY no-op UI, so the deferred detail
|
|
30
|
+
// band is inert; the output preview just never displays. Present so the
|
|
31
|
+
// chat-turn boundary calls are safe in a non-TTY interactive run.
|
|
32
|
+
deferToolOutput: () => {}, commitDeferredDetail: () => {},
|
|
22
33
|
scrollUp: () => {}, scrollDown: () => {},
|
|
23
34
|
rerenderById: () => {},
|
|
24
35
|
removeById: () => {},
|
|
@@ -62,7 +73,6 @@ function createUI(opts) {
|
|
|
62
73
|
const { LayoutManager } = require('./layout');
|
|
63
74
|
const { FullStatusBar } = require('./status-bar');
|
|
64
75
|
const { InputField } = require('./input-field');
|
|
65
|
-
const { interactiveSelect } = require('./select');
|
|
66
76
|
|
|
67
77
|
const layout = new LayoutManager();
|
|
68
78
|
const chatHistory = new ChatHistory();
|
|
@@ -153,34 +163,67 @@ function createUI(opts) {
|
|
|
153
163
|
|
|
154
164
|
// ── captureSelect (modal menu) ───────────────────────────────────────────────
|
|
155
165
|
//
|
|
156
|
-
//
|
|
157
|
-
//
|
|
158
|
-
//
|
|
159
|
-
//
|
|
160
|
-
//
|
|
166
|
+
// ask_user-PRIVATE numbered-options picker (the model/rewind/permission
|
|
167
|
+
// pickers call interactiveSelect directly — this wrapper is NOT on that path).
|
|
168
|
+
// It manages its OWN modal frame (mirroring permissions.js requestPermission)
|
|
169
|
+
// rather than going through interactiveSelect, because it prepends the QUESTION
|
|
170
|
+
// as NON-SELECTABLE header rows above the options: navigation only ever cycles
|
|
171
|
+
// the options, so the prompt rows can never be landed on or returned. The frame
|
|
172
|
+
// redraws in place in the writer's modal region (above status, below
|
|
173
|
+
// scrollback) and keys route through the input field's captureNavigation API,
|
|
174
|
+
// so the menu cohabits with the live region instead of taking over the screen.
|
|
175
|
+
//
|
|
176
|
+
// `menu` = { prompt, options }. `prompt` (the question prose, already stripped
|
|
177
|
+
// of the numbered options by the executor) is word-wrapped to terminal width
|
|
178
|
+
// and clamped to MAX_PROMPT_LINES so a long question can't overflow the band.
|
|
161
179
|
inputField.captureSelect = (menu) => new Promise((resolve) => {
|
|
180
|
+
const options = (menu && Array.isArray(menu.options)) ? menu.options : [];
|
|
162
181
|
if (!process.stdin.isTTY) {
|
|
163
|
-
resolve(
|
|
182
|
+
resolve(options[0]);
|
|
164
183
|
return;
|
|
165
184
|
}
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
185
|
+
if (options.length === 0) {
|
|
186
|
+
resolve(null);
|
|
187
|
+
return;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// Wrap + clamp the question into non-selectable header rows.
|
|
191
|
+
const promptLines = wrapPromptLines(menu && menu.prompt, {
|
|
192
|
+
cols: Math.max(20, getCols() - 4),
|
|
193
|
+
maxLines: MAX_PROMPT_LINES,
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
let idx = 0;
|
|
197
|
+
const buildLines = () => {
|
|
198
|
+
const lines = [];
|
|
199
|
+
for (const p of promptLines) lines.push(` ${FG_GRAY}${p}${RST}`);
|
|
200
|
+
if (promptLines.length) lines.push('');
|
|
201
|
+
for (let i = 0; i < options.length; i++) {
|
|
202
|
+
lines.push(i === idx
|
|
203
|
+
? ` ${FG_YELLOW}❯${RST} ${FG_CYAN}${options[i]}${RST}`
|
|
204
|
+
: ` ${FG_GRAY}${options[i]}${RST}`);
|
|
205
|
+
}
|
|
206
|
+
return lines;
|
|
207
|
+
};
|
|
208
|
+
|
|
209
|
+
writer.setModal(buildLines());
|
|
210
|
+
inputField.captureNavigation((action) => {
|
|
211
|
+
if (action === 'prev') {
|
|
212
|
+
idx = (idx - 1 + options.length) % options.length;
|
|
213
|
+
writer.setModal(buildLines());
|
|
214
|
+
} else if (action === 'next') {
|
|
215
|
+
idx = (idx + 1) % options.length;
|
|
216
|
+
writer.setModal(buildLines());
|
|
217
|
+
} else if (action === 'select' || action === 'cancel') {
|
|
218
|
+
// Order matters (mirrors interactiveSelect): clearModal first while the
|
|
219
|
+
// caret is still suppressed, then releaseNavigation so the host's render
|
|
220
|
+
// hooks restore the input caret on the next setLive frame.
|
|
221
|
+
writer.clearModal();
|
|
222
|
+
inputField.releaseNavigation();
|
|
223
|
+
// Cancel → last option (typically "No"/decline) so callers don't need to
|
|
224
|
+
// special-case cancellation — matches the prior contract.
|
|
225
|
+
resolve(action === 'select' ? options[idx] : options[options.length - 1]);
|
|
178
226
|
}
|
|
179
|
-
).then((idx) => {
|
|
180
|
-
// Cancel returns null. Match the prior contract: pick the last
|
|
181
|
-
// option (typically "No"/decline) so callers don't need to
|
|
182
|
-
// special-case cancellation.
|
|
183
|
-
resolve(idx === null ? menu.options[menu.options.length - 1] : menu.options[idx]);
|
|
184
227
|
});
|
|
185
228
|
});
|
|
186
229
|
|