u-foo 3.0.9 → 3.0.11

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 (65) hide show
  1. package/README.md +25 -9
  2. package/README.zh-CN.md +22 -9
  3. package/dist/tui/darwin-arm64/ufoo-tui +0 -0
  4. package/dist/tui/darwin-x64/ufoo-tui +0 -0
  5. package/dist/tui/linux-arm64/ufoo-tui +0 -0
  6. package/dist/tui/linux-x64/ufoo-tui +0 -0
  7. package/package.json +12 -4
  8. package/scripts/pack-tui.js +112 -0
  9. package/scripts/postinstall.js +11 -0
  10. package/src/agents/activity/activityReconcile.js +106 -0
  11. package/src/agents/activity/activityStatePublisher.js +31 -2
  12. package/src/agents/activity/index.js +1 -0
  13. package/src/agents/launch/launcher.js +19 -0
  14. package/src/agents/launch/ptyRunner.js +20 -1
  15. package/src/app/chat/ChatController.js +433 -0
  16. package/src/app/chat/agentDirectory.js +63 -0
  17. package/src/app/chat/agentEnter.js +70 -0
  18. package/src/app/chat/agentIdentity.js +50 -0
  19. package/src/app/chat/bootstrap.js +66 -0
  20. package/src/app/chat/commandExecutor.js +108 -0
  21. package/src/app/chat/commands.js +38 -1
  22. package/src/app/chat/dashboardView.js +6 -2
  23. package/src/app/chat/historyStore.js +181 -0
  24. package/src/app/chat/index.js +14 -2
  25. package/src/app/chat/inputSubmitHandler.js +21 -7
  26. package/src/app/chat/ipcBuilders.js +52 -0
  27. package/src/app/chat/multiWindow/paneManager.js +10 -1
  28. package/src/app/chat/multiWindow/renderer.js +1 -1
  29. package/src/app/chat/multiWindow/vtFrame.js +93 -0
  30. package/src/app/chat/streamState.js +182 -0
  31. package/src/app/cli/features/doctor.js +22 -0
  32. package/src/code/UcodeController.js +156 -0
  33. package/src/code/context/planGraphService.js +4 -0
  34. package/src/code/repl.js +4 -3
  35. package/src/code/runtime/taskLoop.js +46 -50
  36. package/src/code/tui.js +13 -2
  37. package/src/code/ucodeSlashDispatch.js +241 -0
  38. package/src/coordination/bus/activate.js +3 -0
  39. package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
  40. package/src/runtime/contracts/uiProtocol.js +190 -0
  41. package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
  42. package/src/ui/dashboardBridge.js +81 -0
  43. package/src/ui/format/index.js +2 -2
  44. package/src/ui/index.js +8 -4
  45. package/src/ui/multiPaneBusMirror.js +137 -0
  46. package/src/ui/multiWindowHandoff.js +232 -0
  47. package/src/ui/ptyHandoff.js +23 -0
  48. package/src/ui/rustChatHost.js +1520 -0
  49. package/src/ui/rustMultiSession.js +497 -0
  50. package/src/ui/rustUcodeHost.js +999 -0
  51. package/src/ui/scrollbackReplay.js +82 -0
  52. package/src/ui/settingsBridge.js +49 -0
  53. package/src/ui/toolMergeBridge.js +66 -0
  54. package/src/ui/tuiLauncher.js +105 -0
  55. package/src/ui/ucodeStatusLine.js +74 -0
  56. package/src/ui/uiHostServer.js +339 -0
  57. package/src/ui/MIGRATION.md +0 -334
  58. package/src/ui/ink/ChatApp.js +0 -4152
  59. package/src/ui/ink/DashboardBar.js +0 -691
  60. package/src/ui/ink/InkDemo.js +0 -96
  61. package/src/ui/ink/MultilineInput.js +0 -662
  62. package/src/ui/ink/UcodeApp.js +0 -1675
  63. package/src/ui/ink/agentMirror.js +0 -730
  64. package/src/ui/ink/chatReducer.js +0 -473
  65. package/src/ui/runInk.js +0 -66
@@ -1,1675 +0,0 @@
1
- "use strict";
2
-
3
- /**
4
- * Ink-based ucode TUI rendered via React + ink.
5
- *
6
- * Activation: this is the only ucode TUI.
7
- *
8
- * Coverage today: banner, scrolling log via <Static>, tool-call merge with
9
- * Ctrl+O expand, multiline editor (see MultilineInput.js), spinner+phase
10
- * status line, abortController-driven Esc cancel, input history Up/Down,
11
- * agent selection footer, runSingleCommand + runNaturalLanguageTask path.
12
- *
13
- * Also covers blessed parity branches: background tasks, ubus, resume,
14
- * nl_bg, and autoBus polling.
15
- */
16
-
17
- const { runInk } = require("../runInk");
18
- const fmt = require("../format");
19
- const { createMultilineInput } = require("./MultilineInput");
20
- const {
21
- handleImagePaste,
22
- formatUserLogWithAttachments,
23
- buildAttachedImagesPromptPrefix,
24
- } = require("../../code/imageIngest");
25
-
26
- // Throttle for the live thinking-chain status line: rapid thinking_delta
27
- // chunks would otherwise re-render the footer on every SSE event.
28
- const THINKING_STATUS_THROTTLE_MS = 120;
29
-
30
- // Log line kinds drive the color treatment of scrollback rows. Kind is pure
31
- // presentation metadata — the stored text never changes.
32
- const LOG_LINE_TEXT_PROPS = {
33
- user: { color: "green", bold: true },
34
- assistant: {},
35
- system: { color: "gray", dimColor: true },
36
- error: { color: "red" },
37
- tool: {},
38
- toolDetail: { color: "gray", dimColor: true },
39
- bus: { color: "cyan" },
40
- };
41
-
42
- // Only assistant prose gets markdown. Error rows are app-generated
43
- // (`Error: …`) and already painted red via resolveLogLineTextProps — running
44
- // them through the MD Error: line rule would wrap chalk ANSI and break the
45
- // plain-text body the Ink color prop expects.
46
- const MARKDOWN_LOG_KINDS = new Set(["assistant"]);
47
-
48
- // Resolve a log line kind to ink <Text> props. Unknown/missing kinds (e.g.
49
- // the banner, which already carries chalk ANSI styling) render uncolored.
50
- function resolveLogLineTextProps(kind) {
51
- return LOG_LINE_TEXT_PROPS[kind] || LOG_LINE_TEXT_PROPS.assistant;
52
- }
53
-
54
- function createUcodeApp({ React, ink, props, interactive = true }) {
55
- const { useEffect, useState, useCallback, useRef } = React;
56
- const { Box, Text, useInput, useApp, useStdout } = ink;
57
- const h = React.createElement;
58
- const MultilineInput = createMultilineInput({ React, ink });
59
-
60
- const banner = fmt.buildUcodeBannerLines({
61
- model: (props.state && props.state.model) || process.env.UFOO_UCODE_MODEL || "",
62
- engine: (props.state && props.state.engine) || "ufoo-core",
63
- workspaceRoot: props.workspaceRoot,
64
- sessionId: (props.state && props.state.sessionId) || "",
65
- planMode: Boolean(
66
- props.state
67
- && props.state.executionState
68
- && props.state.executionState.planMode
69
- ),
70
- });
71
-
72
- return function UcodeApp() {
73
- const [logLines, setLogLines] = useState(() =>
74
- banner.concat([""]).map((line, idx) => ({ id: `b-${idx}`, text: line }))
75
- );
76
- const [draft, setDraft] = useState("");
77
- const [draftVersion, setDraftVersion] = useState(0);
78
- const [imageAttachments, setImageAttachments] = useState([]);
79
- // status: idle when message === "". `type` picks a STATUS_INDICATORS
80
- // bucket; `showTimer` and `startedAt` reproduce the blessed spinner
81
- // controls. The BG suffix is computed from backgroundTasksRef and
82
- // appended by computeStatusText below.
83
- const [status, setStatus] = useState({
84
- message: "",
85
- type: "thinking",
86
- showTimer: false,
87
- startedAt: 0,
88
- });
89
- const [planUi, setPlanUi] = useState(() => ({
90
- hasPlan: false,
91
- visible: false,
92
- bandLines: [],
93
- roadmapMarkdown: "",
94
- idleHint: "",
95
- statusLine: "",
96
- hash: "",
97
- }));
98
- const [interactionLines, setInteractionLines] = useState([]);
99
- // Bumps when pendingUserPrompts change so the near-input queue banner
100
- // re-renders without dumping a chat-log system line.
101
- const [queueTick, setQueueTick] = useState(0);
102
- const bumpQueue = useCallback(() => setQueueTick((n) => n + 1), []);
103
- const [spinnerTick, setSpinnerTick] = useState(0);
104
- const [size, setSize] = useState({ cols: 0, rows: 0 });
105
- const [contextMeter, setContextMeter] = useState(() => {
106
- try {
107
- const {
108
- buildContextMeter,
109
- normalizeContextMeter,
110
- } = require("../../code/contextWindow");
111
- const existing = props.state && props.state.contextMeter;
112
- if (existing && typeof existing === "object") {
113
- return normalizeContextMeter(existing, (props.state && props.state.model) || "");
114
- }
115
- return buildContextMeter({
116
- usedTokens: 0,
117
- model: (props.state && props.state.model) || process.env.UFOO_UCODE_MODEL || "",
118
- });
119
- } catch {
120
- return { usedTokens: 0, limitTokens: 200000, label: "0 / 200K", model: "" };
121
- }
122
- });
123
- const [agents, setAgents] = useState([]);
124
- const [selectedAgentIndex, setSelectedAgentIndex] = useState(-1);
125
- const [agentSelectionMode, setAgentSelectionMode] = useState(false);
126
- // activeMerge holds the in-flight group of consecutive tool calls.
127
- // Rendered as a single live row below <Static>; promoted to <Static>
128
- // and cleared whenever a non-tool log line arrives. lastMergeRef tracks
129
- // the most recent group with >=2 entries so Ctrl+O can still expand it
130
- // after the group has been frozen into the log.
131
- const [activeMerge, setActiveMerge] = useState(null);
132
- const lastMergeRef = useRef(null);
133
- // pendingTaskRef holds the live AbortController for the current
134
- // runNaturalLanguageTask call so Esc can cancel it. We use a ref (not
135
- // state) because the value is consumed inside the run loop, not by
136
- // render.
137
- const pendingTaskRef = useRef(null);
138
- const backgroundTasksRef = useRef(new Map());
139
- const backgroundSeqRef = useRef(0);
140
- const autoBusQueuedRef = useRef(false);
141
- const autoBusErrorRef = useRef("");
142
- const [, setBackgroundVersion] = useState(0);
143
- // inputHistory mirrors blessed's flat history list. Up walks back
144
- // through it when the editor reports the cursor is already on the top
145
- // visual row (i.e. moveCursorVertically returned moved=false).
146
- const [inputHistory, setInputHistory] = useState([]);
147
- const [historyIndex, setHistoryIndex] = useState(0);
148
- const [completionIndex, setCompletionIndex] = useState(0);
149
- const [completionWindowStart, setCompletionWindowStart] = useState(0);
150
- const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
151
- const POPUP_PAGE_SIZE = 8;
152
- const { exit } = useApp();
153
- const { stdout } = useStdout();
154
- const lineSeqRef = useRef(banner.length + 1);
155
- const mergeIdRef = useRef(0);
156
- const toolMergeScopeRef = useRef(0);
157
- // thinkingTailRef accumulates raw thinking_delta text for the live
158
- // status line; the collapsed tail is pushed through a throttled
159
- // trailing flush (thinkingTimerRef) so fast streams don't re-render
160
- // the footer on every chunk.
161
- const thinkingTailRef = useRef("");
162
- const thinkingFlushAtRef = useRef(0);
163
- const thinkingTimerRef = useRef(null);
164
- // Persist fence/open-code state across streamed assistant log lines so
165
- // ``` blocks stay styled even when deltas arrive one line at a time.
166
- const markdownStateRef = useRef({ inCodeBlock: false });
167
- // GFM tables need the full block for column alignment — buffer consecutive
168
- // pipe rows and flush as one multi-line markdown unit.
169
- const tableBufRef = useRef(fmt.createMarkdownTableBuffer());
170
-
171
- const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
172
- ? agents[selectedAgentIndex]
173
- : null;
174
-
175
- const bumpBackground = useCallback(() => setBackgroundVersion((v) => v + 1), []);
176
-
177
- const refreshPlanUi = useCallback((activityMessage = "") => {
178
- try {
179
- const { buildPlanUiProjection } = require("../../code/context/planProjection");
180
- const {
181
- getPendingUserInteraction,
182
- formatInteractionPromptLines,
183
- syncInteractionFromPlanGraph,
184
- } = require("../../code/context/userInteraction");
185
- if (props.state && props.state.executionState) {
186
- syncInteractionFromPlanGraph(props.state.executionState);
187
- }
188
- const next = buildPlanUiProjection(
189
- props.state && props.state.executionState,
190
- {
191
- cols: size.cols || 80,
192
- activityMessage: String(activityMessage || ""),
193
- }
194
- );
195
- setPlanUi((prev) => (prev && prev.hash === next.hash ? prev : next));
196
- const pending = getPendingUserInteraction(props.state && props.state.executionState);
197
- setInteractionLines(pending ? formatInteractionPromptLines(pending, {
198
- cols: size.cols || 80,
199
- }) : []);
200
- return next;
201
- } catch {
202
- return null;
203
- }
204
- }, [props.state, size.cols]);
205
-
206
- const getBackgroundSuffix = useCallback(() => {
207
- const tasks = backgroundTasksRef.current;
208
- if (!tasks || tasks.size === 0) return "";
209
- let running = 0;
210
- let done = 0;
211
- let failed = 0;
212
- for (const task of tasks.values()) {
213
- if (!task) continue;
214
- if (task.status === "running") running += 1;
215
- else if (task.status === "done") done += 1;
216
- else if (task.status === "failed") failed += 1;
217
- }
218
- const parts = [];
219
- if (running) parts.push(`${running} running`);
220
- if (done) parts.push(`${done} done`);
221
- if (failed) parts.push(`${failed} failed`);
222
- return parts.length ? ` · BG ${parts.join("/")}` : "";
223
- }, []);
224
-
225
- const getAgentLabel = useCallback((agent) => {
226
- if (!agent) return "";
227
- if (agent.nickname) return agent.nickname;
228
- const idTail = String(agent.id || "").slice(0, 6);
229
- return idTail ? `${agent.type}:${idTail}` : agent.type;
230
- }, []);
231
-
232
- const ucodeModel = (props.state && props.state.model)
233
- || process.env.UFOO_UCODE_MODEL
234
- || "default";
235
- let workspaceLabel = "";
236
- try {
237
- const os = require("os");
238
- const path = require("path");
239
- const root = props.workspaceRoot || process.cwd();
240
- const home = os.homedir();
241
- let normalized = root.startsWith(home) ? root.replace(home, "~") : root;
242
- workspaceLabel = path.normalize(normalized);
243
- } catch {
244
- workspaceLabel = String(props.workspaceRoot || "");
245
- }
246
- const hintParts = [ucodeModel];
247
- if (workspaceLabel) hintParts.push(workspaceLabel);
248
- const agentsHint = hintParts.join(" · ");
249
-
250
- const selfSubscriberId = String(
251
- (props.autoBus && props.autoBus.subscriberId) ||
252
- process.env.UFOO_SUBSCRIBER_ID ||
253
- ""
254
- ).trim();
255
-
256
- const refreshAgents = useCallback(() => {
257
- try {
258
- const list = fmt.filterSelectableAgents(
259
- fmt.loadActiveAgents(props.workspaceRoot),
260
- selfSubscriberId
261
- );
262
- setAgents(list);
263
- } catch {
264
- // loadActiveAgents already swallows errors and returns []. This catch
265
- // is just a belt-and-braces guard against future regressions.
266
- }
267
- }, [selfSubscriberId]);
268
-
269
- useEffect(() => {
270
- if (!interactive) return undefined;
271
- refreshAgents();
272
- const timer = setInterval(refreshAgents, 3000);
273
- return () => clearInterval(timer);
274
- }, [interactive, refreshAgents]);
275
-
276
- // Keep selection within bounds when the agents list changes.
277
- useEffect(() => {
278
- if (selectedAgentIndex < 0) return;
279
- if (agents.length === 0) {
280
- setSelectedAgentIndex(-1);
281
- setAgentSelectionMode(false);
282
- } else if (selectedAgentIndex >= agents.length) {
283
- setSelectedAgentIndex(agents.length - 1);
284
- }
285
- }, [agents, selectedAgentIndex]);
286
-
287
- const onArrowDownAtEnd = useCallback((currentValue) => {
288
- // History first: if we're past the bottom of a multi-line edit, walk
289
- // forward through the recent history. Reaching the end clears the
290
- // input the same way blessed does.
291
- if (inputHistory.length > 0) {
292
- const transition = fmt.resolveHistoryDownTransition({
293
- inputHistory,
294
- historyIndex,
295
- currentValue,
296
- });
297
- if (transition.moved) {
298
- setHistoryIndex(transition.nextHistoryIndex);
299
- setDraft(transition.nextValue);
300
- setCompletionSuppressedDraft(transition.nextValue || null);
301
- setDraftVersion((v) => v + 1);
302
- return;
303
- }
304
- }
305
- if (agents.length === 0) return;
306
- const decision = fmt.resolveAgentSelectionOnDown({
307
- agentSelectionMode,
308
- selectedAgentIndex,
309
- totalAgents: agents.length,
310
- });
311
- if (decision.action === "enter") {
312
- setSelectedAgentIndex(decision.index);
313
- setAgentSelectionMode(true);
314
- }
315
- }, [inputHistory, historyIndex, agents, agentSelectionMode, selectedAgentIndex]);
316
-
317
- const onArrowUpAtStart = useCallback((currentValue) => {
318
- // While @-targeting an agent with an empty draft, Up clears the
319
- // selection before walking input history — otherwise history eats the
320
- // key and the ›@agent prefix sticks.
321
- const inputValue = currentValue != null ? currentValue : draft;
322
- if (fmt.shouldClearAgentSelectionOnUp({
323
- agentSelectionMode,
324
- inputValue,
325
- })) {
326
- setAgentSelectionMode(false);
327
- setSelectedAgentIndex(-1);
328
- return;
329
- }
330
- // History: if we're already on the top visual row, walk back through
331
- // the recent history before doing anything else.
332
- if (inputHistory.length > 0) {
333
- const nextIndex = Math.max(0, historyIndex - 1);
334
- if (nextIndex !== historyIndex || draft !== inputHistory[nextIndex]) {
335
- setHistoryIndex(nextIndex);
336
- const nextValue = inputHistory[nextIndex] || "";
337
- setDraft(nextValue);
338
- setCompletionSuppressedDraft(nextValue || null);
339
- setDraftVersion((v) => v + 1);
340
- }
341
- }
342
- }, [inputHistory, historyIndex, draft, agentSelectionMode]);
343
-
344
- const onArrowSideAtEmpty = useCallback((direction) => {
345
- if (!agentSelectionMode) return;
346
- if (agents.length === 0) return;
347
- const next = fmt.cycleAgentSelectionIndex(
348
- selectedAgentIndex,
349
- agents.length,
350
- direction
351
- );
352
- setSelectedAgentIndex(next);
353
- }, [agents, agentSelectionMode, selectedAgentIndex]);
354
-
355
- const { UCODE_COMMAND_REGISTRY, UCODE_COMMAND_TREE } = require("../../code/commands");
356
- const { listSessionSummaries } = require("../../code/sessionStore");
357
- const { suggestUcodeModels, suggestUcodeThinkingLevels, applyUcodeModelCommand, listUcodeModels } = require("../../code/modelCommand");
358
- let resumeSessions = [];
359
- try {
360
- resumeSessions = listSessionSummaries(props.workspaceRoot || process.cwd(), { limit: 40 });
361
- } catch {
362
- resumeSessions = [];
363
- }
364
- const [remoteModels, setRemoteModels] = useState([]);
365
- useEffect(() => {
366
- let cancelled = false;
367
- (async () => {
368
- try {
369
- const listed = await listUcodeModels(props.state || {}, {
370
- workspaceRoot: props.workspaceRoot || process.cwd(),
371
- });
372
- if (!cancelled && listed.ok) {
373
- setRemoteModels(Array.isArray(listed.models) ? listed.models : []);
374
- }
375
- } catch {
376
- if (!cancelled) setRemoteModels([]);
377
- }
378
- })();
379
- return () => { cancelled = true; };
380
- }, [
381
- props.workspaceRoot,
382
- props.state && props.state.provider,
383
- props.state && props.state.model,
384
- ]);
385
- const modelSuggestions = suggestUcodeModels(props.state || {}, { models: remoteModels });
386
- const thinkingSuggestions = suggestUcodeThinkingLevels(props.state || {});
387
-
388
- const completions = fmt.buildCompletions({
389
- text: draft,
390
- agents: agents.map((a) => String((a && (a.fullId || a.id || a.nickname)) || "")).filter(Boolean),
391
- agentLabels: agents.map((a) => getAgentLabel(a)),
392
- commands: UCODE_COMMAND_REGISTRY,
393
- commandTree: UCODE_COMMAND_TREE,
394
- argumentLists: {
395
- "/resume": resumeSessions,
396
- "/model": modelSuggestions,
397
- "/model/thinking": thinkingSuggestions,
398
- },
399
- limit: 20,
400
- });
401
- const completionsOpen = completions.length > 0 && draft !== completionSuppressedDraft;
402
-
403
- useEffect(() => {
404
- if (completions.length === 0) {
405
- if (completionIndex !== 0) setCompletionIndex(0);
406
- if (completionWindowStart !== 0) setCompletionWindowStart(0);
407
- } else if (completionIndex >= completions.length) {
408
- setCompletionIndex(completions.length - 1);
409
- setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
410
- }
411
- }, [completions.length, completionIndex, completionWindowStart]);
412
-
413
- const acceptCompletion = useCallback(() => {
414
- if (!completionsOpen) return false;
415
- const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
416
- if (item) {
417
- setDraft(item.replace);
418
- setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
419
- setDraftVersion((v) => v + 1);
420
- }
421
- setCompletionIndex(0);
422
- return true;
423
- }, [completionsOpen, completions, completionIndex]);
424
-
425
- const pushRenderedLogLines = useCallback((rawText, kind = "assistant") => {
426
- const raw = String(rawText == null ? "" : rawText);
427
- let renderedLines = [raw];
428
- if (MARKDOWN_LOG_KINDS.has(kind)) {
429
- try {
430
- renderedLines = fmt.renderLogLinesWithMarkdownAnsi(raw, markdownStateRef.current);
431
- if (!Array.isArray(renderedLines) || renderedLines.length === 0) {
432
- renderedLines = raw.split(/\r?\n/);
433
- }
434
- } catch {
435
- renderedLines = raw.split(/\r?\n/);
436
- }
437
- }
438
- setLogLines((prev) => {
439
- const next = prev.slice();
440
- for (const line of renderedLines) {
441
- const id = `l-${lineSeqRef.current}`;
442
- lineSeqRef.current += 1;
443
- next.push({ id, text: String(line || ""), kind });
444
- }
445
- return next.length > 1000 ? next.slice(-1000) : next;
446
- });
447
- }, []);
448
-
449
- const flushTableBuffer = useCallback(() => {
450
- const buffered = tableBufRef.current.flush();
451
- if (buffered == null) return;
452
- pushRenderedLogLines(buffered, "assistant");
453
- }, [pushRenderedLogLines]);
454
-
455
- const appendLogLine = useCallback((text, kind = "assistant") => {
456
- const raw = String(text == null ? "" : text);
457
- if (MARKDOWN_LOG_KINDS.has(kind)) {
458
- if (tableBufRef.current.push(raw)) return;
459
- flushTableBuffer();
460
- } else {
461
- flushTableBuffer();
462
- }
463
- pushRenderedLogLines(raw, kind);
464
- }, [flushTableBuffer, pushRenderedLogLines]);
465
-
466
- const renderMergeText = useCallback((merge) => {
467
- if (!merge || !Array.isArray(merge.entries)) return "";
468
- return fmt.buildToolMergeRowText(merge.entries);
469
- }, []);
470
-
471
- // Promote the in-flight tool group (if any) to a permanent log line.
472
- // Called before any non-tool text is logged, so the group "freezes"
473
- // exactly the way blessed updates the line in place when the next text
474
- // arrives.
475
- const flushActiveMerge = useCallback(() => {
476
- setActiveMerge((current) => {
477
- if (!current) return null;
478
- appendLogLine(renderMergeText(current), "tool");
479
- return null;
480
- });
481
- }, [appendLogLine, renderMergeText]);
482
-
483
- const logToolHint = useCallback((entry, payload) => {
484
- const tool = String((entry && entry.tool) || "").trim().toLowerCase();
485
- if (!tool) return;
486
- const resObj = payload && typeof payload === "object" ? payload : (entry && entry.result) || {};
487
- const phase = String((entry && entry.phase) || "").trim().toLowerCase();
488
- const isError = phase === "error" || resObj.ok === false;
489
- const detail = fmt.normalizeToolLogDetail(tool, entry && entry.args, resObj);
490
- const errorText = String((entry && entry.error) || resObj.error || "").trim();
491
- const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
492
-
493
- setActiveMerge((current) => {
494
- const scope = toolMergeScopeRef.current;
495
- const isNewScope = !(current && current.scope === scope);
496
- if (isNewScope) {
497
- mergeIdRef.current += 1;
498
- }
499
- const next = fmt.appendToolMergeEntry(current, toolEntry, scope, mergeIdRef.current);
500
- if (next.entries.length >= 2) lastMergeRef.current = next;
501
- return next;
502
- });
503
- }, []);
504
-
505
- const appendLogText = useCallback((text, kind = "assistant") => {
506
- // Multi-line text → split into separate log entries so <Static> keys
507
- // stay stable when streaming arrives line-by-line. Always promote any
508
- // in-flight tool group first so it freezes above the new text.
509
- // Table rows are re-batched inside appendLogLine before markdown render.
510
- const raw = String(text == null ? "" : text);
511
- if (!raw) return;
512
- flushActiveMerge();
513
- const lines = raw.split(/\r?\n/);
514
- for (const line of lines) appendLogLine(line, kind);
515
- if (MARKDOWN_LOG_KINDS.has(kind)) flushTableBuffer();
516
- }, [appendLogLine, flushActiveMerge, flushTableBuffer]);
517
-
518
- const expandLastMerge = useCallback(() => {
519
- // Try the active group first; fall back to the most recent frozen one.
520
- // Both paths must keep the "expand only once" guarantee that blessed
521
- // enforces via group.expanded.
522
- const active = activeMerge;
523
- const candidate = (active && !active.expanded && active.entries.length >= 2)
524
- ? active
525
- : (lastMergeRef.current && !lastMergeRef.current.expanded && lastMergeRef.current.entries.length >= 2
526
- ? lastMergeRef.current
527
- : null);
528
- if (!candidate) return;
529
-
530
- const lines = fmt.buildMergedToolExpandedLines(candidate.entries);
531
- for (let i = 0; i < lines.length; i += 1) {
532
- const branch = i === lines.length - 1 ? "└" : "│";
533
- appendLogLine(`${branch} ${lines[i]}`, "toolDetail");
534
- }
535
- candidate.expanded = true;
536
- if (active && active.id === candidate.id) setActiveMerge(null);
537
- if (lastMergeRef.current && lastMergeRef.current.id === candidate.id) {
538
- lastMergeRef.current = null;
539
- }
540
- }, [activeMerge, appendLogLine]);
541
-
542
- const runChainRef = useRef(Promise.resolve());
543
-
544
- const executeLine = useCallback(async (rawValue, options = {}) => {
545
- const modelSource = options.modelText != null ? options.modelText : rawValue;
546
- const logSource = options.logText != null ? options.logText : modelSource;
547
- const preserveNewlines = Boolean(options.preserveNewlines);
548
- const modelNormalized = preserveNewlines
549
- ? String(modelSource || "").trim()
550
- : String(modelSource || "").replace(/\r?\n/g, " ").trim();
551
- const logNormalized = fmt.redactUserMessageForLog(
552
- String(logSource || "").replace(/\r?\n/g, " ").trim(),
553
- );
554
- if (!modelNormalized && !logNormalized) return;
555
- toolMergeScopeRef.current += 1;
556
- flushActiveMerge();
557
- appendLogLine(`› ${logNormalized || modelNormalized}`, "user");
558
-
559
- const runtimeWorkspace = String(
560
- (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd()
561
- );
562
-
563
- let result;
564
- try {
565
- result = props.runSingleCommand(modelNormalized, runtimeWorkspace);
566
- } catch (err) {
567
- appendLogText(`Error: ${err && err.message ? err.message : "command parse failed"}`, "error");
568
- return;
569
- }
570
- if (!result || typeof result !== "object") return;
571
-
572
- switch (result.kind) {
573
- case "empty":
574
- return;
575
- case "exit":
576
- exit();
577
- return;
578
- case "probe":
579
- return;
580
- case "help":
581
- case "error":
582
- appendLogText(result.output || "");
583
- return;
584
- case "status": {
585
- try {
586
- const { summarizeSessionUsage, formatSessionUsageStatus } = require("../../code/usageStore");
587
- const usageSummary = summarizeSessionUsage({
588
- workspaceRoot: runtimeWorkspace,
589
- sessionId: (props.state && props.state.sessionId) || "",
590
- });
591
- appendLogText(formatSessionUsageStatus(usageSummary), "system");
592
- if (props.state && props.state.executionState) {
593
- const { formatPlanModeStatus } = require("../../code/context/planMode");
594
- const planLines = formatPlanModeStatus(props.state.executionState)
595
- .split("\n")
596
- .slice(0, 6)
597
- .join("\n");
598
- appendLogText(planLines, "system");
599
- }
600
- } catch (err) {
601
- appendLogText(`Error: ${err && err.message ? err.message : "status failed"}`, "error");
602
- }
603
- return;
604
- }
605
- case "model": {
606
- const applied = await applyUcodeModelCommand(props.state || {}, result, {
607
- workspaceRoot: runtimeWorkspace,
608
- });
609
- appendLogText(applied.output || "", applied.ok ? "system" : "error");
610
- if (applied.ok && result.action === "set") {
611
- try {
612
- const { buildContextMeter } = require("../../code/contextWindow");
613
- setContextMeter((prev) => {
614
- const nextMeter = buildContextMeter({
615
- usedTokens: (prev && prev.usedTokens) || 0,
616
- model: (props.state && props.state.model) || "",
617
- });
618
- if (props.state && typeof props.state === "object") {
619
- props.state.contextMeter = nextMeter;
620
- }
621
- return nextMeter;
622
- });
623
- } catch { /* ignore */ }
624
- }
625
- if (applied.ok && result.action === "set" && typeof props.persistSessionState === "function") {
626
- try {
627
- const persisted = props.persistSessionState(props.state);
628
- if (persisted && persisted.ok === false) {
629
- appendLogText(
630
- `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
631
- "error"
632
- );
633
- }
634
- } catch {
635
- // persist is best-effort after a successful model switch
636
- }
637
- }
638
- return;
639
- }
640
- case "plan": {
641
- const { applyUcodePlanCommand } = require("../../code/context/planMode");
642
- const applied = applyUcodePlanCommand(props.state || {}, result);
643
- appendLogText(applied.output || "", applied.ok ? "system" : "error");
644
- if (applied.refreshPlanUi || applied.ok) {
645
- refreshPlanUi();
646
- }
647
- if (applied.ok && typeof props.persistSessionState === "function") {
648
- try {
649
- props.persistSessionState(props.state);
650
- } catch {
651
- // best-effort
652
- }
653
- }
654
- return;
655
- }
656
- case "ubus": {
657
- setStatus({ message: "Checking bus messages...", type: "typing", showTimer: false, startedAt: Date.now() });
658
- try {
659
- const { extractAgentNickname } = require("../../code/agent");
660
- const ubusResult = await props.runUbusCommand(props.state, {
661
- workspaceRoot: runtimeWorkspace,
662
- onMessageReceived: (msg) => {
663
- const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
664
- appendLogText(`${nickname}: ${(msg && msg.task) || ""}`, "bus");
665
- },
666
- });
667
- if (!ubusResult || !ubusResult.ok) {
668
- appendLogText(`Error: ${(ubusResult && ubusResult.error) || "ubus failed"}`, "error");
669
- return;
670
- }
671
- const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
672
- if (exchanges.length > 0) {
673
- for (const exchange of exchanges) {
674
- const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
675
- appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`, "bus");
676
- }
677
- } else if (Number(ubusResult.handled) === 0) {
678
- appendLogText("ubus: no pending messages.", "system");
679
- }
680
- if (typeof props.persistSessionState === "function") {
681
- const persisted = props.persistSessionState(props.state);
682
- if (!persisted || persisted.ok === false) {
683
- appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`, "error");
684
- }
685
- }
686
- } finally {
687
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
688
- }
689
- return;
690
- }
691
- case "resume": {
692
- if (typeof props.resumeSessionState !== "function") {
693
- appendLogText("Error: resume unsupported", "error");
694
- return;
695
- }
696
- const resumed = props.resumeSessionState(props.state, result.sessionId, runtimeWorkspace);
697
- if (!resumed || !resumed.ok) {
698
- appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`, "error");
699
- return;
700
- }
701
- // Rebuild the visible log from the restored session transcript so
702
- // the user sees prior turns instead of only a status toast.
703
- markdownStateRef.current = { inCodeBlock: false };
704
- tableBufRef.current = fmt.createMarkdownTableBuffer();
705
- const history = fmt.buildUcodeSessionLogEntries(
706
- Array.isArray(props.state && props.state.nlMessages) ? props.state.nlMessages : [],
707
- { markdownState: markdownStateRef.current, idPrefix: "h", startSeq: 0 },
708
- );
709
- const bannerEntries = banner.concat([""]).map((line, idx) => ({
710
- id: `b-${idx}`,
711
- text: line,
712
- }));
713
- const notice = {
714
- id: `h-resume-${Date.now().toString(36)}`,
715
- text: `Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`,
716
- kind: "system",
717
- };
718
- const nextLines = bannerEntries.concat(history.entries).concat([notice]);
719
- setLogLines(nextLines.length > 1000 ? nextLines.slice(-1000) : nextLines);
720
- lineSeqRef.current = Math.max(
721
- bannerEntries.length + 1,
722
- Number(history.nextSeq) || 0,
723
- nextLines.length,
724
- );
725
- setActiveMerge(null);
726
- lastMergeRef.current = null;
727
- try {
728
- const {
729
- buildContextMeter,
730
- normalizeContextMeter,
731
- } = require("../../code/contextWindow");
732
- const restored = props.state && props.state.contextMeter;
733
- const nextMeter = restored && typeof restored === "object"
734
- ? normalizeContextMeter(restored, (props.state && props.state.model) || "")
735
- : buildContextMeter({
736
- usedTokens: 0,
737
- model: (props.state && props.state.model) || "",
738
- });
739
- setContextMeter(nextMeter);
740
- } catch { /* ignore */ }
741
- return;
742
- }
743
- case "tool": {
744
- const payload = result.result && typeof result.result === "object" ? result.result : {};
745
- logToolHint({
746
- tool: result.tool,
747
- args: result.args,
748
- phase: payload.ok === false ? "error" : "end",
749
- error: payload.error || "",
750
- }, payload);
751
- return;
752
- }
753
- case "nl_bg": {
754
- backgroundSeqRef.current += 1;
755
- const jobId = `bg-${Date.now().toString(36)}-${backgroundSeqRef.current.toString(36)}`;
756
- const taskRecord = {
757
- id: jobId,
758
- task: result.task,
759
- status: "running",
760
- startedAt: Date.now(),
761
- summary: "",
762
- };
763
- backgroundTasksRef.current.set(jobId, taskRecord);
764
- bumpBackground();
765
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
766
- appendLogText(`[${jobId}] started in background.`, "system");
767
-
768
- const bgState = {
769
- workspaceRoot: props.state && props.state.workspaceRoot,
770
- provider: props.state && props.state.provider,
771
- model: props.state && props.state.model,
772
- engine: props.state && props.state.engine,
773
- context: props.state && props.state.context,
774
- nlMessages: Array.isArray(props.state && props.state.nlMessages) ? props.state.nlMessages.slice() : [],
775
- sessionId: "",
776
- timeoutMs: props.state && props.state.timeoutMs,
777
- jsonOutput: false,
778
- };
779
-
780
- Promise.resolve()
781
- .then(() => props.runNaturalLanguageTask(result.task, bgState))
782
- .then((nlResult) => {
783
- taskRecord.status = nlResult && nlResult.ok ? "done" : "failed";
784
- taskRecord.finishedAt = Date.now();
785
- taskRecord.summary = String(props.formatNlResult(nlResult, false) || "").trim();
786
- const title = taskRecord.status === "done" ? "done" : "failed";
787
- appendLogText(`[${jobId}] ${title}: ${taskRecord.summary || "no summary"}`, "system");
788
- })
789
- .catch((err) => {
790
- taskRecord.status = "failed";
791
- taskRecord.finishedAt = Date.now();
792
- taskRecord.summary = err && err.message ? String(err.message) : "background task failed";
793
- appendLogText(`[${jobId}] failed: ${taskRecord.summary}`, "system");
794
- })
795
- .finally(() => {
796
- bumpBackground();
797
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
798
- });
799
- return;
800
- }
801
- case "nl": {
802
- const startedAt = Date.now();
803
- const abortController = new AbortController();
804
- pendingTaskRef.current = { abortController, startedAt };
805
- const setNlStatus = (msg) => {
806
- const projection = refreshPlanUi(msg);
807
- const message = projection && projection.hasPlan && projection.activityStatusLine
808
- ? projection.activityStatusLine
809
- : msg;
810
- setStatus({
811
- message,
812
- type: "thinking",
813
- showTimer: true,
814
- startedAt,
815
- });
816
- };
817
- const cancelThinkingFlush = () => {
818
- if (thinkingTimerRef.current) {
819
- clearTimeout(thinkingTimerRef.current);
820
- thinkingTimerRef.current = null;
821
- }
822
- };
823
- const flushThinkingStatus = () => {
824
- thinkingFlushAtRef.current = Date.now();
825
- setNlStatus(collapseThinkingTail(thinkingTailRef.current) || "Thinking...");
826
- };
827
- setNlStatus("Waiting for model...");
828
- let streamBuf = "";
829
- let sawStreamText = false;
830
- let streamStarted = false;
831
- let dropLeadingStreamBlank = false;
832
- let nlResult = null;
833
- try {
834
- nlResult = await props.runNaturalLanguageTask(result.task, props.state, {
835
- signal: abortController.signal,
836
- onContextUsage: (meter) => {
837
- if (!meter || typeof meter !== "object") return;
838
- setContextMeter(meter);
839
- },
840
- onPhase: (event) => {
841
- if (!event || typeof event !== "object") return;
842
- if (event.type === "request_start") {
843
- cancelThinkingFlush();
844
- setNlStatus("Waiting for model...");
845
- } else if (event.type === "thinking_delta") {
846
- thinkingTailRef.current += String(event.text || "");
847
- const elapsed = Date.now() - thinkingFlushAtRef.current;
848
- if (elapsed >= THINKING_STATUS_THROTTLE_MS) {
849
- cancelThinkingFlush();
850
- flushThinkingStatus();
851
- } else if (!thinkingTimerRef.current) {
852
- // Trailing flush guarantees the final tail lands even
853
- // when the stream ends inside a throttle window.
854
- thinkingTimerRef.current = setTimeout(() => {
855
- thinkingTimerRef.current = null;
856
- flushThinkingStatus();
857
- }, THINKING_STATUS_THROTTLE_MS - elapsed);
858
- }
859
- } else if (event.type === "text_delta") {
860
- cancelThinkingFlush();
861
- setNlStatus("Generating response...");
862
- } else if (event.type === "tool_request") {
863
- cancelThinkingFlush();
864
- const label = fmt.TOOL_LABELS[String(event.name || "").toLowerCase()] ||
865
- `Calling ${event.name}`;
866
- setNlStatus(`${label}...`);
867
- }
868
- },
869
- onDelta: (delta) => {
870
- const text = String(delta || "");
871
- if (!text) return;
872
- if (!streamStarted) {
873
- flushActiveMerge();
874
- streamStarted = true;
875
- }
876
- const split = fmt.splitStreamingLogChunk(streamBuf, text, {
877
- dropLeadingBlank: dropLeadingStreamBlank,
878
- });
879
- if (split.sawVisible) {
880
- sawStreamText = true;
881
- dropLeadingStreamBlank = false;
882
- }
883
- for (const line of split.lines) {
884
- appendLogLine(line);
885
- }
886
- streamBuf = split.buffer;
887
- },
888
- onToolLog: (entry) => {
889
- if (!entry || typeof entry !== "object") return;
890
- if (entry.tool && entry.phase === "start") {
891
- const label = fmt.TOOL_LABELS[String(entry.tool || "").toLowerCase()] ||
892
- `Calling ${entry.tool}`;
893
- setNlStatus(`${label}...`);
894
- dropLeadingStreamBlank = true;
895
- }
896
- if (entry.tool === "plan_graph" || entry.phase === "end" || entry.phase === "result") {
897
- refreshPlanUi();
898
- }
899
- logToolHint(entry, entry.result);
900
- },
901
- });
902
- } catch (err) {
903
- appendLogText(`Error: ${err && err.message ? err.message : "agent loop failed"}`, "error");
904
- return;
905
- } finally {
906
- pendingTaskRef.current = null;
907
- cancelThinkingFlush();
908
- thinkingTailRef.current = "";
909
- refreshPlanUi();
910
- bumpQueue();
911
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
912
- }
913
- if (streamBuf) {
914
- if (/[^\s]/.test(streamBuf)) sawStreamText = true;
915
- appendLogLine(streamBuf);
916
- }
917
- flushTableBuffer();
918
- // Skip the summary echo when the model already streamed its
919
- // response in full — otherwise the user sees the same text twice.
920
- // Mirrors the shouldSkipSummary check in tui.js.
921
- const streamed = Boolean(nlResult && nlResult.streamed);
922
- const ok = Boolean(nlResult && nlResult.ok);
923
- const shouldSkipSummary = streamed && ok && sawStreamText;
924
- if (!shouldSkipSummary) {
925
- const summary = props.formatNlResult(nlResult, false);
926
- if (summary) appendLogText(summary);
927
- }
928
- flushActiveMerge();
929
- if (nlResult && nlResult.contextMeter) {
930
- setContextMeter(nlResult.contextMeter);
931
- } else if (props.state && props.state.contextMeter) {
932
- setContextMeter(props.state.contextMeter);
933
- }
934
- try {
935
- const persisted = props.persistSessionState(props.state);
936
- if (persisted && persisted.ok === false) {
937
- appendLogText(
938
- `Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
939
- "error"
940
- );
941
- }
942
- } catch {
943
- // persistSessionState failures shouldn't crash the TUI.
944
- }
945
- return;
946
- }
947
- default:
948
- if (result.output) appendLogText(result.output);
949
- }
950
- }, [appendLogLine, appendLogText, exit, props, logToolHint, flushActiveMerge, flushTableBuffer, refreshPlanUi, bumpQueue]);
951
- // ^ `props` is captured by the createUcodeApp closure on a single mount,
952
- // so its reference is stable across renders even though it looks like a
953
- // changing dep to React's exhaustive-deps lint.
954
-
955
- const runAutoBusOnce = useCallback(async () => {
956
- const autoBus = props.autoBus || {};
957
- if (!autoBus.enabled || pendingTaskRef.current) return;
958
- const getPendingCount = typeof autoBus.getPendingCount === "function"
959
- ? autoBus.getPendingCount
960
- : () => 0;
961
- if (Number(getPendingCount()) <= 0) {
962
- autoBusErrorRef.current = "";
963
- return;
964
- }
965
-
966
- const abortController = new AbortController();
967
- const startedAt = Date.now();
968
- pendingTaskRef.current = { abortController, startedAt };
969
- setStatus({
970
- message: "Processing bus messages...",
971
- type: "thinking",
972
- showTimer: true,
973
- startedAt,
974
- });
975
-
976
- try {
977
- const { extractAgentNickname } = require("../../code/agent");
978
- const ubusResult = await props.runUbusCommand(props.state, {
979
- workspaceRoot: props.workspaceRoot,
980
- subscriberId: autoBus.subscriberId,
981
- signal: abortController.signal,
982
- onMessageReceived: (msg) => {
983
- const nickname = extractAgentNickname(msg && msg.from) || (msg && msg.from) || "bus";
984
- appendLogText(`${nickname}: ${(msg && msg.task) || ""}`, "bus");
985
- setStatus({
986
- message: "Working on task...",
987
- type: "thinking",
988
- showTimer: true,
989
- startedAt,
990
- });
991
- },
992
- });
993
-
994
- if (!ubusResult || !ubusResult.ok) {
995
- const nextError = String((ubusResult && ubusResult.error) || "ubus failed");
996
- if (nextError !== autoBusErrorRef.current) {
997
- autoBusErrorRef.current = nextError;
998
- appendLogText(`Error: ${nextError}`, "error");
999
- }
1000
- return;
1001
- }
1002
-
1003
- autoBusErrorRef.current = "";
1004
- const exchanges = Array.isArray(ubusResult.messageExchanges) ? ubusResult.messageExchanges : [];
1005
- for (const exchange of exchanges) {
1006
- const nickname = extractAgentNickname(exchange && exchange.from) || (exchange && exchange.from) || "bus";
1007
- appendLogText(`@${nickname} ${(exchange && exchange.reply) || ""}`, "bus");
1008
- }
1009
- if (Number(ubusResult.handled) > 0 && typeof props.persistSessionState === "function") {
1010
- const persisted = props.persistSessionState(props.state);
1011
- if (!persisted || persisted.ok === false) {
1012
- appendLogText(`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${(persisted && persisted.error) || "unknown error"}`, "error");
1013
- }
1014
- }
1015
- } finally {
1016
- pendingTaskRef.current = null;
1017
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
1018
- }
1019
- }, [appendLogText, props]);
1020
-
1021
- useEffect(() => {
1022
- if (!interactive || !(props.autoBus && props.autoBus.enabled)) return undefined;
1023
- const schedule = () => {
1024
- if (autoBusQueuedRef.current || pendingTaskRef.current) return;
1025
- const getPendingCount = typeof props.autoBus.getPendingCount === "function"
1026
- ? props.autoBus.getPendingCount
1027
- : () => 0;
1028
- if (Number(getPendingCount()) <= 0) return;
1029
- autoBusQueuedRef.current = true;
1030
- runChainRef.current = runChainRef.current
1031
- .then(() => runAutoBusOnce())
1032
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : "ubus failed"}`, "error"))
1033
- .finally(() => {
1034
- autoBusQueuedRef.current = false;
1035
- });
1036
- };
1037
- const timer = setInterval(schedule, 1500);
1038
- schedule();
1039
- return () => clearInterval(timer);
1040
- }, [interactive, props.autoBus, runAutoBusOnce, appendLogText]);
1041
-
1042
- const submit = useCallback((submitted) => {
1043
- const value = String(submitted == null ? draft : submitted);
1044
- const attachments = Array.isArray(imageAttachments) ? imageAttachments.slice() : [];
1045
- const trimmed = value.trim();
1046
- if (!trimmed && attachments.length === 0) return;
1047
- setDraft("");
1048
- setDraftVersion((v) => v + 1);
1049
- setImageAttachments([]);
1050
- setInputHistory((prev) => {
1051
- const historyValue = formatUserLogWithAttachments(trimmed, attachments) || trimmed;
1052
- const next = prev.concat([historyValue]).slice(-200);
1053
- setHistoryIndex(next.length);
1054
- return next;
1055
- });
1056
-
1057
- const modelText = `${buildAttachedImagesPromptPrefix(attachments)}${trimmed}`.trim();
1058
- const logText = formatUserLogWithAttachments(trimmed, attachments);
1059
-
1060
- // Pending approval/choice/chat takes priority over nudge / new NL.
1061
- try {
1062
- const { hasPendingUserInteraction } = require("../../code/context/userInteraction");
1063
- if (props.state && props.state.executionState && hasPendingUserInteraction(props.state.executionState)) {
1064
- appendLogText(`› ${logText}`, "user");
1065
- const startedAt = Date.now();
1066
- setStatus({
1067
- message: "Applying your reply...",
1068
- type: "thinking",
1069
- showTimer: true,
1070
- startedAt,
1071
- });
1072
- runChainRef.current = runChainRef.current
1073
- .then(async () => {
1074
- const submit = typeof props.submitUserInteractionAnswer === "function"
1075
- ? props.submitUserInteractionAnswer
1076
- : require("../../code/protocol").submitUserInteractionAnswer;
1077
- let streamBuf = "";
1078
- let sawStreamText = false;
1079
- let streamStarted = false;
1080
- let dropLeadingStreamBlank = false;
1081
- const result = await submit(trimmed, props.state, {
1082
- onContextUsage: (meter) => {
1083
- if (!meter || typeof meter !== "object") return;
1084
- setContextMeter(meter);
1085
- },
1086
- onDelta: (delta) => {
1087
- const text = String(delta || "");
1088
- if (!text) return;
1089
- if (!streamStarted) {
1090
- flushActiveMerge();
1091
- streamStarted = true;
1092
- }
1093
- const split = fmt.splitStreamingLogChunk(streamBuf, text, {
1094
- dropLeadingBlank: dropLeadingStreamBlank,
1095
- });
1096
- if (split.sawVisible) {
1097
- sawStreamText = true;
1098
- dropLeadingStreamBlank = false;
1099
- }
1100
- for (const line of split.lines) {
1101
- appendLogLine(line);
1102
- }
1103
- streamBuf = split.buffer;
1104
- },
1105
- });
1106
- if (streamBuf) {
1107
- if (/[^\s]/.test(streamBuf)) sawStreamText = true;
1108
- appendLogLine(streamBuf);
1109
- }
1110
- flushTableBuffer();
1111
- refreshPlanUi();
1112
- if (result && result.contextMeter) {
1113
- setContextMeter(result.contextMeter);
1114
- } else if (props.state && props.state.contextMeter) {
1115
- setContextMeter(props.state.contextMeter);
1116
- }
1117
- if (!result || result.ok === false) {
1118
- appendLogText(`Error: ${(result && result.error) || "resume failed"}`, "error");
1119
- } else if (result.shouldEchoSummary) {
1120
- appendLogText(result.echoSummaryText || result.summary || "", result.waitingUserInteraction ? "system" : "assistant");
1121
- } else if (result.waitingUserInteraction) {
1122
- appendLogText("Still waiting for your reply.", "system");
1123
- }
1124
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
1125
- })
1126
- .catch((err) => {
1127
- appendLogText(`Error: ${err && err.message ? err.message : err}`, "error");
1128
- setStatus({ message: "", type: "thinking", showTimer: false, startedAt: 0 });
1129
- });
1130
- return;
1131
- }
1132
- } catch (err) {
1133
- appendLogText(`Error: ${err && err.message ? err.message : "interaction failed"}`, "error");
1134
- return;
1135
- }
1136
-
1137
- // While a native task is in flight, queue an additional user reminder
1138
- // for the next LLM turn instead of starting a second NL task.
1139
- // Slash commands (/model, /plan, …) must still run immediately — same
1140
- // rule as the REPL path — otherwise they pollute the nudge queue.
1141
- if (pendingTaskRef.current) {
1142
- if (/^\//.test(trimmed)) {
1143
- runChainRef.current = runChainRef.current
1144
- .then(() => executeLine(modelText, {
1145
- modelText,
1146
- logText,
1147
- preserveNewlines: attachments.length > 0,
1148
- }))
1149
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
1150
- return;
1151
- }
1152
- const { enqueueUserPrompt } = require("../../code/context/userNudge");
1153
- const { emptyExecutionState } = require("../../code/context/executionSegment");
1154
- if (!props.state || typeof props.state !== "object") {
1155
- appendLogText("Error: missing session state for user reminder", "error");
1156
- return;
1157
- }
1158
- if (!props.state.executionState || typeof props.state.executionState !== "object") {
1159
- props.state.executionState = emptyExecutionState();
1160
- }
1161
- enqueueUserPrompt(props.state.executionState, modelText);
1162
- bumpQueue();
1163
- return;
1164
- }
1165
-
1166
- // Serialize executions so streaming tasks don't interleave.
1167
- runChainRef.current = runChainRef.current
1168
- .then(() => executeLine(modelText, {
1169
- modelText,
1170
- logText,
1171
- preserveNewlines: attachments.length > 0,
1172
- }))
1173
- .catch((err) => appendLogText(`Error: ${err && err.message ? err.message : err}`, "error"));
1174
- }, [
1175
- draft,
1176
- imageAttachments,
1177
- executeLine,
1178
- appendLogText,
1179
- appendLogLine,
1180
- flushActiveMerge,
1181
- flushTableBuffer,
1182
- bumpQueue,
1183
- props.state,
1184
- props.submitUserInteractionAnswer,
1185
- refreshPlanUi,
1186
- ]);
1187
-
1188
- useEffect(() => {
1189
- if (!stdout) return undefined;
1190
- const update = () => {
1191
- const next = { cols: stdout.columns || 0, rows: stdout.rows || 0 };
1192
- setSize((prev) => (prev.cols === next.cols && prev.rows === next.rows ? prev : next));
1193
- };
1194
- update();
1195
- stdout.on("resize", update);
1196
- return () => stdout.off("resize", update);
1197
- }, [stdout]);
1198
-
1199
- useEffect(() => {
1200
- refreshPlanUi();
1201
- }, [refreshPlanUi]);
1202
-
1203
- // Drive the spinner + elapsed-timer redraws while a task is in flight.
1204
- useEffect(() => {
1205
- const statusType = inferStatusType(status.message, status.type);
1206
- if (!status.message || statusType === "none" || statusType === "idle" ||
1207
- statusType === "done" || statusType === "success" || statusType === "error") {
1208
- return undefined;
1209
- }
1210
- const timer = setInterval(() => {
1211
- setSpinnerTick((t) => t + 1);
1212
- }, 100);
1213
- return () => clearInterval(timer);
1214
- }, [status.message, status.type, status.showTimer]);
1215
-
1216
- const statusText = useMemoStatusText(
1217
- React,
1218
- status,
1219
- spinnerTick,
1220
- getBackgroundSuffix(),
1221
- !status.message ? (planUi.idleHint || "") : ""
1222
- );
1223
-
1224
- // Top-level catches Ctrl+C / Ctrl+O, plus completion popup navigation
1225
- // while a slash/agent menu is open.
1226
- useInput((input, key) => {
1227
- if (key.ctrl && input === "c") { exit(); return; }
1228
- if (key.ctrl && input === "o") { expandLastMerge(); return; }
1229
- if (!completionsOpen) return;
1230
- if (key.upArrow) {
1231
- setCompletionIndex((i) => {
1232
- const next = (i - 1 + completions.length) % completions.length;
1233
- setCompletionWindowStart((ws) => {
1234
- if (next < ws) return next;
1235
- if (next === completions.length - 1) {
1236
- return Math.max(0, completions.length - POPUP_PAGE_SIZE);
1237
- }
1238
- return ws;
1239
- });
1240
- return next;
1241
- });
1242
- return;
1243
- }
1244
- if (key.downArrow) {
1245
- setCompletionIndex((i) => {
1246
- const next = (i + 1) % completions.length;
1247
- setCompletionWindowStart((ws) => {
1248
- if (next === 0) return 0;
1249
- if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
1250
- return ws;
1251
- });
1252
- return next;
1253
- });
1254
- return;
1255
- }
1256
- if (key.return) {
1257
- // Leaf completions (e.g. /resume <session>) run immediately on Enter.
1258
- // Parents with children only fill the draft so the next menu can open.
1259
- const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
1260
- if (item && !item.hasChildren) {
1261
- const cmd = String(item.replace || "").trim();
1262
- setCompletionIndex(0);
1263
- setCompletionSuppressedDraft(null);
1264
- if (cmd) submit(cmd);
1265
- return;
1266
- }
1267
- acceptCompletion();
1268
- return;
1269
- }
1270
- if (key.tab) {
1271
- acceptCompletion();
1272
- return;
1273
- }
1274
- if (key.escape) {
1275
- setCompletionSuppressedDraft(null);
1276
- setDraft("");
1277
- setDraftVersion((v) => v + 1);
1278
- }
1279
- }, { isActive: interactive });
1280
-
1281
- return h(Box, { flexDirection: "column", width: "100%" },
1282
- h(Box, { flexDirection: "column", width: "100%" },
1283
- ...(() => {
1284
- // Re-render raw markdown at paint time so leftover ** / ### / tables
1285
- // from older append paths or nested `**code**` patterns still resolve.
1286
- const mdState = { inCodeBlock: false };
1287
- return logLines.map((item, idx) => {
1288
- let text = item.text || " ";
1289
- if (MARKDOWN_LOG_KINDS.has(item.kind) && /(?:\*\*|__|^\s*#{1,6}\s|^\s*`{3}|^\s*\|)/m.test(text)) {
1290
- try {
1291
- const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
1292
- if (Array.isArray(rendered) && rendered.length > 0) {
1293
- text = rendered.length === 1 ? rendered[0] : rendered.join("\n");
1294
- }
1295
- } catch {
1296
- // keep original
1297
- }
1298
- } else if (MARKDOWN_LOG_KINDS.has(item.kind) && mdState.inCodeBlock) {
1299
- try {
1300
- const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
1301
- if (Array.isArray(rendered) && rendered[0] != null) text = rendered[0];
1302
- } catch {
1303
- // keep original
1304
- }
1305
- }
1306
- const textEl = h(Text, { ...resolveLogLineTextProps(item.kind) }, text || " ");
1307
- // Give user turns a blank line above/below so › prompts don't
1308
- // sit flush against system/tool rows. Multi-line user blocks
1309
- // only pad the outer edges.
1310
- if (item.kind === "user") {
1311
- const prev = logLines[idx - 1];
1312
- const next = logLines[idx + 1];
1313
- const marginTop = !prev || prev.kind !== "user" ? 1 : 0;
1314
- const marginBottom = !next || next.kind !== "user" ? 1 : 0;
1315
- return h(Box, {
1316
- key: item.id,
1317
- width: "100%",
1318
- marginTop,
1319
- marginBottom,
1320
- }, textEl);
1321
- }
1322
- return h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, text || " ");
1323
- });
1324
- })()
1325
- ),
1326
- activeMerge ? h(Box, null,
1327
- h(Text, { color: activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
1328
- renderMergeText(activeMerge)
1329
- ),
1330
- ) : null,
1331
- planUi.visible && (planUi.roadmapMarkdown || (planUi.bandLines && planUi.bandLines.length > 0))
1332
- ? h(Box, {
1333
- flexDirection: "column",
1334
- width: "100%",
1335
- marginTop: 1,
1336
- },
1337
- ...(() => {
1338
- let lines = Array.isArray(planUi.bandLines) ? planUi.bandLines.slice() : [];
1339
- const md = String(planUi.roadmapMarkdown || "").trim();
1340
- if (md) {
1341
- try {
1342
- const rendered = fmt.renderLogLinesWithMarkdownAnsi(md, { inCodeBlock: false });
1343
- if (Array.isArray(rendered) && rendered.length > 0) lines = rendered;
1344
- } catch {
1345
- lines = md.split(/\r?\n/);
1346
- }
1347
- }
1348
- return lines.map((line, idx) => h(Text, {
1349
- key: `plan-band-${idx}`,
1350
- color: md ? undefined : "magenta",
1351
- dimColor: !md && idx > 0,
1352
- wrap: "truncate",
1353
- }, line || " "));
1354
- })(),
1355
- )
1356
- : null,
1357
- interactionLines.length > 0
1358
- ? h(Box, {
1359
- flexDirection: "column",
1360
- width: "100%",
1361
- marginTop: 1,
1362
- },
1363
- ...interactionLines.map((line, idx) => h(Text, {
1364
- key: `ask-${idx}`,
1365
- color: "yellow",
1366
- wrap: "wrap",
1367
- }, line || " ")),
1368
- )
1369
- : null,
1370
- h(Box, { marginTop: 1, width: "100%" },
1371
- h(Text, { color: "gray" }, statusText),
1372
- h(Box, { flexGrow: 1 }),
1373
- h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
1374
- ),
1375
- completionsOpen ? (() => {
1376
- const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
1377
- const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
1378
- const visible = completions.slice(start, end);
1379
- const cols = Math.max(8, size.cols || 80);
1380
- // Frame the popup with a top rule; MultilineInput's borderTop is the
1381
- // matching bottom rule, so we intentionally omit a trailing ─ here.
1382
- return h(Box, { flexDirection: "column", width: "100%" },
1383
- h(Text, { color: "gray" }, "─".repeat(cols)),
1384
- ...visible.map((s, idxInWindow) => {
1385
- const idx = start + idxInWindow;
1386
- const selected = idx === completionIndex;
1387
- // Keep label+description in one Text. Splitting into sibling
1388
- // Text nodes with wrap:"truncate" lets Yoga shrink the label
1389
- // and mid-cut commands (e.g. "/help" → "/he p").
1390
- const line = s.description
1391
- ? `${s.label} ${s.description}`
1392
- : String(s.label || "");
1393
- return h(Box, { key: `cmp-${idx}`, width: "100%" },
1394
- h(Text, {
1395
- color: selected ? "cyan" : "gray",
1396
- inverse: selected,
1397
- wrap: "truncate",
1398
- }, line),
1399
- );
1400
- }),
1401
- );
1402
- })() : null,
1403
- imageAttachments.length > 0
1404
- ? h(Box, { flexDirection: "column", width: "100%", marginBottom: 0 },
1405
- h(Text, { color: "cyan", dimColor: true },
1406
- imageAttachments.map((item) => {
1407
- const name = item.fileName || require("path").basename(String(item.relPath || "image"));
1408
- return `[img] ${name}`;
1409
- }).join(" "),
1410
- ),
1411
- )
1412
- : null,
1413
- (() => {
1414
- void queueTick;
1415
- let pending = [];
1416
- try {
1417
- const { listPendingUserPrompts } = require("../../code/context/userNudge");
1418
- pending = listPendingUserPrompts(props.state && props.state.executionState);
1419
- } catch {
1420
- pending = [];
1421
- }
1422
- if (pending.length === 0) return null;
1423
- const latest = String(pending[pending.length - 1] || "");
1424
- const more = pending.length > 1 ? ` · +${pending.length - 1}` : "";
1425
- const preview = latest.length > 72 ? `${latest.slice(0, 72)}…` : latest;
1426
- return h(Box, { width: "100%" },
1427
- h(Text, { color: "yellow", wrap: "truncate" },
1428
- `排队中 · 未发出 · ${preview}${more}`,
1429
- ),
1430
- );
1431
- })(),
1432
- h(Box, { width: "100%" },
1433
- h(MultilineInput, {
1434
- value: draft,
1435
- valueVersion: draftVersion,
1436
- onChange: (next) => {
1437
- if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
1438
- setCompletionSuppressedDraft(null);
1439
- }
1440
- setDraft(next);
1441
- },
1442
- onPasteText: (filtered) => {
1443
- const workspaceRoot = String(
1444
- (props.state && props.state.workspaceRoot) || props.workspaceRoot || process.cwd(),
1445
- );
1446
- const sessionId = String((props.state && props.state.sessionId) || "session");
1447
- const outcome = handleImagePaste(filtered, {
1448
- workspaceRoot,
1449
- sessionId,
1450
- tryClipboard: true,
1451
- });
1452
- if (Array.isArray(outcome.attachments) && outcome.attachments.length > 0) {
1453
- setImageAttachments((prev) => {
1454
- const next = prev.slice();
1455
- for (const item of outcome.attachments) {
1456
- if (!item || !item.relPath) continue;
1457
- if (next.some((existing) => existing.relPath === item.relPath)) continue;
1458
- next.push(item);
1459
- }
1460
- return next;
1461
- });
1462
- }
1463
- if (Array.isArray(outcome.errors) && outcome.errors.length > 0 && outcome.attachments.length === 0) {
1464
- // Soft notice only when nothing was ingested.
1465
- appendLogText(`Image paste: ${outcome.errors[0]}`, "system");
1466
- }
1467
- return { text: outcome.text == null ? filtered : outcome.text };
1468
- },
1469
- onSubmit: (value) => {
1470
- setCompletionSuppressedDraft(null);
1471
- submit(value);
1472
- },
1473
- onCancel: () => {
1474
- if (completionsOpen) {
1475
- setCompletionSuppressedDraft(null);
1476
- setDraft("");
1477
- setDraftVersion((v) => v + 1);
1478
- return;
1479
- }
1480
- // If a task is in flight, Esc requests cancellation. Otherwise
1481
- // it clears the agent selection (matches blessed). The text
1482
- // value is left alone so the user doesn't lose what they typed.
1483
- const pending = pendingTaskRef.current;
1484
- if (pending && pending.abortController && !pending.abortController.signal.aborted) {
1485
- try { pending.abortController.abort(); } catch { /* ignore */ }
1486
- try {
1487
- const { clearUserPrompts } = require("../../code/context/userNudge");
1488
- if (props.state && props.state.executionState) {
1489
- clearUserPrompts(props.state.executionState);
1490
- }
1491
- bumpQueue();
1492
- } catch { /* ignore */ }
1493
- appendLogLine("⚙ Cancellation requested. Stopping the current task...", "system");
1494
- setStatus({
1495
- message: "Cancelling...",
1496
- type: "waiting",
1497
- showTimer: true,
1498
- startedAt: pending.startedAt,
1499
- });
1500
- return;
1501
- }
1502
- if (agentSelectionMode) {
1503
- setAgentSelectionMode(false);
1504
- setSelectedAgentIndex(-1);
1505
- }
1506
- },
1507
- onArrowDownAtBottom: onArrowDownAtEnd,
1508
- onArrowUpAtTop: onArrowUpAtStart,
1509
- onArrowLeftAtEmpty: () => onArrowSideAtEmpty("left"),
1510
- onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
1511
- width: Math.max(20, (size.cols || 80) - 4),
1512
- interactive,
1513
- interceptArrowsAndEnter: completionsOpen,
1514
- placeholder: "",
1515
- promptPrefix: targetAgent ? `›@${getAgentLabel(targetAgent)} ` : "› ",
1516
- // Completions render ABOVE the input. Only the Agents footer is
1517
- // below — counting popup rows here parks the hardware cursor up
1518
- // into the menu (ghost block on /status etc.).
1519
- linesBelowInput: 1,
1520
- // During model/tool activity ucode redraws the status line every
1521
- // spinner frame. Keeping the hardware cursor hidden avoids a
1522
- // visible hide/show flash; the inverse caret remains rendered and
1523
- // the cursor position is still parked for IME composition.
1524
- showHardwareCursor: !status.message,
1525
- }),
1526
- ),
1527
- h(Box, { width: "100%" },
1528
- h(Text, { wrap: "truncate", color: "gray" }, "Agents: "),
1529
- agents.length === 0
1530
- ? h(Text, { wrap: "truncate", color: "cyan" }, "none")
1531
- : (() => {
1532
- const labels = agents.map((a) => `@${getAgentLabel(a)}`);
1533
- // Reserve space for the context meter on the right plus the
1534
- // "Agents: " prefix / hint. Clamp aggressively when cols unknown.
1535
- const cols = size.cols || 80;
1536
- const meterLabel = String((contextMeter && contextMeter.label) || "").trim();
1537
- const reservedForMeter = meterLabel
1538
- ? fmt.displayCellWidth(` ${meterLabel}`) + 1
1539
- : 0;
1540
- const reservedForHint = fmt.displayCellWidth(` · ${agentsHint}`);
1541
- const budget = Math.max(12, cols - 10 - reservedForHint - reservedForMeter);
1542
- const plan = fmt.planAgentsFooter(
1543
- labels,
1544
- agentSelectionMode ? selectedAgentIndex : -1,
1545
- budget
1546
- );
1547
- return h(React.Fragment, null,
1548
- ...plan.items.map((item, idx) =>
1549
- h(React.Fragment, { key: idx },
1550
- idx > 0 ? h(Text, { color: "gray" }, " ") : null,
1551
- h(Text, {
1552
- wrap: "truncate",
1553
- color: item.selected ? undefined : "cyan",
1554
- inverse: item.selected,
1555
- }, item.label),
1556
- )
1557
- ),
1558
- plan.hint
1559
- ? h(Text, { wrap: "truncate", color: "gray" }, plan.hint)
1560
- : null,
1561
- );
1562
- })(),
1563
- h(Text, { wrap: "truncate", color: "gray" }, ` · ${agentsHint}`),
1564
- h(Box, { flexGrow: 1 }),
1565
- h(Text, { wrap: "truncate", color: "gray" },
1566
- String((contextMeter && contextMeter.label) || "").trim() || "0 / 200K"),
1567
- ),
1568
- );
1569
- };
1570
- }
1571
-
1572
- function runUcodeInkTui(props = {}) {
1573
- return new Promise((resolve, reject) => {
1574
- runInk(
1575
- (React, ink) => {
1576
- const UcodeApp = createUcodeApp({ React, ink, props });
1577
- return React.createElement(UcodeApp);
1578
- },
1579
- {
1580
- stdin: props.stdin || process.stdin,
1581
- stdout: props.stdout || process.stdout,
1582
- exitOnCtrlC: true,
1583
- }
1584
- )
1585
- .then(async (handle) => {
1586
- try {
1587
- await handle.waitUntilExit();
1588
- resolve({ code: 0 });
1589
- } catch (err) {
1590
- reject(err);
1591
- }
1592
- })
1593
- .catch(reject);
1594
- });
1595
- }
1596
-
1597
- module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText, collapseThinkingTail, resolveLogLineTextProps };
1598
-
1599
- function inferStatusType(text = "", requestedType = "") {
1600
- const type = String(requestedType || "").trim().toLowerCase();
1601
- if (type === "done" || type === "success" || type === "error" || type === "idle" || type === "none") {
1602
- return type;
1603
- }
1604
- const clean = String(text || "").trim();
1605
- if (/^[✗!]/.test(clean) || /\b(error|failed|failure)\b/i.test(clean) || /失败|错误/.test(clean)) return "error";
1606
- if (
1607
- /^[✓✔]/.test(clean) ||
1608
- /^(done|complete|completed|finished|success|succeeded|ready)\b/i.test(clean) ||
1609
- /\bdone\s*$/i.test(clean) ||
1610
- /完成|成功/.test(clean)
1611
- ) return "done";
1612
- return type || "thinking";
1613
- }
1614
-
1615
- /**
1616
- * Pure status-line text builder used by the React component (and unit
1617
- * tests). Returns "UCODE · Ready" while idle and a spinner+message+timer
1618
- * combination while a task is in flight, mirroring updateStatus() in the
1619
- * blessed implementation.
1620
- */
1621
- function collapseThinkingTail(text, maxChars = 80) {
1622
- const collapsed = String(text || "").replace(/\s+/g, " ").trim();
1623
- const parsed = Number(maxChars);
1624
- const limit = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
1625
- if (!collapsed) return "";
1626
-
1627
- // Prefer the latest markdown emphasis / section so the status line shows the
1628
- // current thought instead of a mid-word tail of an earlier heading.
1629
- let candidate = collapsed;
1630
- const boldParts = collapsed.match(/\*\*[^*]+\*\*/g);
1631
- if (boldParts && boldParts.length > 0) {
1632
- candidate = boldParts[boldParts.length - 1].replace(/\*/g, "").trim() || candidate;
1633
- } else {
1634
- const clauses = collapsed.split(/(?<=[.!?。!?])\s+/).map((part) => part.trim()).filter(Boolean);
1635
- if (clauses.length > 1) candidate = clauses[clauses.length - 1];
1636
- }
1637
-
1638
- if (candidate.length <= limit) return candidate;
1639
- return `…${candidate.slice(-(limit - 1))}`;
1640
- }
1641
-
1642
- function computeStatusText(status, spinnerTick, backgroundSuffix = "", idlePlanHint = "") {
1643
- const message = String((status && status.message) || "");
1644
- const suffix = String(backgroundSuffix || "");
1645
- if (!message) {
1646
- const hint = String(idlePlanHint || "").trim();
1647
- return hint ? `UCODE · Ready · ${hint}${suffix}` : `UCODE · Ready${suffix}`;
1648
- }
1649
- const type = inferStatusType(message, status && status.type);
1650
- if (type === "done" || type === "success") {
1651
- const clean = message.trim();
1652
- return `${/^[✓✔]/.test(clean) ? clean : `✓ ${clean}`}${suffix}`;
1653
- }
1654
- if (type === "error") {
1655
- const clean = message.trim();
1656
- return `${/^[✗!]/.test(clean) ? clean : `✗ ${clean}`}${suffix}`;
1657
- }
1658
- if (type === "idle" || type === "none") return `${message.trim() || "UCODE · Ready"}${suffix}`;
1659
- const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
1660
- const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
1661
- const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
1662
- const timerText = status && status.showTimer && startedAt
1663
- ? ` (${fmt.formatPendingElapsed(Date.now() - startedAt)}, esc cancel)`
1664
- : "";
1665
- return `${indicator} ${message}${timerText}${suffix}`;
1666
- }
1667
-
1668
- function useMemoStatusText(React, status, spinnerTick, backgroundSuffix = "", idlePlanHint = "") {
1669
- // Dependencies intentionally include startedAt so the timer ticks even
1670
- // when the message string is unchanged.
1671
- return React.useMemo(
1672
- () => computeStatusText(status, spinnerTick, backgroundSuffix, idlePlanHint),
1673
- [status, spinnerTick, backgroundSuffix, idlePlanHint]
1674
- );
1675
- }