@semalt-ai/code 1.8.0 → 1.8.3
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 +14 -1
- package/CLAUDE.md +2 -1
- package/index.js +15 -1
- package/lib/agent.js +607 -77
- package/lib/api.js +240 -23
- package/lib/commands.js +105 -81
- package/lib/config.js +32 -4
- package/lib/constants.js +67 -1
- package/lib/metrics.js +16 -3
- package/lib/permissions.js +66 -67
- package/lib/prompts.js +97 -83
- package/lib/tool_specs.js +499 -0
- package/lib/tools.js +645 -319
- package/lib/ui/ansi.js +17 -4
- package/lib/ui/chat-history.js +201 -61
- package/lib/ui/create-ui.js +116 -373
- package/lib/ui/diff.js +87 -75
- package/lib/ui/input-field.js +76 -58
- package/lib/ui/status-bar.js +56 -25
- package/lib/ui/terminal.js +58 -0
- package/lib/ui/theme.js +78 -0
- package/lib/ui/utils.js +63 -1
- package/lib/ui/writer.js +255 -0
- package/lib/ui.js +5 -0
- package/package.json +1 -1
package/lib/ui/ansi.js
CHANGED
|
@@ -3,6 +3,17 @@
|
|
|
3
3
|
const RST = '\x1b[0m';
|
|
4
4
|
const BOLD = '\x1b[1m';
|
|
5
5
|
const DIM = '\x1b[2m';
|
|
6
|
+
const EL = '\x1b[K'; // erase-to-end-of-line using current bg
|
|
7
|
+
|
|
8
|
+
function bg256(n) { return `\x1b[48;5;${n}m`; }
|
|
9
|
+
function fg256(n) { return `\x1b[38;5;${n}m`; }
|
|
10
|
+
function bgRGB(r, g, b) { return `\x1b[48;2;${r};${g};${b}m`; }
|
|
11
|
+
function fgRGB(r, g, b) { return `\x1b[38;2;${r};${g};${b}m`; }
|
|
12
|
+
|
|
13
|
+
function hasTruecolor() {
|
|
14
|
+
const v = process.env.COLORTERM;
|
|
15
|
+
return v === 'truecolor' || v === '24bit';
|
|
16
|
+
}
|
|
6
17
|
|
|
7
18
|
const THEME = {
|
|
8
19
|
user: '\x1b[36m',
|
|
@@ -50,13 +61,15 @@ const KEYWORDS = new Set([
|
|
|
50
61
|
]);
|
|
51
62
|
|
|
52
63
|
const SPINNER_DEFS = {
|
|
53
|
-
thinking:
|
|
54
|
-
streaming:
|
|
55
|
-
tool:
|
|
64
|
+
thinking: { frames: ['⠋','⠙','⠹','⠸','⠼','⠴','⠦','⠧','⠇','⠏'], color: '\x1b[36m' },
|
|
65
|
+
streaming: { frames: ['▁','▂','▃','▄','▅','▆','▇','█','▇','▆','▅','▄','▃','▂'], color: '\x1b[32m' },
|
|
66
|
+
tool: { frames: ['⣾','⣽','⣻','⢿','⡿','⣟','⣯','⣷'], color: '\x1b[33m' },
|
|
67
|
+
waiting_download: { frames: ['⬇ ','⬇⠂','⬇⠆','⬇⠇','⬇⠧','⬇⠷','⬇⠿','⬇⠾','⬇⠼','⬇⠸','⬇⠰','⬇⠠'], color: '\x1b[38;5;75m' },
|
|
56
68
|
};
|
|
57
69
|
|
|
58
70
|
module.exports = {
|
|
59
|
-
RST, BOLD, DIM, THEME,
|
|
71
|
+
RST, BOLD, DIM, EL, THEME,
|
|
72
|
+
bg256, fg256, bgRGB, fgRGB, hasTruecolor,
|
|
60
73
|
FG_GRAY, FG_DARK, FG_BLUE, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_TEAL,
|
|
61
74
|
BOX_H, BOX_V, BOX_TL, BOX_TR, BOX_BL, BOX_BR,
|
|
62
75
|
FG_CODE_BG, BG_SELECTED, FG_CODE_BORDER, FG_CODE_LANG, FG_TAG, FG_FILEPATH,
|
package/lib/ui/chat-history.js
CHANGED
|
@@ -2,6 +2,8 @@
|
|
|
2
2
|
|
|
3
3
|
const { RST, DIM, BOLD, FG_CYAN, FG_GREEN, FG_YELLOW, FG_RED, FG_DARK, FG_GRAY } = require('./ansi');
|
|
4
4
|
const { getCols, stripAnsi } = require('./utils');
|
|
5
|
+
const { UI_THEME, UI_ICONS, TOOL_CATEGORIES } = require('./theme');
|
|
6
|
+
const writer = require('./writer');
|
|
5
7
|
|
|
6
8
|
|
|
7
9
|
function safeContent(text) {
|
|
@@ -9,6 +11,8 @@ function safeContent(text) {
|
|
|
9
11
|
return text.replace(/<\/?[a-zA-Z_][a-zA-Z0-9_]*(\s[^>]*)?>/g, '');
|
|
10
12
|
}
|
|
11
13
|
|
|
14
|
+
// Legacy per-tag icons kept as a fallback only. New chrome uses a single
|
|
15
|
+
// status glyph (✓/✗/●) picked by outcome, not by tool kind.
|
|
12
16
|
const TOOL_ICONS = {
|
|
13
17
|
exec: '▶', shell: '▶', run_command: '▶', run: '▶',
|
|
14
18
|
read_file: '[R]', write_file: '✎',
|
|
@@ -18,6 +22,24 @@ const TOOL_ICONS = {
|
|
|
18
22
|
store_memory: '◆', recall_memory: '◆', list_memories: '◆',
|
|
19
23
|
};
|
|
20
24
|
|
|
25
|
+
// Dim any path/URL-like substring so it recedes when scanning a list of
|
|
26
|
+
// tool operations.
|
|
27
|
+
function _dimPaths(text) {
|
|
28
|
+
return text.replace(/(\S*\/\S+)/g, (m) => {
|
|
29
|
+
const trail = m.match(/[`"'),.;:!?\]]+$/);
|
|
30
|
+
if (trail) {
|
|
31
|
+
const core = m.slice(0, -trail[0].length);
|
|
32
|
+
if (!core) return m;
|
|
33
|
+
return `${UI_THEME.subtle}${core}${UI_THEME.default}${trail[0]}`;
|
|
34
|
+
}
|
|
35
|
+
return `${UI_THEME.subtle}${m}${UI_THEME.default}`;
|
|
36
|
+
});
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function _isAutoApproveMeta(content) {
|
|
40
|
+
return /auto-approv/i.test(content);
|
|
41
|
+
}
|
|
42
|
+
|
|
21
43
|
function _fmtTime(ts) {
|
|
22
44
|
const d = ts instanceof Date ? ts : (ts ? new Date(ts) : new Date());
|
|
23
45
|
return `${String(d.getHours()).padStart(2, '0')}:${String(d.getMinutes()).padStart(2, '0')}`;
|
|
@@ -25,47 +47,68 @@ function _fmtTime(ts) {
|
|
|
25
47
|
|
|
26
48
|
const BG_USER = '\x1b[48;5;237m';
|
|
27
49
|
|
|
28
|
-
|
|
50
|
+
// Build renderers return a single string ending with \n (or empty). The
|
|
51
|
+
// caller commits it to scrollback in one write so nothing interleaves mid-
|
|
52
|
+
// bubble — the whole user/assistant/tool block reaches the terminal as one
|
|
53
|
+
// atomic chunk.
|
|
54
|
+
function _buildUser(content, ts) {
|
|
29
55
|
const time = _fmtTime(ts);
|
|
30
|
-
|
|
56
|
+
let out = `\n${FG_CYAN}▸ You${RST} ${DIM}${time}${RST}\n`;
|
|
31
57
|
for (const line of (content || '').split('\n')) {
|
|
32
|
-
|
|
58
|
+
out += `${BG_USER} ${line}\x1b[K${RST}\n`;
|
|
33
59
|
}
|
|
34
|
-
|
|
60
|
+
out += '\n';
|
|
61
|
+
return out;
|
|
35
62
|
}
|
|
36
63
|
|
|
37
|
-
function
|
|
64
|
+
function _buildAI(content, ts) {
|
|
38
65
|
const time = _fmtTime(ts);
|
|
39
|
-
|
|
66
|
+
let out = `\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`;
|
|
40
67
|
for (const line of (content || '').split('\n')) {
|
|
41
|
-
|
|
68
|
+
out += ` ${line}\n`;
|
|
42
69
|
}
|
|
43
|
-
|
|
70
|
+
out += '\n';
|
|
71
|
+
return out;
|
|
44
72
|
}
|
|
45
73
|
|
|
46
74
|
const MAX_TOOL_DISPLAY = 15;
|
|
47
75
|
|
|
48
76
|
class ChatHistory {
|
|
49
77
|
constructor() {
|
|
50
|
-
this.
|
|
78
|
+
this._streamActive = false;
|
|
79
|
+
this._streamPartial = '';
|
|
51
80
|
this._streamStart = null;
|
|
81
|
+
this._didStream = false;
|
|
52
82
|
this._msgById = {};
|
|
53
83
|
this._msgLineCount = {};
|
|
54
|
-
this._pendingWs = '';
|
|
55
|
-
this._didStream = false;
|
|
56
84
|
this._toolExpanded = {};
|
|
57
85
|
this._lastExpandableId = null;
|
|
58
86
|
this._toolIdCounter = 0;
|
|
59
87
|
this._messages = [];
|
|
88
|
+
// Callback into the UI orchestrator to refresh the live region (so the
|
|
89
|
+
// in-progress streaming line and chrome re-render together). Set by
|
|
90
|
+
// createUI; safe to leave null for headless/test contexts.
|
|
91
|
+
this._onLiveUpdate = null;
|
|
60
92
|
}
|
|
61
93
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
94
|
+
// Return the in-progress streaming line as it should appear inside the
|
|
95
|
+
// live region. Null when no stream is active or the partial is empty —
|
|
96
|
+
// the live composer then omits the streaming row entirely.
|
|
97
|
+
getStreamingLiveLine() {
|
|
98
|
+
if (!this._streamActive) return null;
|
|
99
|
+
if (!this._streamPartial) return null;
|
|
100
|
+
// Render leading-whitespace-only partials as an empty space so the
|
|
101
|
+
// cursor sits on a visible row, not a bare \n.
|
|
102
|
+
return ' ' + this._streamPartial.replace(/\n/g, ' ');
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
isStreaming() { return this._streamActive; }
|
|
106
|
+
|
|
107
|
+
_commit(text) { writer.scrollback(text); }
|
|
108
|
+
|
|
109
|
+
_notifyLive() {
|
|
110
|
+
if (this._onLiveUpdate) {
|
|
111
|
+
try { this._onLiveUpdate(); } catch {}
|
|
69
112
|
}
|
|
70
113
|
}
|
|
71
114
|
|
|
@@ -74,36 +117,45 @@ class ChatHistory {
|
|
|
74
117
|
const content = safeContent(msg.content || '');
|
|
75
118
|
if ((msg.role === 'assistant' || msg.role === 'system') && !content.trim()) return;
|
|
76
119
|
|
|
77
|
-
|
|
120
|
+
// Any in-progress stream must close before we emit a new scrollback
|
|
121
|
+
// block — otherwise the stream's partial line would sit between the
|
|
122
|
+
// header and body of the new bubble.
|
|
123
|
+
this._flushStream();
|
|
78
124
|
|
|
79
125
|
if (msg.id) this._msgById[msg.id] = msg;
|
|
80
126
|
|
|
127
|
+
let out = '';
|
|
128
|
+
let lineCount = 0;
|
|
129
|
+
|
|
81
130
|
if (msg.role === 'user') {
|
|
82
|
-
|
|
131
|
+
out = _buildUser(content, msg.ts);
|
|
83
132
|
} else if (msg.role === 'assistant') {
|
|
84
|
-
|
|
133
|
+
out = _buildAI(content, msg.ts);
|
|
85
134
|
} else if (msg.role === 'shell') {
|
|
86
135
|
const cmd = msg.cmd || '';
|
|
87
|
-
const
|
|
88
|
-
|
|
89
|
-
if (
|
|
90
|
-
for (const line of
|
|
91
|
-
if (line.trim())
|
|
136
|
+
const shellOut = safeContent(msg.content || '');
|
|
137
|
+
out = `\n${DIM} $ ${cmd}${RST}\n`;
|
|
138
|
+
if (shellOut.trim()) {
|
|
139
|
+
for (const line of shellOut.split('\n')) {
|
|
140
|
+
if (line.trim()) out += `${DIM} ${line}${RST}\n`;
|
|
92
141
|
}
|
|
93
142
|
}
|
|
94
|
-
|
|
143
|
+
out += '\n';
|
|
95
144
|
} else if (msg.role === 'tool') {
|
|
96
|
-
const
|
|
97
|
-
|
|
98
|
-
|
|
145
|
+
const indicator = msg.isError
|
|
146
|
+
? `${UI_THEME.error}${UI_ICONS.error}${RST}`
|
|
147
|
+
: `${UI_THEME.success}${UI_ICONS.success}${RST}`;
|
|
148
|
+
const category = TOOL_CATEGORIES[msg.tag] || msg.tag || 'tool';
|
|
149
|
+
const head = `${UI_THEME.accent}${category}${UI_THEME.muted}:${RST}`;
|
|
150
|
+
const desc = _dimPaths(content);
|
|
151
|
+
out = ` ${indicator} ${head} ${desc}\n`;
|
|
152
|
+
lineCount = 1;
|
|
99
153
|
if (msg.output) {
|
|
100
|
-
// Wrap long lines so char-heavy single-line output (e.g. minified HTML)
|
|
101
|
-
// is treated the same as multi-line output for truncation purposes.
|
|
102
154
|
const wrapAt = Math.max(60, getCols() - 8);
|
|
103
155
|
const outLines = [];
|
|
104
156
|
for (const raw of msg.output.split('\n')) {
|
|
105
157
|
if (!raw.trim()) continue;
|
|
106
|
-
if (raw.length <= wrapAt) {
|
|
158
|
+
if (msg.tag === 'debug' || raw.length <= wrapAt) {
|
|
107
159
|
outLines.push(raw);
|
|
108
160
|
} else {
|
|
109
161
|
for (let i = 0; i < raw.length; i += wrapAt) {
|
|
@@ -125,70 +177,149 @@ class ChatHistory {
|
|
|
125
177
|
const visible = (truncatable && !expanded) ? outLines.slice(0, MAX_TOOL_DISPLAY) : outLines;
|
|
126
178
|
const remaining = outLines.length - visible.length;
|
|
127
179
|
|
|
128
|
-
|
|
180
|
+
const outerOpen = msg.tag === 'debug' ? ' ' : `${DIM} `;
|
|
181
|
+
const outerClose = msg.tag === 'debug' ? '' : RST;
|
|
182
|
+
for (const ol of visible) { out += `${outerOpen}${ol}${outerClose}\n`; lineCount++; }
|
|
129
183
|
if (remaining > 0) {
|
|
130
|
-
|
|
131
|
-
|
|
184
|
+
out += `${DIM} … ${remaining} more lines ${FG_DARK}(ctrl+o to expand ${remaining} rows)${RST}\n`;
|
|
185
|
+
lineCount++;
|
|
132
186
|
} else if (truncatable && expanded) {
|
|
133
|
-
|
|
134
|
-
|
|
187
|
+
out += `${DIM} ${FG_DARK}(ctrl+o to collapse)${RST}\n`;
|
|
188
|
+
lineCount++;
|
|
135
189
|
}
|
|
136
190
|
}
|
|
137
|
-
if (msg.id) this._msgLineCount[msg.id] = lc;
|
|
138
191
|
} else if (msg.role === 'permission') {
|
|
139
|
-
|
|
192
|
+
out = `\n ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}Permission required${RST}${UI_THEME.muted}:${RST} ${content}\n`;
|
|
140
193
|
} else {
|
|
141
|
-
|
|
142
|
-
const color = isErr ? FG_RED : FG_YELLOW;
|
|
143
|
-
const prefix = isErr ? '✕' : '⚠';
|
|
194
|
+
// Fallback for role: 'system' and anything unrecognised.
|
|
144
195
|
const lines = content.split('\n');
|
|
145
|
-
|
|
196
|
+
const first = lines[0] || '';
|
|
197
|
+
const stripGlyph = (s) => s.replace(/^[✓✗✕⚠]\s*/, '');
|
|
198
|
+
let rendered;
|
|
199
|
+
if (msg.isError && !msg.isWarning) {
|
|
200
|
+
rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${stripGlyph(first)}${RST}`;
|
|
201
|
+
} else if (msg.isWarning) {
|
|
202
|
+
rendered = ` ${UI_THEME.warning}${UI_ICONS.warn}${RST} ${UI_THEME.warning}${stripGlyph(first)}${RST}`;
|
|
203
|
+
} else if (_isAutoApproveMeta(first)) {
|
|
204
|
+
const body = first.replace(/(auto-approv\w*)/gi, (m) => `${UI_THEME.warning}${m}${UI_THEME.subtle}`);
|
|
205
|
+
rendered = ` ${UI_THEME.subtle}${body}${RST}`;
|
|
206
|
+
} else if (first.startsWith('✓')) {
|
|
207
|
+
rendered = ` ${UI_THEME.success}${UI_ICONS.success}${RST} ${first.slice(1).trimStart()}`;
|
|
208
|
+
} else if (first.startsWith('✗')) {
|
|
209
|
+
rendered = ` ${UI_THEME.error}${UI_ICONS.error}${RST} ${UI_THEME.error}${first.slice(1).trimStart()}${RST}`;
|
|
210
|
+
} else {
|
|
211
|
+
rendered = ` ${UI_THEME.subtle}${UI_ICONS.bullet}${RST} ${UI_THEME.subtle}${first}${RST}`;
|
|
212
|
+
}
|
|
213
|
+
out = `${rendered}\n`;
|
|
214
|
+
const contColor = (msg.isError && !msg.isWarning) ? UI_THEME.error
|
|
215
|
+
: msg.isWarning ? UI_THEME.warning
|
|
216
|
+
: UI_THEME.subtle;
|
|
146
217
|
for (let i = 1; i < lines.length; i++) {
|
|
147
|
-
|
|
218
|
+
out += ` ${contColor}${lines[i]}${RST}\n`;
|
|
148
219
|
}
|
|
149
|
-
|
|
220
|
+
lineCount = lines.length;
|
|
150
221
|
}
|
|
222
|
+
|
|
223
|
+
if (out) this._commit(out);
|
|
224
|
+
if (msg.id && lineCount > 0) this._msgLineCount[msg.id] = lineCount;
|
|
225
|
+
this._messages.push(msg);
|
|
226
|
+
this._notifyLive();
|
|
151
227
|
}
|
|
152
228
|
|
|
229
|
+
// Streaming path. Accumulates tokens until a newline, then commits each
|
|
230
|
+
// complete line to scrollback. The unfinished partial line is exposed via
|
|
231
|
+
// getStreamingLiveLine() so the UI composer can show it at the top of
|
|
232
|
+
// the live region while it's still in-flight.
|
|
153
233
|
streamToken(token) {
|
|
154
|
-
if (!
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
234
|
+
if (!token) return;
|
|
235
|
+
if (!this._streamActive) {
|
|
236
|
+
// Emit header immediately so users see the new AI turn before any
|
|
237
|
+
// tokens arrive. No blank line before the header since scrollback
|
|
238
|
+
// flows continuously.
|
|
159
239
|
const time = _fmtTime(new Date());
|
|
160
|
-
|
|
161
|
-
this.
|
|
240
|
+
this._commit(`\n${FG_GREEN}▸ AI-agent${RST} ${DIM}${time}${RST}\n`);
|
|
241
|
+
this._streamActive = true;
|
|
162
242
|
this._streamStart = new Date();
|
|
163
|
-
this.
|
|
243
|
+
this._streamPartial = '';
|
|
244
|
+
}
|
|
245
|
+
this._streamPartial += token;
|
|
246
|
+
let nlIdx;
|
|
247
|
+
while ((nlIdx = this._streamPartial.indexOf('\n')) >= 0) {
|
|
248
|
+
const line = this._streamPartial.slice(0, nlIdx);
|
|
249
|
+
this._commit(' ' + line + '\n');
|
|
250
|
+
this._streamPartial = this._streamPartial.slice(nlIdx + 1);
|
|
251
|
+
}
|
|
252
|
+
this._notifyLive();
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
_flushStream() {
|
|
256
|
+
if (!this._streamActive) return;
|
|
257
|
+
if (this._streamPartial !== '') {
|
|
258
|
+
this._commit(' ' + this._streamPartial + '\n');
|
|
259
|
+
this._streamPartial = '';
|
|
164
260
|
}
|
|
165
|
-
|
|
261
|
+
this._commit('\n');
|
|
262
|
+
this._streamActive = false;
|
|
263
|
+
this._streamStart = null;
|
|
264
|
+
this._didStream = true;
|
|
166
265
|
}
|
|
167
266
|
|
|
168
267
|
clearStreamingContent() {
|
|
169
|
-
this.
|
|
268
|
+
this._flushStream();
|
|
269
|
+
this._notifyLive();
|
|
170
270
|
}
|
|
171
271
|
|
|
172
272
|
finalizeLastMessage(cleanContent) {
|
|
173
|
-
|
|
174
|
-
|
|
273
|
+
const wasStreaming = this._streamActive;
|
|
274
|
+
const streamStart = this._streamStart;
|
|
275
|
+
if (wasStreaming) {
|
|
276
|
+
this._flushStream();
|
|
277
|
+
if (cleanContent && cleanContent.trim()) {
|
|
278
|
+
this._messages.push({
|
|
279
|
+
role: 'assistant',
|
|
280
|
+
content: cleanContent,
|
|
281
|
+
ts: streamStart || new Date(),
|
|
282
|
+
});
|
|
283
|
+
}
|
|
175
284
|
} else if (!this._didStream && cleanContent && cleanContent.trim()) {
|
|
176
|
-
|
|
285
|
+
// Non-streaming path — synthesise an assistant bubble from the final
|
|
286
|
+
// content.
|
|
287
|
+
this._commit(_buildAI(cleanContent, new Date()));
|
|
288
|
+
this._messages.push({
|
|
289
|
+
role: 'assistant',
|
|
290
|
+
content: cleanContent,
|
|
291
|
+
ts: new Date(),
|
|
292
|
+
});
|
|
177
293
|
}
|
|
178
294
|
this._didStream = false;
|
|
295
|
+
this._notifyLive();
|
|
179
296
|
}
|
|
180
297
|
|
|
181
298
|
clearMessages() {
|
|
182
|
-
this.
|
|
183
|
-
this.
|
|
299
|
+
this._streamActive = false;
|
|
300
|
+
this._streamPartial = '';
|
|
184
301
|
this._didStream = false;
|
|
185
302
|
this._msgById = {};
|
|
186
303
|
this._msgLineCount = {};
|
|
187
304
|
this._toolExpanded = {};
|
|
188
305
|
this._lastExpandableId = null;
|
|
189
306
|
this._messages = [];
|
|
307
|
+
// Wipe the terminal's visible scrollback and the saved scrollback buffer
|
|
308
|
+
// so the cleared state feels clean. Serialized through the writer so
|
|
309
|
+
// the clear can't land inside a pending scrollback burst, then redraw
|
|
310
|
+
// the live region under the new cursor.
|
|
311
|
+
writer.enqueue(() => {
|
|
312
|
+
try { process.stdout.write('\x1b[3J\x1b[2J\x1b[H'); } catch {}
|
|
313
|
+
});
|
|
314
|
+
writer.redrawLive();
|
|
315
|
+
this._notifyLive();
|
|
190
316
|
}
|
|
191
317
|
|
|
318
|
+
// In append-only scrollback we cannot rewrite a previous bubble in place;
|
|
319
|
+
// these methods now simply re-render the updated version as new
|
|
320
|
+
// scrollback. Expand/collapse prints the full (or summary) view below the
|
|
321
|
+
// prior truncated view. This is a small visual regression from the old
|
|
322
|
+
// scroll-region mode in exchange for eliminating the torn-frame race.
|
|
192
323
|
toggleLastExpand() {
|
|
193
324
|
const id = this._lastExpandableId;
|
|
194
325
|
if (!id) return;
|
|
@@ -204,9 +335,18 @@ class ChatHistory {
|
|
|
204
335
|
this.addMessage(msg);
|
|
205
336
|
}
|
|
206
337
|
|
|
338
|
+
collapseById(id) {
|
|
339
|
+
// Nothing to erase in the append-only model — drop the tracking so a
|
|
340
|
+
// future expand toggle doesn't target the stale entry.
|
|
341
|
+
delete this._msgById[id];
|
|
342
|
+
delete this._msgLineCount[id];
|
|
343
|
+
if (this._lastExpandableId === id) this._lastExpandableId = null;
|
|
344
|
+
}
|
|
345
|
+
|
|
207
346
|
removeById(id) {
|
|
208
347
|
delete this._msgById[id];
|
|
209
348
|
delete this._msgLineCount[id];
|
|
349
|
+
if (this._lastExpandableId === id) this._lastExpandableId = null;
|
|
210
350
|
}
|
|
211
351
|
|
|
212
352
|
invalidateCache() {}
|