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.
- package/README.md +25 -9
- package/README.zh-CN.md +22 -9
- package/dist/tui/darwin-arm64/ufoo-tui +0 -0
- package/dist/tui/darwin-x64/ufoo-tui +0 -0
- package/dist/tui/linux-arm64/ufoo-tui +0 -0
- package/dist/tui/linux-x64/ufoo-tui +0 -0
- package/package.json +12 -4
- package/scripts/pack-tui.js +112 -0
- package/scripts/postinstall.js +11 -0
- package/src/agents/activity/activityReconcile.js +106 -0
- package/src/agents/activity/activityStatePublisher.js +31 -2
- package/src/agents/activity/index.js +1 -0
- package/src/agents/launch/launcher.js +19 -0
- package/src/agents/launch/ptyRunner.js +20 -1
- package/src/app/chat/ChatController.js +433 -0
- package/src/app/chat/agentDirectory.js +63 -0
- package/src/app/chat/agentEnter.js +70 -0
- package/src/app/chat/agentIdentity.js +50 -0
- package/src/app/chat/bootstrap.js +66 -0
- package/src/app/chat/commandExecutor.js +108 -0
- package/src/app/chat/commands.js +38 -1
- package/src/app/chat/dashboardView.js +6 -2
- package/src/app/chat/historyStore.js +181 -0
- package/src/app/chat/index.js +14 -2
- package/src/app/chat/inputSubmitHandler.js +21 -7
- package/src/app/chat/ipcBuilders.js +52 -0
- package/src/app/chat/multiWindow/paneManager.js +10 -1
- package/src/app/chat/multiWindow/renderer.js +1 -1
- package/src/app/chat/multiWindow/vtFrame.js +93 -0
- package/src/app/chat/streamState.js +182 -0
- package/src/app/cli/features/doctor.js +22 -0
- package/src/code/UcodeController.js +156 -0
- package/src/code/context/planGraphService.js +4 -0
- package/src/code/repl.js +4 -3
- package/src/code/runtime/taskLoop.js +46 -50
- package/src/code/tui.js +13 -2
- package/src/code/ucodeSlashDispatch.js +241 -0
- package/src/coordination/bus/activate.js +3 -0
- package/src/runtime/contracts/schemas/ufoo-ui-v1/envelope.json +28 -0
- package/src/runtime/contracts/uiProtocol.js +190 -0
- package/src/ui/{ink/chatLogModel.js → chatLogModel.js} +2 -2
- package/src/ui/dashboardBridge.js +81 -0
- package/src/ui/format/index.js +2 -2
- package/src/ui/index.js +8 -4
- package/src/ui/multiPaneBusMirror.js +137 -0
- package/src/ui/multiWindowHandoff.js +232 -0
- package/src/ui/ptyHandoff.js +23 -0
- package/src/ui/rustChatHost.js +1520 -0
- package/src/ui/rustMultiSession.js +497 -0
- package/src/ui/rustUcodeHost.js +999 -0
- package/src/ui/scrollbackReplay.js +82 -0
- package/src/ui/settingsBridge.js +49 -0
- package/src/ui/toolMergeBridge.js +66 -0
- package/src/ui/tuiLauncher.js +105 -0
- package/src/ui/ucodeStatusLine.js +74 -0
- package/src/ui/uiHostServer.js +339 -0
- package/src/ui/MIGRATION.md +0 -334
- package/src/ui/ink/ChatApp.js +0 -4152
- package/src/ui/ink/DashboardBar.js +0 -691
- package/src/ui/ink/InkDemo.js +0 -96
- package/src/ui/ink/MultilineInput.js +0 -662
- package/src/ui/ink/UcodeApp.js +0 -1675
- package/src/ui/ink/agentMirror.js +0 -730
- package/src/ui/ink/chatReducer.js +0 -473
- package/src/ui/runInk.js +0 -66
package/src/ui/ink/UcodeApp.js
DELETED
|
@@ -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
|
-
}
|