@semalt-ai/code 1.19.0 → 1.20.0

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.
Files changed (81) hide show
  1. package/.claude/settings.local.json +2 -1
  2. package/ARCHITECTURE.md +6 -95
  3. package/CLAUDE.md +196 -1874
  4. package/README.md +1 -1
  5. package/docs/ARCHITECTURE.md +1321 -0
  6. package/docs/CONFIG.md +340 -0
  7. package/docs/HISTORY.md +245 -0
  8. package/index.js +1 -1
  9. package/lib/agent.js +145 -16
  10. package/lib/api.js +28 -3
  11. package/lib/commands/chat-session.js +187 -4
  12. package/lib/commands/chat-slash.js +16 -0
  13. package/lib/commands/chat-turn.js +272 -49
  14. package/lib/commands/chat.js +12 -8
  15. package/lib/config.js +27 -0
  16. package/lib/constants.js +30 -1
  17. package/lib/headless.js +36 -1
  18. package/lib/images.js +8 -2
  19. package/lib/permissions.js +23 -16
  20. package/lib/prompts.js +15 -3
  21. package/lib/tool_registry.js +357 -53
  22. package/lib/tool_specs.js +42 -8
  23. package/lib/tools.js +80 -19
  24. package/lib/ui/anim.js +86 -0
  25. package/lib/ui/ansi.js +17 -27
  26. package/lib/ui/chat-history.js +253 -71
  27. package/lib/ui/create-ui.js +67 -24
  28. package/lib/ui/diff.js +90 -25
  29. package/lib/ui/file-activity.js +236 -0
  30. package/lib/ui/format.js +173 -28
  31. package/lib/ui/input-field.js +5 -4
  32. package/lib/ui/md-stream.js +234 -0
  33. package/lib/ui/render-operation.js +113 -0
  34. package/lib/ui/select.js +1 -4
  35. package/lib/ui/status-bar.js +99 -57
  36. package/lib/ui/stream.js +20 -13
  37. package/lib/ui/theme.js +190 -45
  38. package/lib/ui/tool-operation.js +190 -0
  39. package/lib/ui/utils.js +9 -5
  40. package/lib/ui/web-activity.js +58 -6
  41. package/lib/ui/writer.js +159 -45
  42. package/lib/ui.js +1 -1
  43. package/package.json +1 -1
  44. package/test/anim-driver.test.js +153 -0
  45. package/test/ask-user-display.test.js +226 -0
  46. package/test/ask-user-gate.test.js +231 -0
  47. package/test/chat-history-nocolor.test.js +155 -0
  48. package/test/chat-relogin.test.js +207 -0
  49. package/test/defer-detail-band.test.js +403 -0
  50. package/test/detail-band-tab-flatten.test.js +242 -0
  51. package/test/exec-diff.test.js +268 -0
  52. package/test/executors.test.js +250 -13
  53. package/test/extract-tool-calls.test.js +37 -3
  54. package/test/file-activity.test.js +522 -0
  55. package/test/grep-path-target.test.js +227 -0
  56. package/test/harness/chat-harness.js +2 -1
  57. package/test/headless.test.js +146 -1
  58. package/test/input-field-ctrl-o.test.js +37 -0
  59. package/test/live-height-physical.test.js +281 -0
  60. package/test/max-iterations.test.js +9 -7
  61. package/test/md-stream.test.js +183 -0
  62. package/test/native-dispatch.test.js +53 -0
  63. package/test/native-live-narration.test.js +254 -0
  64. package/test/output-heredoc-leak.test.js +195 -0
  65. package/test/output-preview.test.js +245 -0
  66. package/test/permissions.test.js +199 -0
  67. package/test/read-paginate.test.js +1 -1
  68. package/test/render-operation.test.js +317 -0
  69. package/test/replay-descriptor-xml.test.js +216 -0
  70. package/test/replay-descriptor.test.js +189 -0
  71. package/test/replay-web-aggregate.test.js +291 -0
  72. package/test/replay-web-persist.test.js +241 -0
  73. package/test/running-glyph-anim.test.js +111 -0
  74. package/test/status-bar-driver.test.js +93 -0
  75. package/test/status-bar-resync.test.js +188 -0
  76. package/test/stream-parser.test.js +24 -0
  77. package/test/theme-palette.test.js +166 -0
  78. package/test/truncate-visible.test.js +78 -0
  79. package/test/view-image.test.js +199 -0
  80. package/test/web-activity-ordering.test.js +12 -3
  81. package/path +0 -1
package/lib/api.js CHANGED
@@ -76,6 +76,21 @@ function debugDumpMessages(msgs) {
76
76
  }
77
77
  }
78
78
 
79
+ // Strip client-only sibling keys from messages right before the wire. Today
80
+ // that is the Phase 6a `_display` descriptor core (persisted on native tool
81
+ // messages for replay fidelity). Returns the array unchanged when no message
82
+ // carries one, so the common path allocates nothing.
83
+ function stripInternalKeys(messages) {
84
+ if (!Array.isArray(messages) || !messages.some((m) => m && m._display !== undefined)) return messages;
85
+ return messages.map((m) => {
86
+ if (m && m._display !== undefined) {
87
+ const { _display, ...rest } = m;
88
+ return rest;
89
+ }
90
+ return m;
91
+ });
92
+ }
93
+
79
94
  // Fit messages into tokenBudget tokens.
80
95
  // Uses chars/4 — aligned with estimateTokens; a deliberate under-estimate
81
96
  // for token-dense content (code, JSON, HTML) but consistent across the
@@ -415,7 +430,7 @@ function createApiClient({ getConfig, saveConfig, ui }) {
415
430
  });
416
431
  }
417
432
 
418
- async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, silent = false, signal = null, onTrim = null, nativeTools = true } = {}) {
433
+ async function chatStream(messages, { model, temperature, maxTokens, linePrefix = '', showThink = false, onToken = null, onReasoning = null, silent = false, signal = null, onTrim = null, nativeTools = true } = {}) {
419
434
  // nativeTools is plumbed through for downstream use (tools param + tool_calls parsing); no behavior change yet.
420
435
  const config = getConfig();
421
436
  const resolvedModel = model || config.default_model;
@@ -514,8 +529,10 @@ function createApiClient({ getConfig, saveConfig, ui }) {
514
529
  validateToolCallInvariant(msgs);
515
530
  // Transform any image-bearing turn into the provider-specific multimodal
516
531
  // content[] shape right before the wire (Task 5.4); the internal `images`
517
- // field never leaves the client.
518
- const wireMsgs = imagesPresent ? buildProviderMessages(msgs, imageFormat) : msgs;
532
+ // field never leaves the client. The Phase 6a `_display` descriptor sibling
533
+ // (persisted on native tool messages for replay) is likewise client-only —
534
+ // strip it here so it is never fed to the model.
535
+ const wireMsgs = stripInternalKeys(imagesPresent ? buildProviderMessages(msgs, imageFormat) : msgs);
519
536
  const reqPayload = { ...payload, messages: wireMsgs };
520
537
  // Optional payload augmentations (Task 2.7): reasoning_effort for models
521
538
  // that support it, and prompt-caching markers on the stable prefix when
@@ -788,6 +805,14 @@ function createApiClient({ getConfig, saveConfig, ui }) {
788
805
  const uiActive = isUIActive();
789
806
  if (!inReasoning) {
790
807
  inReasoning = true;
808
+ // Live-narration safety signal (a): the model demonstrably uses
809
+ // the structured reasoning_content channel this turn, so any
810
+ // delta.content that follows is narration, not inlined reasoning.
811
+ // Fire once per stream so the UI can eager-open its live gate on
812
+ // the native rail. Failures here must never break the stream.
813
+ if (typeof onReasoning === 'function') {
814
+ try { onReasoning(); } catch { /* UI signal is best-effort */ }
815
+ }
791
816
  if (showThink && !uiActive) {
792
817
  // audit: allowed — non-TUI thinking output, interleaves with StreamRenderer sync writes.
793
818
  process.stdout.write(`\n ${FG_DARK}${DIM}⟨thinking⟩${RST}`);
@@ -45,7 +45,12 @@ function createChatSession(ctx) {
45
45
  const title = firstUserText.length > 60 ? firstUserText.slice(0, 57) + '...' : firstUserText;
46
46
  const resp = await dashboardCreateChat(title, config.dashboard_model_id);
47
47
  if (resp && resp.chat && resp.chat.id) ctx.currentChatId = resp.chat.id;
48
- } catch {}
48
+ } catch (err) {
49
+ // Surface (don't swallow) a creation failure: a silent failure here leaves
50
+ // currentChatId null, so saveTurnToDashboard early-returns with no warning —
51
+ // quiet data loss. Non-fatal to the turn; the local session save still runs.
52
+ msgs.sysWarn(`could not create dashboard chat: ${err && err.message ? err.message : String(err)}`);
53
+ }
49
54
  }
50
55
 
51
56
  async function saveTurnToDashboard() {
@@ -59,23 +64,180 @@ function createChatSession(ctx) {
59
64
  msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
60
65
  }
61
66
  } catch (err) {
67
+ // A 404 means the chat id is stale for the CURRENT token — the chat belongs
68
+ // to another principal (relogin) or was deleted on the dashboard. Self-heal
69
+ // ONCE per turn: recreate a fresh chat under the current token and re-save
70
+ // the SAME pending slice. Scoped strictly to 404 — a transient network/5xx
71
+ // must NOT recreate the chat (that would spawn duplicates); leave savedUpTo
72
+ // unadvanced so a later turn retries the same slice naturally.
73
+ if (err && err.statusCode === 404) {
74
+ ctx.currentChatId = null;
75
+ const firstUser = ctx.messages.find((m) => m.role === 'user');
76
+ const title = firstUser && typeof firstUser.content === 'string' && firstUser.content
77
+ ? firstUser.content : 'Untitled chat';
78
+ await createChatIfNeeded(title); // warns on its own failure (see above)
79
+ if (ctx.currentChatId === null) return; // recreation failed; already warned
80
+ try {
81
+ const resp = await dashboardSaveMessages(ctx.currentChatId, newMessages);
82
+ ctx.savedUpTo = ctx.messages.length; // advance ONLY after re-save succeeds
83
+ if (resp && typeof resp.skipped_count === 'number' && resp.skipped_count > 0) {
84
+ msgs.sysWarn(`history save: ${resp.skipped_count} message(s) skipped by server`);
85
+ }
86
+ } catch (err2) {
87
+ msgs.sysWarn(`history save failed: ${err2 && err2.message ? err2.message : String(err2)}`);
88
+ }
89
+ return;
90
+ }
62
91
  msgs.sysWarn(`history save failed: ${err && err.message ? err.message : String(err)}`);
63
92
  }
64
93
  }
65
94
 
66
95
  function displayLoadedMessages(loadedMessages) {
67
96
  chatHistory.clearMessages();
68
- for (const m of loadedMessages) {
69
- if (m.role !== 'user' && m.role !== 'assistant' && m.role !== 'tool') continue;
97
+ const cfg = getConfig() || {};
98
+ const { descriptorFromStored } = require('../ui/tool-operation');
99
+ const { isWebCore, aggregateWebOps, formatWebSummaryLine } = require('../ui/web-activity');
100
+ const {
101
+ isGroupableFileCore, fileSummaryState, formatFileSummaryLine,
102
+ } = require('../ui/file-activity');
103
+
104
+ // Phase 6c-ii — replayed web activity renders as the aggregated `✓ web · …`
105
+ // committed summary, byte-identical to the live committed line. A web GROUP
106
+ // is a maximal consecutive run of web ops, so the buffer is LOOP-LEVEL (not
107
+ // blob-/message-local): a group spans iterations, and those iterations live
108
+ // in separate {role:'tool'} messages (native rail) or separate
109
+ // {role:'user'} feedback blobs (XML rail). The live flush triggers
110
+ // (chat-turn.js) are mirrored below: a non-web tool starting, a terminal
111
+ // assistant message with content, and turn end (the trailing flushWeb()).
112
+ // flushWeb() calls ONLY the pure aggregateWebOps/formatWebSummaryLine — it
113
+ // never instantiates createWebActivityTracker or touches the live region.
114
+ let webBuf = [];
115
+ function flushWeb() {
116
+ if (!webBuf.length) return;
117
+ const line = formatWebSummaryLine(aggregateWebOps(webBuf), { pending: false });
118
+ chatHistory.addRawLine(line);
119
+ webBuf = [];
120
+ }
121
+
122
+ // Parallel re-grouping for consecutive file ops (read_file/list_dir),
123
+ // mirroring flushWeb but with the live tracker's THRESHOLD applied at the
124
+ // REPLAY terminal width: a buffered run of ≥3 commits one aggregated summary
125
+ // (formatFileSummaryLine reads getCols() at flush, so a 200-col session
126
+ // re-groups correctly in an 80-col terminal); a run of 1–2 commits each op as
127
+ // its own per-op line via the SAME `_display` render the live path uses —
128
+ // byte-identical to a fresh per-op commit. read_file and list_dir share ONE
129
+ // group (mirroring the live merged key): a mixed run re-groups into the same
130
+ // single summary, with fileSummaryState picking the homogeneous-vs-mixed verb.
131
+ let fileBuf = []; // [{ core, ts }]
132
+ function flushFile() {
133
+ if (!fileBuf.length) return;
134
+ const buf = fileBuf;
135
+ fileBuf = [];
136
+ if (buf.length >= 3) {
137
+ const line = formatFileSummaryLine(fileSummaryState(buf.map((e) => e.core)), { pending: false });
138
+ chatHistory.addRawLine(line);
139
+ } else {
140
+ for (const { core, ts } of buf) {
141
+ chatHistory.addMessage({
142
+ role: 'tool', tag: 'tool', content: '', ts,
143
+ _display: core,
144
+ diffMaxLines: cfg.diff_max_lines,
145
+ previewLines: cfg.shell_preview_lines || 5,
146
+ });
147
+ }
148
+ }
149
+ }
150
+ // pushFile is only reached for groupable file cores (the caller gates on
151
+ // isGroupableFileCore), and read_file + list_dir now share one group, so every
152
+ // buffered op belongs to the same run — no key split. A non-groupable op
153
+ // flushes the buffer via the flushFile() calls on the other branches.
154
+ function pushFile(core, ts) {
155
+ fileBuf.push({ core, ts });
156
+ }
157
+
158
+ // A message that carries tool activity for an in-flight iteration: a native
159
+ // {role:'tool'} result, or an XML {role:'user'} feedback blob. The replay
160
+ // analogue of "an assistant iteration had tool calls" (live: cleanContent==='')
161
+ // is "the assistant is immediately followed by tool activity" — rail-agnostic,
162
+ // and independent of whether `tool_calls` survived the storage round-trip.
163
+ const isToolActivity = (msg) => !!msg && (
164
+ msg.role === 'tool' ||
165
+ (msg.role === 'user' && typeof msg.content === 'string' && msg.content.startsWith('Tool execution results:'))
166
+ );
167
+
168
+ const relevant = loadedMessages.filter(
169
+ (m) => m.role === 'user' || m.role === 'assistant' || m.role === 'tool',
170
+ );
171
+
172
+ for (let ri = 0; ri < relevant.length; ri++) {
173
+ const m = relevant[ri];
70
174
  const raw = typeof m.content === 'string' ? m.content : '';
71
175
  const ts = m.created_at ? new Date(m.created_at) : (m.ts ? new Date(m.ts) : new Date());
72
176
 
73
177
  if (m.role === 'tool') {
74
- chatHistory.addMessage({ role: 'tool', tag: 'tool', content: raw, ts });
178
+ // Phase 6c-ii a native web op persists a {v:1,kind:'web',…} core in
179
+ // `_display`. Buffer it into the current web group (no per-op line) and
180
+ // continue; the aggregated summary commits when the group flushes. A
181
+ // non-web tool first ENDS any open web run so its summary lands ABOVE this
182
+ // line (mirrors chat-turn.js:222), then renders via the 6a path.
183
+ if (isWebCore(m._display)) { flushFile(); webBuf.push(m._display); continue; }
184
+ // A groupable file core (read_file/list_dir) buffers into the file group
185
+ // (flushing any open web run first so its summary lands above); the
186
+ // aggregated/per-op commit happens when the file group flushes.
187
+ if (isGroupableFileCore(m._display)) { flushWeb(); pushFile(m._display, ts); continue; }
188
+ flushWeb(); flushFile();
189
+ // Phase 6a — forward the persisted display descriptor (native rail) so
190
+ // chat-history can replay it with full fidelity. `_display` absent →
191
+ // legacy summarizeToolResult fallback. The budgets match the live path
192
+ // (diff_max_lines for an edit diff; shell_preview_lines for an output
193
+ // preview) so a replayed line is byte-identical to a fresh render.
194
+ chatHistory.addMessage({
195
+ role: 'tool', tag: 'tool', content: raw, ts,
196
+ _display: m._display,
197
+ diffMaxLines: cfg.diff_max_lines,
198
+ previewLines: cfg.shell_preview_lines || 5,
199
+ });
75
200
  continue;
76
201
  }
77
202
 
78
203
  if (m.role === 'user' && raw.startsWith('Tool execution results:')) {
204
+ // Phase 6b — XML rail per-call replay parity. The feedback blob folds all
205
+ // tool results of a turn into one {role:'user'} message; it cannot be
206
+ // split back by parsing (the \n\n separator appears freely inside result
207
+ // bodies). When the persisted aligned `_display[]` array is present AND
208
+ // EVERY slot is a valid core — a normal descriptor core OR (Phase 6c-ii)
209
+ // a web-op core — replay each in order. The gate stays fail-safe: a single
210
+ // `null` slot or an unknown core drops the WHOLE blob to the legacy
211
+ // whole-blob summary below (no partial render), so a web op's activity
212
+ // never silently vanishes on replay.
213
+ const displays = Array.isArray(m._display) ? m._display : null;
214
+ // Phase 6c-ii — flip the 6c-i gate: a web-op core ({v:1,kind:'web',…}) is
215
+ // now a VALID slot (it aggregates into the web summary) instead of failing
216
+ // the gate. Normal slots must still pass descriptorFromStored; web slots
217
+ // must be v:1 web-cores. Any other slot (null / unknown) still fails.
218
+ if (displays && displays.length > 0 && displays.every(
219
+ (el) => el && ((isWebCore(el) && el.v === 1) || (!isWebCore(el) && descriptorFromStored(el))),
220
+ )) {
221
+ for (const el of displays) {
222
+ // A web slot buffers into the open web group; a groupable file slot
223
+ // buffers into the file group; a normal slot first flushes any
224
+ // preceding grouped run (mirrors a non-grouped tool starting), then
225
+ // renders.
226
+ if (isWebCore(el)) { flushFile(); webBuf.push(el); continue; }
227
+ if (isGroupableFileCore(el)) { flushWeb(); pushFile(el, ts); continue; }
228
+ flushWeb(); flushFile();
229
+ chatHistory.addMessage({
230
+ role: 'tool', tag: 'tool', content: '', ts,
231
+ _display: el,
232
+ diffMaxLines: cfg.diff_max_lines,
233
+ previewLines: cfg.shell_preview_lines || 5,
234
+ });
235
+ }
236
+ continue;
237
+ }
238
+ // Gate failed — a preceding grouped run (if any) ends here, then the legacy
239
+ // whole-blob summary renders (6b/6c-i fail-safe preserved).
240
+ flushWeb(); flushFile();
79
241
  const body = raw
80
242
  .replace(/^Tool execution results[^\n]*\n+/, '')
81
243
  .replace(/\n+Continue with the task\.[\s\S]*$/, '')
@@ -84,9 +246,30 @@ function createChatSession(ctx) {
84
246
  continue;
85
247
  }
86
248
 
249
+ // Plain user / assistant message. Empty-content messages never flush (an
250
+ // intermediate, tool-call-only assistant message between two web iterations
251
+ // carries empty display content — flushing there would split a cross-
252
+ // iteration group into two summaries) and are not rendered.
87
253
  if (!raw.trim()) continue;
254
+ if (m.role === 'assistant') {
255
+ // Flush ONLY on a TERMINAL assistant message — one not immediately
256
+ // followed by tool activity (the replay analogue of live cleanContent!==''
257
+ // at chat-turn.js:389-391). An intermediate assistant with content is
258
+ // still rendered, but does NOT flush, so the open web group survives.
259
+ if (!isToolActivity(relevant[ri + 1])) { flushWeb(); flushFile(); }
260
+ } else {
261
+ // A plain user message starts a NEW turn — close any grouped run left open
262
+ // by the prior turn (the live per-turn `finally` flush, chat-turn.js).
263
+ flushWeb(); flushFile();
264
+ }
88
265
  chatHistory.addMessage({ role: m.role, content: raw, ts });
89
266
  }
267
+
268
+ // Turn-end safety net: a trailing grouped run (turn ended/was interrupted
269
+ // right after a web or file op, with no terminal assistant) commits here —
270
+ // mirrors the live per-turn `finally` flush in chat-turn.js.
271
+ flushWeb();
272
+ flushFile();
90
273
  }
91
274
 
92
275
  function seedContextFromMessages() {
@@ -85,6 +85,18 @@ function createSlashHandlers(ctx) {
85
85
  statusBar.setContextLimit(ctx.resolvedTokenLimit);
86
86
  chatHistory.addMessage({ role: 'system', content: `✓ Model → ${picked.name} (${picked.modelId})` });
87
87
  }
88
+ // Relogin is a principal change: the new token may belong to a different
89
+ // user, so the surviving currentChatId would otherwise suppress fresh-chat
90
+ // creation and make saveTurnToDashboard POST to a chat the new user can't
91
+ // see (404 "Chat not found"). Reset chat context AFTER the new token is
92
+ // saved (loginFlow) and the model resolved (ensureDefaultModel) so the next
93
+ // turn's createChatIfNeeded lazily creates a fresh chat under the new token.
94
+ // Mirror /new's FULL reset (messages + approvals too): retaining the prior
95
+ // user's messages/approvals across a token change is unsafe. The way back to
96
+ // an old chat is explicit --resume / /chats.
97
+ ctx.messages = [];
98
+ ctx.currentChatId = null; ctx.savedUpTo = 0;
99
+ permissionManager.clear();
88
100
  statusBar.update('idle');
89
101
  inputField.setDisabled(false);
90
102
  },
@@ -111,6 +123,10 @@ function createSlashHandlers(ctx) {
111
123
  if (!config.auth_token) { chatHistory.addMessage({ role: 'system', content: '✗ Not logged in.' }); return; }
112
124
  inputField.setDisabled(true);
113
125
  statusBar.update('thinking', 'Logging out...');
126
+ // Defense-in-depth: drop the chat context up front, REGARDLESS of whether
127
+ // the dashboardLogout HTTP call below succeeds (it early-returns on a
128
+ // non-401 error), so no later save can target the logged-out user's chat.
129
+ ctx.currentChatId = null; ctx.savedUpTo = 0;
114
130
  try { await dashboardLogout(); } catch (err) { if (err.statusCode !== 401) { chatHistory.addMessage({ role: 'system', content: `✗ ${err.message}`, isError: true }); statusBar.update('idle'); inputField.setDisabled(false); return; } }
115
131
  setConfig({ ...config, auth_token: '' });
116
132
  chatHistory.addMessage({ role: 'system', content: '✓ Logged out and cleared local CLI token.' });