@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
|
@@ -10,7 +10,11 @@
|
|
|
10
10
|
// session ctx — those callbacks use only the per-tool fields, never session state.
|
|
11
11
|
|
|
12
12
|
const { resolveMaxIterations } = require('../config');
|
|
13
|
-
const { createWebActivityTracker } = require('../ui/web-activity');
|
|
13
|
+
const { createWebActivityTracker, serializeWebOp } = require('../ui/web-activity');
|
|
14
|
+
const { createFileActivityTracker } = require('../ui/file-activity');
|
|
15
|
+
const { buildToolOperation, serializeOperation } = require('../ui/tool-operation');
|
|
16
|
+
const { renderOperation } = require('../ui/render-operation');
|
|
17
|
+
const { normalizeCmdForDisplay } = require('../ui/format');
|
|
14
18
|
|
|
15
19
|
function createTurnHandler(ctx, slashHandlers) {
|
|
16
20
|
// The session ctx — the per-tool callbacks below intentionally shadow `ctx`
|
|
@@ -19,7 +23,7 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
19
23
|
const sessionCtx = ctx;
|
|
20
24
|
const {
|
|
21
25
|
inputField, statusBar, chatHistory, getConfig, approxTokens, resolveCommand,
|
|
22
|
-
runAgentLoop, opts, TAG_REGISTRY,
|
|
26
|
+
runAgentLoop, opts, TAG_REGISTRY, writerModule,
|
|
23
27
|
collapseListMsg, handlePendingSelection, showPendingStep, activateNavCapture, finalizeListMsg,
|
|
24
28
|
createChatIfNeeded, saveTurnToDashboard, saveSession,
|
|
25
29
|
} = ctx;
|
|
@@ -134,6 +138,30 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
134
138
|
// think block (Qwen3-style: plain text followed by </think>, no opening tag).
|
|
135
139
|
let implicitThinkPhase = !opts.showThink;
|
|
136
140
|
let implicitThinkBuffer = '';
|
|
141
|
+
// Live-narration safety signals for the NATIVE rail only (XML rail ignores
|
|
142
|
+
// all three and keeps the buffered-until-boundary behavior unchanged):
|
|
143
|
+
// • nativeRail — set from onStreamStart's first arg; true only
|
|
144
|
+
// when this stream is on the native tool-call rail.
|
|
145
|
+
// • reasoningSeen — signal (a): a delta.reasoning_content arrived this
|
|
146
|
+
// iteration, proving the model uses the structured
|
|
147
|
+
// reasoning channel → subsequent content is narration.
|
|
148
|
+
// • inlineReasoningFalse — signal (b): the active profile asserts
|
|
149
|
+
// inline_reasoning:false → never inlines reasoning.
|
|
150
|
+
// When nativeRail AND (reasoningSeen OR inlineReasoningFalse), onToken may
|
|
151
|
+
// eager-open the implicit-think gate and stream narration live (see below).
|
|
152
|
+
let nativeRail = false;
|
|
153
|
+
let reasoningSeen = false;
|
|
154
|
+
let inlineReasoningFalse = false;
|
|
155
|
+
// Orphan closing reasoning tag, emitted VERBATIM by the StreamParser: a lone
|
|
156
|
+
// </think> (or </reasoning>/</reflection>/</plan>) has no matching open tag, so
|
|
157
|
+
// the parser's closing form `/think` is not a TAG_REGISTRY key (the registry
|
|
158
|
+
// keys are the bare names) and it streams the literal `</tag>` through onToken
|
|
159
|
+
// (agent.js StreamParser: `if (!entry) this.onToken('<' + tagRaw + '>')`).
|
|
160
|
+
// MiniMax-style models emit reasoning via BOTH reasoning_content AND an inline
|
|
161
|
+
// </think> terminator in content; that stray tag must never reach the terminal.
|
|
162
|
+
// Persisted history is already clean (cleanAssistantContent strips it), so this
|
|
163
|
+
// guard is live-stream-only. Match exactly the raw-emitted closing shapes.
|
|
164
|
+
const ORPHAN_CLOSE_TAG_RE = /^<\/(think|reasoning|reflection|plan)>$/i;
|
|
137
165
|
|
|
138
166
|
// Web-activity collapse (Task W.3): in the default (non-debug) view, a run of
|
|
139
167
|
// consecutive web ops (web_search → http_get) renders as ONE process-summary
|
|
@@ -141,6 +169,14 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
141
169
|
// is bypassed and web ops render the normal per-op way (full detail).
|
|
142
170
|
const webTracker = createWebActivityTracker({ writerModule });
|
|
143
171
|
|
|
172
|
+
// File-activity collapse (parallel instance of the web tracker): in the
|
|
173
|
+
// default (non-debug) view, a run of consecutive same-type pure file reads
|
|
174
|
+
// (read_file / list_dir) collapses into ONE process-summary line instead of a
|
|
175
|
+
// per-op line each — unless the run is only 1–2 ops, which still commit as
|
|
176
|
+
// individual lines (decided at flush). read and list group SEPARATELY. Fresh
|
|
177
|
+
// per turn. In --debug the tracker is bypassed (full per-op detail).
|
|
178
|
+
const fileTracker = createFileActivityTracker({ writerModule });
|
|
179
|
+
|
|
144
180
|
const callbacks = {
|
|
145
181
|
onThinking: () => statusBar.update('thinking', 'Thinking...'),
|
|
146
182
|
onRequestSent: () => {
|
|
@@ -148,14 +184,56 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
148
184
|
// Reset think-phase detection for each new agent iteration.
|
|
149
185
|
implicitThinkPhase = !opts.showThink;
|
|
150
186
|
implicitThinkBuffer = '';
|
|
187
|
+
// Reset the live-narration safety signals alongside the gate — each API
|
|
188
|
+
// call re-establishes the rail and re-observes the reasoning channel.
|
|
189
|
+
nativeRail = false;
|
|
190
|
+
reasoningSeen = false;
|
|
191
|
+
inlineReasoningFalse = false;
|
|
151
192
|
},
|
|
152
|
-
onStreamStart: () => {
|
|
193
|
+
onStreamStart: (isNativeRail, inlineReasoning) => {
|
|
194
|
+
// Capture the rail + inline-reasoning assertion threaded from agent.js
|
|
195
|
+
// (signal b). Recorded BEFORE the first content token so onToken's
|
|
196
|
+
// eager-open check below sees them on the very first token.
|
|
197
|
+
nativeRail = !!isNativeRail;
|
|
198
|
+
inlineReasoningFalse = inlineReasoning === false;
|
|
153
199
|
// If showThink is on, switch to streaming immediately.
|
|
154
200
|
// Otherwise keep "Thinking…" until </think> is resolved.
|
|
155
201
|
if (opts.showThink) statusBar.update('streaming', 'Streaming response');
|
|
156
202
|
},
|
|
203
|
+
onReasoningStart: () => {
|
|
204
|
+
// Signal (a): the model emitted reasoning_content this iteration, so the
|
|
205
|
+
// structured reasoning channel is in use. Fires before any delta.content
|
|
206
|
+
// token, so the eager-open in onToken sees it for the first token.
|
|
207
|
+
reasoningSeen = true;
|
|
208
|
+
},
|
|
157
209
|
onTagOpen: (tag, attrs) => {
|
|
158
210
|
const entry = TAG_REGISTRY[tag];
|
|
211
|
+
// Positive-evidence early exit from the implicit-think gate (live narration).
|
|
212
|
+
// The gate buffers leading bare text because a Qwen3-style model emits implicit
|
|
213
|
+
// reasoning as bare text terminated by an orphan </think> — and that reasoning
|
|
214
|
+
// is indistinguishable from ordinary narration until the boundary arrives. So
|
|
215
|
+
// for a plain bare-text preamble we STAY buffered until </think> (handled in
|
|
216
|
+
// onToken); flipping early there could stream hidden reasoning = a leak.
|
|
217
|
+
// But two tag openings are positive PROOF the bare text is NOT implicit
|
|
218
|
+
// reasoning, so we can open the gate and start streaming live immediately:
|
|
219
|
+
// • a <think>/<reasoning>/<reflection>/<plan> tag (display:'think_bubble')
|
|
220
|
+
// means THIS model delimits reasoning with explicit tags; that inner
|
|
221
|
+
// content is consumed by the StreamParser and never reaches onToken (and is
|
|
222
|
+
// suppressed by handleTag when !showThink), so any bare text outside the
|
|
223
|
+
// tag is narration — safe to stream.
|
|
224
|
+
// • a <final_answer> (type:'final') streams its inner content THROUGH onToken
|
|
225
|
+
// (streamInner in the parser), so the gate must open or the answer is
|
|
226
|
+
// swallowed by the buffer.
|
|
227
|
+
// We deliberately do NOT exit on a tool tag: bare-reasoning-then-tool with no
|
|
228
|
+
// </think> is possible (malformed implicit-think), so opening there could leak.
|
|
229
|
+
// Any buffered leading text is DISCARDED here (treated as reasoning), never
|
|
230
|
+
// flushed — preserving implicit-think suppression.
|
|
231
|
+
if (!opts.showThink && implicitThinkPhase &&
|
|
232
|
+
(entry?.display === 'think_bubble' || entry?.type === 'final')) {
|
|
233
|
+
implicitThinkPhase = false;
|
|
234
|
+
implicitThinkBuffer = '';
|
|
235
|
+
statusBar.update('streaming', 'Streaming response');
|
|
236
|
+
}
|
|
159
237
|
if (entry?.type === 'tool') {
|
|
160
238
|
const actionLabel = entry.label || tag;
|
|
161
239
|
const detail = attrs.path || attrs.url || attrs.key || attrs.src || '';
|
|
@@ -176,13 +254,36 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
176
254
|
statusBar.update('streaming', 'Streaming response');
|
|
177
255
|
},
|
|
178
256
|
onPermissionAsk: (tag, input) => {
|
|
257
|
+
// Flush any open file/web activity group BEFORE the permission picker
|
|
258
|
+
// opens. The permission gate fires ahead of onToolStart (agent.js — the
|
|
259
|
+
// "ask before onToolStart" comment), so the non-groupable flush that
|
|
260
|
+
// onToolStart normally performs (below) is sequenced AFTER the modal and
|
|
261
|
+
// can't fire while it's open — leaving an open group stranded LIVE in the
|
|
262
|
+
// writer's activity region beside the prompt for the modal's whole life.
|
|
263
|
+
// Flush here so the group commits to scrollback ABOVE the prompt instead.
|
|
264
|
+
// This is safe to do unconditionally: groupable tools (read_file/list_dir)
|
|
265
|
+
// are read-only with a NULL permission descriptor, so onPermissionAsk
|
|
266
|
+
// NEVER fires for them — by the time we get here the prompting tool is by
|
|
267
|
+
// definition non-groupable, exactly the case onToolStart already flushes.
|
|
268
|
+
// flush() is idempotent (isOpen()/groupId===null guard), so the later
|
|
269
|
+
// onToolStart flush, the turn-end finally flush, or the deny path all
|
|
270
|
+
// become no-ops — no double commit. Covers the deny case too: a denied
|
|
271
|
+
// tool's group is committed here rather than stranded until the finally.
|
|
272
|
+
if (webTracker.isOpen()) webTracker.flush();
|
|
273
|
+
if (fileTracker.isOpen()) fileTracker.flush();
|
|
179
274
|
// Status-bar update fires while the permission picker is open so
|
|
180
275
|
// the user can see what's pending in the side label, not just
|
|
181
276
|
// inside the modal. Mirrors the labels onToolStart uses post-grant
|
|
182
277
|
// — the next streaming/idle state will overwrite this when the
|
|
183
278
|
// picker closes (whether granted or denied).
|
|
184
279
|
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
185
|
-
|
|
280
|
+
// Flatten embedded newlines/tabs (e.g. heredoc commands) BEFORE the
|
|
281
|
+
// slice so the status label is a single physical row. A raw slice of
|
|
282
|
+
// multi-line input rides a \n into the status string → the live region
|
|
283
|
+
// mis-counts rows (1 logical line spanning 2+ physical rows) and leaks
|
|
284
|
+
// stale rules/spinners into scrollback. See Phase 4 fix-A.
|
|
285
|
+
const flat = normalizeCmdForDisplay(input);
|
|
286
|
+
const short = flat.length > 40 ? flat.slice(0, 40) + '…' : flat;
|
|
186
287
|
const isDownload = tag === 'download' || tag === 'http_get';
|
|
187
288
|
if (isDownload) {
|
|
188
289
|
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
@@ -191,24 +292,42 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
191
292
|
}
|
|
192
293
|
},
|
|
193
294
|
onToolStart: (tag, input, ctx) => {
|
|
295
|
+
// Phase 7b boundary — commit the PREVIOUS op's held detail band to
|
|
296
|
+
// scrollback BEFORE this op's running line (or web group) is installed,
|
|
297
|
+
// so the committed preview lands above the new activity row. Mirrors the
|
|
298
|
+
// web tracker's "flush previous, then start new" sequencing. No-op when
|
|
299
|
+
// nothing is deferred. Runs before the web branch too, so a non-web
|
|
300
|
+
// preview followed by a web op still commits in chronological order.
|
|
301
|
+
chatHistory.commitDeferredDetail();
|
|
194
302
|
const actionLabel = TAG_REGISTRY[tag]?.label || tag;
|
|
195
|
-
|
|
303
|
+
// Flatten before slicing — see onPermissionAsk above (Phase 4 fix-A).
|
|
304
|
+
const flat = normalizeCmdForDisplay(input);
|
|
305
|
+
const short = flat.length > 40 ? flat.slice(0, 40) + '…' : flat;
|
|
196
306
|
const isDownload = tag === 'download' || tag === 'http_get';
|
|
197
307
|
if (isDownload) {
|
|
198
308
|
statusBar.update('waiting_download', `Waiting for download: ${short}`);
|
|
199
309
|
} else {
|
|
200
310
|
statusBar.update('tool', `${actionLabel}: ${short}`);
|
|
201
311
|
}
|
|
202
|
-
// Web-activity collapse
|
|
203
|
-
//
|
|
204
|
-
// --debug
|
|
205
|
-
|
|
312
|
+
// Web- and file-activity collapse: in the default view, fold this op into
|
|
313
|
+
// its running process-summary line instead of its own activity row.
|
|
314
|
+
// --debug bypasses both trackers (full per-op detail). Switching group
|
|
315
|
+
// type — or starting a non-grouped tool — closes the OTHER open group
|
|
316
|
+
// first, so its committed summary lands ABOVE this op in scrollback. (A
|
|
317
|
+
// read↔list key change within the file group is handled inside
|
|
318
|
+
// fileTracker.start.)
|
|
319
|
+
const webOp = !sessionCtx.debugMode && webTracker.isWeb(tag);
|
|
320
|
+
const fileOp = !sessionCtx.debugMode && fileTracker.isGroupable(tag);
|
|
321
|
+
if (!webOp && webTracker.isOpen()) webTracker.flush();
|
|
322
|
+
if (!fileOp && fileTracker.isOpen()) fileTracker.flush();
|
|
323
|
+
if (webOp) {
|
|
206
324
|
webTracker.start(tag, input);
|
|
207
325
|
return;
|
|
208
326
|
}
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
327
|
+
if (fileOp) {
|
|
328
|
+
fileTracker.start(tag, input);
|
|
329
|
+
return;
|
|
330
|
+
}
|
|
212
331
|
// Register the invocation with the writer's activity region.
|
|
213
332
|
// The render function is re-invoked by the writer on every
|
|
214
333
|
// redraw so the pending line's elapsed time stays current with
|
|
@@ -223,23 +342,21 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
223
342
|
// name check with a category flag (e.g. blocking: true on the
|
|
224
343
|
// tool spec) if more blocking tools appear.
|
|
225
344
|
if (ctx && ctx.id) {
|
|
345
|
+
// Output Refactor (Phase 1): the interactive core tool line is now
|
|
346
|
+
// produced via a ToolOperation descriptor → the pure renderOperation,
|
|
347
|
+
// instead of an inline formatToolLine call. Byte-for-byte identical —
|
|
348
|
+
// this is a re-routing, not a re-styling.
|
|
226
349
|
if (tag === 'ask_user') {
|
|
227
|
-
const staticLine =
|
|
228
|
-
status: 'pending',
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
attrs: ctx.attrs,
|
|
232
|
-
noDuration: true,
|
|
233
|
-
});
|
|
350
|
+
const staticLine = renderOperation(
|
|
351
|
+
buildToolOperation({ id: ctx.id, tag, arg: input, attrs: ctx.attrs, status: 'pending', noDuration: true }),
|
|
352
|
+
{ mode: 'ansi', phase: 'pending' },
|
|
353
|
+
);
|
|
234
354
|
writerModule.startActivity(ctx.id, () => staticLine);
|
|
235
355
|
} else {
|
|
236
|
-
writerModule.startActivity(ctx.id, (elapsedMs) =>
|
|
237
|
-
status: 'pending',
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
attrs: ctx.attrs,
|
|
241
|
-
durationMs: elapsedMs,
|
|
242
|
-
}));
|
|
356
|
+
writerModule.startActivity(ctx.id, (elapsedMs) => renderOperation(
|
|
357
|
+
buildToolOperation({ id: ctx.id, tag, arg: input, attrs: ctx.attrs, status: 'pending', durationMs: elapsedMs }),
|
|
358
|
+
{ mode: 'ansi', phase: 'pending' },
|
|
359
|
+
));
|
|
243
360
|
}
|
|
244
361
|
}
|
|
245
362
|
},
|
|
@@ -250,21 +367,55 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
250
367
|
// failure (a 403/406 or timeout shows as "blocked"); the detailed error
|
|
251
368
|
// body stays hidden in the collapsed view (visible under --debug).
|
|
252
369
|
if (!sessionCtx.debugMode && webTracker.isWeb(tag)) {
|
|
370
|
+
// Live display unchanged — the tracker still owns the collapsed web
|
|
371
|
+
// summary region. Phase 6c-i: instead of returning `undefined` (which
|
|
372
|
+
// persisted a `null` slot → web vanished into the legacy whole-blob
|
|
373
|
+
// summary on replay), hand back a dedicated web-op core so the agent
|
|
374
|
+
// loop's `displayCore || null` push stores it on BOTH rails (native
|
|
375
|
+
// {role:'tool'} `_display`; XML `_display[]` slot). Nothing in the live
|
|
376
|
+
// render path reads this return value, so the live region is untouched;
|
|
377
|
+
// every replay reader treats the web-core as fallback (chat-history /
|
|
378
|
+
// chat-session) so the screen stays byte-identical until 6c-ii.
|
|
253
379
|
webTracker.end(tag, result, durationMs, ctx);
|
|
254
380
|
if (hasError) statusBar.update('streaming', 'Streaming response');
|
|
255
|
-
return;
|
|
381
|
+
return serializeWebOp(ctx, tag, durationMs);
|
|
256
382
|
}
|
|
257
383
|
const isBlocking = tag === 'ask_user';
|
|
258
|
-
|
|
259
|
-
|
|
384
|
+
// Output Refactor (Phase 1): build ONE descriptor for this finished call
|
|
385
|
+
// and render both the committed result line and (below) its diff detail
|
|
386
|
+
// from it — the single source of truth, replacing the inline
|
|
387
|
+
// formatToolLine + buildExecutionDiff pair. Byte-for-byte identical.
|
|
388
|
+
const operation = buildToolOperation({
|
|
389
|
+
id: ctx ? ctx.id : null,
|
|
260
390
|
tag,
|
|
261
391
|
arg: ctx && ctx.attrs ? (ctx.attrs.command || ctx.attrs.path || ctx.attrs.url || ctx.attrs.src || ctx.attrs.key || ctx.attrs.name || ctx.attrs.pattern) : '',
|
|
262
392
|
attrs: ctx ? ctx.attrs : null,
|
|
393
|
+
status: hasError ? 'error' : 'ok',
|
|
263
394
|
durationMs,
|
|
264
395
|
meta: ctx ? ctx.meta : null,
|
|
265
396
|
error: ctx ? ctx.error : null,
|
|
397
|
+
diff: ctx ? ctx.diff : null,
|
|
398
|
+
// Phase 5: hand the model-facing result to the descriptor so it can
|
|
399
|
+
// derive an output-preview detail (shell/MCP/subagent). Chrome only —
|
|
400
|
+
// the model already received the full result via boundToolOutput.
|
|
401
|
+
output: typeof result === 'string' ? result : null,
|
|
266
402
|
noDuration: isBlocking,
|
|
267
403
|
});
|
|
404
|
+
// File-activity collapse: a SUCCESSFUL read_file/list_dir folds into the
|
|
405
|
+
// running file-group aggregate instead of committing its own line — the
|
|
406
|
+
// group's single summary (or, for a 1–2 op run, the individual lines)
|
|
407
|
+
// commits at flush. The op core is STILL persisted here (serializeOperation
|
|
408
|
+
// below) so replay re-groups it. An ERRORED file op does NOT join the
|
|
409
|
+
// group: it falls through to flush the success-group first (so its summary
|
|
410
|
+
// lands ABOVE), then renders the error standalone + error body.
|
|
411
|
+
if (!sessionCtx.debugMode && !hasError && fileTracker.isGroupable(tag)) {
|
|
412
|
+
fileTracker.end(operation);
|
|
413
|
+
return serializeOperation(operation);
|
|
414
|
+
}
|
|
415
|
+
// A non-grouped tool end (or an errored file op) closes any open file
|
|
416
|
+
// group first, so its committed summary lands ABOVE this line.
|
|
417
|
+
if (fileTracker.isOpen()) fileTracker.flush();
|
|
418
|
+
const finalLine = renderOperation(operation, { mode: 'ansi', phase: 'result' });
|
|
268
419
|
if (ctx && ctx.id) {
|
|
269
420
|
writerModule.endActivity(ctx.id, finalLine);
|
|
270
421
|
} else {
|
|
@@ -273,6 +424,37 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
273
424
|
// to a direct scrollback line so the tool still leaves a trace.
|
|
274
425
|
writerModule.scrollback(finalLine);
|
|
275
426
|
}
|
|
427
|
+
// Execution-time file-edit diff. This is the SINGLE site the full diff of
|
|
428
|
+
// a successful mutating edit renders — decoupled from the permission modal,
|
|
429
|
+
// so an auto-approved edit shows its diff exactly like a manual one, and
|
|
430
|
+
// every entry mode (fresh / --resume / /history / /chats) renders it the
|
|
431
|
+
// same way. Loaded history replays through displayLoadedMessages (summaries
|
|
432
|
+
// only), never onToolEnd, so past turns carry no diff payload and are not
|
|
433
|
+
// replayed. Capped at config.diff_max_lines (head+tail for a large edit).
|
|
434
|
+
if (!hasError && operation.detail && operation.detail.kind === 'diff') {
|
|
435
|
+
const diffStr = renderOperation(operation, {
|
|
436
|
+
mode: 'ansi',
|
|
437
|
+
phase: 'detail',
|
|
438
|
+
maxLines: (getConfig() || {}).diff_max_lines,
|
|
439
|
+
});
|
|
440
|
+
if (diffStr) writerModule.scrollback(diffStr);
|
|
441
|
+
}
|
|
442
|
+
// Phase 5/7b: collapsed output preview for shell/MCP/subagent successes.
|
|
443
|
+
// DEFERRED into the writer's redrawable detail band (not committed to
|
|
444
|
+
// scrollback yet) — the held slot commits once at the next boundary
|
|
445
|
+
// (next-op start / assistant answer / turn end). The preview is static
|
|
446
|
+
// (first N lines + `… M more lines`, no expand affordance). Model-facing
|
|
447
|
+
// context is untouched (the full output already reached the model). The
|
|
448
|
+
// result line above and any diff still commit immediately.
|
|
449
|
+
if (!hasError && operation.detail && operation.detail.kind === 'output') {
|
|
450
|
+
chatHistory.deferToolOutput({
|
|
451
|
+
role: 'tool',
|
|
452
|
+
tag,
|
|
453
|
+
content: '',
|
|
454
|
+
output: operation.detail.payload.body,
|
|
455
|
+
previewLines: (getConfig() || {}).shell_preview_lines || 5,
|
|
456
|
+
});
|
|
457
|
+
}
|
|
276
458
|
if (hasError) {
|
|
277
459
|
// Preserve the expandable error body as a follow-up tool
|
|
278
460
|
// bubble. Empty content suppresses its header so the scrollback
|
|
@@ -283,46 +465,123 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
283
465
|
}
|
|
284
466
|
statusBar.update('streaming', 'Streaming response');
|
|
285
467
|
}
|
|
468
|
+
// Phase 6a — hand the SAME descriptor back to the agent loop (serialized)
|
|
469
|
+
// so the native rail can persist it as a `_display` sibling on the tool
|
|
470
|
+
// result message; replay then rebuilds it for full-fidelity rendering.
|
|
471
|
+
// Display chrome only — never touches the model-facing `content`. The
|
|
472
|
+
// web-activity path above returns its own web-op core (Phase 6c-i) which
|
|
473
|
+
// every replay reader routes to the legacy fallback, so web ops still
|
|
474
|
+
// render via the summary on replay (aggregation lands in 6c-ii).
|
|
475
|
+
return serializeOperation(operation);
|
|
286
476
|
},
|
|
287
477
|
onToken: (token) => {
|
|
288
478
|
if (!opts.showThink && implicitThinkPhase) {
|
|
289
|
-
//
|
|
290
|
-
|
|
291
|
-
|
|
479
|
+
// NATIVE-RAIL eager-open (live token-by-token narration). Gated on a
|
|
480
|
+
// safety signal so reasoning is NEVER leaked: open the gate eagerly
|
|
481
|
+
// ONLY when this stream is on the native rail AND the model has proven
|
|
482
|
+
// (a) it uses the structured reasoning channel this iteration
|
|
483
|
+
// (reasoningSeen) OR (b) it asserts inline_reasoning:false. In either
|
|
484
|
+
// case the leading content is narration, so we open the gate, drop the
|
|
485
|
+
// (empty) buffer, and fall through to stream THIS token live. The XML
|
|
486
|
+
// rail and the no-signal native case skip this branch entirely and keep
|
|
487
|
+
// the buffered-until-boundary fallback below (no behavior change, no
|
|
488
|
+
// leak). Mirror the think_bubble/orphan-</think> exits' status update.
|
|
489
|
+
if (nativeRail && (reasoningSeen || inlineReasoningFalse)) {
|
|
292
490
|
implicitThinkPhase = false;
|
|
293
491
|
implicitThinkBuffer = '';
|
|
294
492
|
statusBar.update('streaming', 'Streaming response');
|
|
493
|
+
// fall through — stream this and all subsequent tokens live. The
|
|
494
|
+
// orphan-close-tag filter below still runs so a stray </think> that
|
|
495
|
+
// MiniMax inlines alongside reasoning_content never reaches the
|
|
496
|
+
// terminal (regression from 938f583's eager-open, which skipped the
|
|
497
|
+
// else-branch's drop guard for this and every subsequent token).
|
|
498
|
+
} else {
|
|
499
|
+
// Check if this token is the closing think tag (Qwen3-style implicit think).
|
|
500
|
+
if (ORPHAN_CLOSE_TAG_RE.test(token.trim())) {
|
|
501
|
+
// Thinking phase is over — discard buffered reasoning, start streaming.
|
|
502
|
+
implicitThinkPhase = false;
|
|
503
|
+
implicitThinkBuffer = '';
|
|
504
|
+
statusBar.update('streaming', 'Streaming response');
|
|
505
|
+
return;
|
|
506
|
+
}
|
|
507
|
+
// Buffer the token; keep the thinking animation visible.
|
|
508
|
+
implicitThinkBuffer += token;
|
|
295
509
|
return;
|
|
296
510
|
}
|
|
297
|
-
|
|
298
|
-
|
|
299
|
-
|
|
511
|
+
}
|
|
512
|
+
// Drop any orphan closing reasoning tag on every token, regardless of which
|
|
513
|
+
// branch opened the gate (eager-open or showThink). The StreamParser emits
|
|
514
|
+
// these verbatim, so once the gate is open they would otherwise stream live.
|
|
515
|
+
if (ORPHAN_CLOSE_TAG_RE.test(token.trim())) return;
|
|
516
|
+
// Ordering fix (Option b) — flush any open file/web activity group BEFORE
|
|
517
|
+
// the FIRST content-bearing narration token commits to scrollback. Streamed
|
|
518
|
+
// narration commits INCREMENTALLY: streamToken() emits the "▸ AI-agent"
|
|
519
|
+
// header and each complete line to immutable scrollback (chat-history.js)
|
|
520
|
+
// BEFORE onAssistantMessage/finalizeLastMessage ever fires. So flushing only
|
|
521
|
+
// at onAssistantMessage is too LATE for the streamed path — the narration
|
|
522
|
+
// lines are already above a still-open group, which then commits BELOW the
|
|
523
|
+
// conclusion it's based on (the "list ×3 below 'directory almost empty'"
|
|
524
|
+
// bug). Flushing here, at streaming-start, guarantees the group's summary
|
|
525
|
+
// commits ABOVE the first visible narration line.
|
|
526
|
+
//
|
|
527
|
+
// Gate strictly: only when the stream has NOT yet started (so we flush once,
|
|
528
|
+
// before the header) AND this token carries non-whitespace content — pure
|
|
529
|
+
// whitespace streaming artifacts in a silent read,read,read run must NOT
|
|
530
|
+
// flush, so such runs still collapse to one "explored ×N". flush() is
|
|
531
|
+
// idempotent (groupId===null guard), so the later onAssistantMessage,
|
|
532
|
+
// onToolStart, onPermissionAsk, and turn-end finally flushes all no-op —
|
|
533
|
+
// exactly one commit.
|
|
534
|
+
if (token && token.trim() && !chatHistory.isStreaming?.()) {
|
|
535
|
+
if (webTracker.isOpen()) webTracker.flush();
|
|
536
|
+
if (fileTracker.isOpen()) fileTracker.flush();
|
|
300
537
|
}
|
|
301
538
|
chatHistory.streamToken(token);
|
|
302
539
|
statusBar.onToken();
|
|
303
540
|
},
|
|
304
|
-
onAssistantMessage: (cleanContent) => {
|
|
305
|
-
// If </think> was never seen, the model had no implicit think block —
|
|
306
|
-
//
|
|
541
|
+
onAssistantMessage: (cleanContent, meta) => {
|
|
542
|
+
// If </think> was never seen, the model had no implicit think block — its
|
|
543
|
+
// leading text was ordinary narration. Drop the raw buffered tokens: the
|
|
544
|
+
// cleaned, canonical narration arrives as `cleanContent` and is rendered by
|
|
545
|
+
// finalizeLastMessage below (as a pre-tool bubble when nothing streamed live),
|
|
546
|
+
// so re-emitting the raw buffer would double it.
|
|
307
547
|
if (implicitThinkPhase && implicitThinkBuffer) {
|
|
308
548
|
implicitThinkPhase = false;
|
|
309
549
|
implicitThinkBuffer = '';
|
|
310
550
|
}
|
|
311
|
-
//
|
|
312
|
-
//
|
|
313
|
-
//
|
|
551
|
+
// Terminal-iteration signal. agent.js now passes `{ terminal }` explicitly
|
|
552
|
+
// (true only on the final, no-tool-call answer). Fall back to the legacy
|
|
553
|
+
// "content is non-empty" proxy when the flag is absent (older callers / the
|
|
554
|
+
// web-ordering unit tests drive these callbacks directly with one arg).
|
|
555
|
+
const terminal = meta && typeof meta.terminal === 'boolean'
|
|
556
|
+
? meta.terminal
|
|
557
|
+
: !!(cleanContent && cleanContent.trim());
|
|
558
|
+
// Ordering fix (Option b) — commit any still-open file/web activity group
|
|
559
|
+
// BEFORE the answer is finalized, so the collapsed summary lands ABOVE the
|
|
560
|
+
// narration in scrollback (correct chronological ordering: a conclusion has
|
|
561
|
+
// the group it's based on committed above it).
|
|
314
562
|
//
|
|
315
|
-
//
|
|
316
|
-
//
|
|
317
|
-
//
|
|
318
|
-
//
|
|
319
|
-
//
|
|
320
|
-
//
|
|
321
|
-
//
|
|
322
|
-
//
|
|
323
|
-
|
|
563
|
+
// Flush on the TERMINAL signal (the final no-tool answer) OR on any
|
|
564
|
+
// CONTENT-BEARING intermediate narration. This is the deliberate Option-(b)
|
|
565
|
+
// tradeoff: an intermediate narration that carries visible content now
|
|
566
|
+
// flushes the open group, so a "chatty" multi-read run FRAGMENTS into
|
|
567
|
+
// correctly-ordered sub-groups (each "explored ×N" above its narration)
|
|
568
|
+
// rather than collapsing across a conclusion that was based on it. A SILENT
|
|
569
|
+
// multi-read run (empty/whitespace-only interim narration — pure streaming
|
|
570
|
+
// artifacts) does NOT flush, so it still collapses fully to one summary.
|
|
571
|
+
//
|
|
572
|
+
// For the STREAMED path the open group is already committed above by the
|
|
573
|
+
// onToken streaming-start flush; this is the backstop for the non-streaming
|
|
574
|
+
// / finalize-only path (and the direct-callback unit tests). flush() is
|
|
575
|
+
// idempotent, so when both fire only one commit results. Empty/interrupted
|
|
576
|
+
// turns (no terminal message, no content) fall back to the turn-end
|
|
577
|
+
// `finally` flush, which is the safety net.
|
|
578
|
+
const contentful = !!(cleanContent && cleanContent.trim());
|
|
579
|
+
if ((terminal || contentful) && webTracker.isOpen()) {
|
|
324
580
|
webTracker.flush();
|
|
325
581
|
}
|
|
582
|
+
if ((terminal || contentful) && fileTracker.isOpen()) {
|
|
583
|
+
fileTracker.flush();
|
|
584
|
+
}
|
|
326
585
|
chatHistory.finalizeLastMessage(cleanContent);
|
|
327
586
|
},
|
|
328
587
|
onMetricsUpdate: (data) => statusBar.updateMetrics(data),
|
|
@@ -330,8 +589,8 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
330
589
|
statusBar.update('thinking', `Retrying (${attempt}/${max})...`);
|
|
331
590
|
},
|
|
332
591
|
onDebug: (block) => {
|
|
333
|
-
// Render in-history as a tool-style bubble so
|
|
334
|
-
//
|
|
592
|
+
// Render in-history as a tool-style bubble so the RAW RESPONSE text
|
|
593
|
+
// survives TUI redraws (stderr would be clobbered).
|
|
335
594
|
chatHistory.addMessage({ role: 'tool', tag: 'debug', content: 'DEBUG', output: block });
|
|
336
595
|
},
|
|
337
596
|
onError: (err) => {
|
|
@@ -430,9 +689,17 @@ function createTurnHandler(ctx, slashHandlers) {
|
|
|
430
689
|
statusBar.update('error', err.message || 'Agent error');
|
|
431
690
|
chatHistory.addMessage({ role: 'system', content: err.message || 'Agent error', isError: true });
|
|
432
691
|
} finally {
|
|
692
|
+
// Phase 7b boundary — commit any trailing op's held detail band before the
|
|
693
|
+
// turn unwinds (the turn may have ended right after a tool with no
|
|
694
|
+
// following message). No-op when nothing is deferred; ordered before the
|
|
695
|
+
// web flush (the two are mutually exclusive in practice).
|
|
696
|
+
try { chatHistory.commitDeferredDetail(); } catch { /* never block turn teardown */ }
|
|
433
697
|
// Commit any still-open web-activity summary (the turn may have ended right
|
|
434
698
|
// after a web op, or been interrupted mid-group) before the turn unwinds.
|
|
435
699
|
try { webTracker.flush(); } catch { /* never block turn teardown */ }
|
|
700
|
+
// Commit any still-open file-activity group (turn ended right after a read
|
|
701
|
+
// run, or was interrupted mid-group) before the turn unwinds.
|
|
702
|
+
try { fileTracker.flush(); } catch { /* never block turn teardown */ }
|
|
436
703
|
inputField.removeListener('abort', _onAbort);
|
|
437
704
|
}
|
|
438
705
|
|
package/lib/commands/chat.js
CHANGED
|
@@ -86,13 +86,11 @@ function createChatCommand(deps) {
|
|
|
86
86
|
onRemoveMessage: (id) => chatHistory.removeById(id),
|
|
87
87
|
// Modal-region API: setModal replaces the modal live band above the
|
|
88
88
|
// status region; clearModal drops it. Arrow-key redraws go through
|
|
89
|
-
// setModal only — no scrollback churn. When the picker resolves we
|
|
90
|
-
// clear the modal
|
|
89
|
+
// setModal only — no scrollback churn. When the picker resolves we just
|
|
90
|
+
// clear the modal — the execution result line is the sole post-approval
|
|
91
|
+
// confirmation, so no summary line is pushed to scrollback (Phase 2 D1).
|
|
91
92
|
onShowModal: (lines) => writer.setModal(lines),
|
|
92
|
-
onCloseModal: (
|
|
93
|
-
writer.clearModal();
|
|
94
|
-
if (summary) chatHistory.addMessage({ role: 'system', content: summary });
|
|
95
|
-
},
|
|
93
|
+
onCloseModal: () => { writer.clearModal(); },
|
|
96
94
|
onCaptureNavigation: (handler) => {
|
|
97
95
|
inputField.captureNavigation(handler);
|
|
98
96
|
return () => inputField.releaseNavigation();
|
|
@@ -100,8 +98,6 @@ function createChatCommand(deps) {
|
|
|
100
98
|
captureSelect: (menu) => inputField.captureSelect(menu),
|
|
101
99
|
});
|
|
102
100
|
|
|
103
|
-
inputField.on('expand', () => chatHistory.toggleLastExpand());
|
|
104
|
-
|
|
105
101
|
const cwd = process.cwd();
|
|
106
102
|
let currentModel = opts.model || getConfig().default_model;
|
|
107
103
|
let resolvedTokenLimit = await resolveTokenLimit(currentModel);
|
|
@@ -331,6 +327,14 @@ function createChatCommand(deps) {
|
|
|
331
327
|
|
|
332
328
|
|
|
333
329
|
statusBar.update('idle');
|
|
330
|
+
// Re-sync the clock to the input field's actual idle state. update('idle')
|
|
331
|
+
// unconditionally restarts the clock (the not-paused ⇒ clock-running
|
|
332
|
+
// invariant), but if an await above (resume / MCP connectAll) yielded the
|
|
333
|
+
// event loop, the one-shot _goIdle already fired and no active→idle
|
|
334
|
+
// transition will re-fire pause(). Converge both paths: if the field is
|
|
335
|
+
// already idle, pause the clock so the viewport can scroll. On a no-await
|
|
336
|
+
// start the field is not yet idle here, so this is a no-op.
|
|
337
|
+
if (inputField.isIdle()) statusBar.pause();
|
|
334
338
|
|
|
335
339
|
// Slash-command handlers (lib/commands/chat-slash.js), keyed by the canonical
|
|
336
340
|
// registry name. The parity check below guarantees registry ↔ handler stay
|
package/lib/config.js
CHANGED
|
@@ -206,6 +206,16 @@ function normalizeConfig(cfg = {}) {
|
|
|
206
206
|
// native_tools defaults to true; only explicit false/0/"false"/"0" opts out.
|
|
207
207
|
const nt = entry.native_tools;
|
|
208
208
|
normalized.native_tools = !(nt === false || nt === 0 || nt === '0' || nt === 'false');
|
|
209
|
+
// inline_reasoning (live-narration safety signal): an OPTIONAL explicit
|
|
210
|
+
// boolean assertion about whether this model inlines its reasoning into
|
|
211
|
+
// delta.content (Qwen3-style bare text + orphan </think>). Left unset by
|
|
212
|
+
// default → assume it MIGHT inline (safe default: keep buffering until a
|
|
213
|
+
// boundary). Only an explicit `false` asserts "never inlines" → the agent
|
|
214
|
+
// loop may stream narration live from token 1 on the native rail. Only an
|
|
215
|
+
// explicit boolean is persisted; any other value is dropped (stays unset).
|
|
216
|
+
if (typeof entry.inline_reasoning === 'boolean') {
|
|
217
|
+
normalized.inline_reasoning = entry.inline_reasoning;
|
|
218
|
+
}
|
|
209
219
|
// Multimodal image input (Task 5.4). `vision` (only when an explicit
|
|
210
220
|
// boolean) marks the profile vision-capable or text-only; a text-only
|
|
211
221
|
// profile makes an image attach fail LOUD rather than silently drop.
|
|
@@ -418,6 +428,22 @@ function isNativeToolsActive(model) {
|
|
|
418
428
|
return !(profile && profile.native_tools === false);
|
|
419
429
|
}
|
|
420
430
|
|
|
431
|
+
// Resolves the active profile's `inline_reasoning` assertion (live-narration
|
|
432
|
+
// safety signal b). Returns the explicit boolean if the profile sets one, else
|
|
433
|
+
// `undefined` ("unknown — assume it might inline reasoning"). Mirrors the
|
|
434
|
+
// profile lookup used by isNativeToolsActive. The agent loop treats only an
|
|
435
|
+
// explicit `false` as the safe-to-stream-live signal.
|
|
436
|
+
function getInlineReasoning(model) {
|
|
437
|
+
const cfg = loadConfig();
|
|
438
|
+
if (!Array.isArray(cfg.models)) return undefined;
|
|
439
|
+
const profile = cfg.models.find(
|
|
440
|
+
(p) => p && p.api_base === cfg.api_base && p.model === model
|
|
441
|
+
);
|
|
442
|
+
return profile && typeof profile.inline_reasoning === 'boolean'
|
|
443
|
+
? profile.inline_reasoning
|
|
444
|
+
: undefined;
|
|
445
|
+
}
|
|
446
|
+
|
|
421
447
|
const REDACTED_KEYS = new Set(['api_key', 'auth_token']);
|
|
422
448
|
|
|
423
449
|
function configShow(systemPromptOverride = null) {
|
|
@@ -457,6 +483,7 @@ module.exports = {
|
|
|
457
483
|
configSet,
|
|
458
484
|
configShow,
|
|
459
485
|
isNativeToolsActive,
|
|
486
|
+
getInlineReasoning,
|
|
460
487
|
loadConfig,
|
|
461
488
|
loadUserConfig,
|
|
462
489
|
normalizeConfig,
|