u-foo 2.5.14 → 2.5.15
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/package.json +1 -1
- package/src/agents/prompts/native/environment.js +20 -8
- package/src/code/agent.js +339 -24
- package/src/code/commands.js +61 -0
- package/src/code/context/artifactGc.js +292 -0
- package/src/code/context/artifactIndex.js +161 -0
- package/src/code/context/artifacts.js +183 -0
- package/src/code/context/assembler.js +698 -0
- package/src/code/context/executionSegment.js +314 -0
- package/src/code/context/featureFlag.js +13 -0
- package/src/code/context/index.js +18 -0
- package/src/code/context/projectSnapshot.js +201 -0
- package/src/code/context/promptLayers.js +159 -0
- package/src/code/context/reducers.js +328 -0
- package/src/code/context/stableJson.js +29 -0
- package/src/code/context/stateCommit.js +412 -0
- package/src/code/context/transcript.js +182 -0
- package/src/code/context/transcriptSync.js +106 -0
- package/src/code/context/workingSet.js +323 -0
- package/src/code/dispatch.js +4 -1
- package/src/code/index.js +6 -0
- package/src/code/modelCommand.js +87 -0
- package/src/code/nativeRunner.js +140 -30
- package/src/code/repl.js +36 -32
- package/src/code/sessionStore.js +227 -15
- package/src/code/skills/index.js +10 -0
- package/src/code/skills/injection.js +65 -3
- package/src/code/skills/loader.js +21 -0
- package/src/code/skills/manifest.js +87 -0
- package/src/code/skills/render.js +15 -1
- package/src/code/taskDecomposer.js +32 -2
- package/src/code/tools/artifactRead.js +40 -0
- package/src/code/tui.js +2 -0
- package/src/code/usageStore.js +15 -0
- package/src/ui/format/index.js +260 -44
- package/src/ui/format/markdownRenderer.js +215 -72
- package/src/ui/ink/ChatApp.js +39 -8
- package/src/ui/ink/UcodeApp.js +313 -27
- package/src/ui/ink/chatLogModel.js +102 -21
package/src/ui/ink/UcodeApp.js
CHANGED
|
@@ -29,10 +29,17 @@ const LOG_LINE_TEXT_PROPS = {
|
|
|
29
29
|
assistant: {},
|
|
30
30
|
system: { color: "gray", dimColor: true },
|
|
31
31
|
error: { color: "red" },
|
|
32
|
+
tool: {},
|
|
32
33
|
toolDetail: { color: "gray", dimColor: true },
|
|
33
34
|
bus: { color: "cyan" },
|
|
34
35
|
};
|
|
35
36
|
|
|
37
|
+
// Only assistant prose gets markdown. Error rows are app-generated
|
|
38
|
+
// (`Error: …`) and already painted red via resolveLogLineTextProps — running
|
|
39
|
+
// them through the MD Error: line rule would wrap chalk ANSI and break the
|
|
40
|
+
// plain-text body the Ink color prop expects.
|
|
41
|
+
const MARKDOWN_LOG_KINDS = new Set(["assistant"]);
|
|
42
|
+
|
|
36
43
|
// Resolve a log line kind to ink <Text> props. Unknown/missing kinds (e.g.
|
|
37
44
|
// the banner, which already carries chalk ANSI styling) render uncolored.
|
|
38
45
|
function resolveLogLineTextProps(kind) {
|
|
@@ -95,6 +102,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
95
102
|
// visual row (i.e. moveCursorVertically returned moved=false).
|
|
96
103
|
const [inputHistory, setInputHistory] = useState([]);
|
|
97
104
|
const [historyIndex, setHistoryIndex] = useState(0);
|
|
105
|
+
const [completionIndex, setCompletionIndex] = useState(0);
|
|
106
|
+
const [completionWindowStart, setCompletionWindowStart] = useState(0);
|
|
107
|
+
const [completionSuppressedDraft, setCompletionSuppressedDraft] = useState(null);
|
|
108
|
+
const POPUP_PAGE_SIZE = 8;
|
|
98
109
|
const { exit } = useApp();
|
|
99
110
|
const { stdout } = useStdout();
|
|
100
111
|
const lineSeqRef = useRef(banner.length + 1);
|
|
@@ -107,6 +118,9 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
107
118
|
const thinkingTailRef = useRef("");
|
|
108
119
|
const thinkingFlushAtRef = useRef(0);
|
|
109
120
|
const thinkingTimerRef = useRef(null);
|
|
121
|
+
// Persist fence/open-code state across streamed assistant log lines so
|
|
122
|
+
// ``` blocks stay styled even when deltas arrive one line at a time.
|
|
123
|
+
const markdownStateRef = useRef({ inCodeBlock: false });
|
|
110
124
|
|
|
111
125
|
const targetAgent = agentSelectionMode && selectedAgentIndex >= 0
|
|
112
126
|
? agents[selectedAgentIndex]
|
|
@@ -208,6 +222,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
208
222
|
if (transition.moved) {
|
|
209
223
|
setHistoryIndex(transition.nextHistoryIndex);
|
|
210
224
|
setDraft(transition.nextValue);
|
|
225
|
+
setCompletionSuppressedDraft(transition.nextValue || null);
|
|
211
226
|
setDraftVersion((v) => v + 1);
|
|
212
227
|
return;
|
|
213
228
|
}
|
|
@@ -224,22 +239,31 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
224
239
|
}
|
|
225
240
|
}, [inputHistory, historyIndex, agents, agentSelectionMode, selectedAgentIndex]);
|
|
226
241
|
|
|
227
|
-
const onArrowUpAtStart = useCallback(() => {
|
|
228
|
-
//
|
|
229
|
-
//
|
|
242
|
+
const onArrowUpAtStart = useCallback((currentValue) => {
|
|
243
|
+
// While @-targeting an agent with an empty draft, Up clears the
|
|
244
|
+
// selection before walking input history — otherwise history eats the
|
|
245
|
+
// key and the ›@agent prefix sticks.
|
|
246
|
+
const inputValue = currentValue != null ? currentValue : draft;
|
|
247
|
+
if (fmt.shouldClearAgentSelectionOnUp({
|
|
248
|
+
agentSelectionMode,
|
|
249
|
+
inputValue,
|
|
250
|
+
})) {
|
|
251
|
+
setAgentSelectionMode(false);
|
|
252
|
+
setSelectedAgentIndex(-1);
|
|
253
|
+
return;
|
|
254
|
+
}
|
|
255
|
+
// History: if we're already on the top visual row, walk back through
|
|
256
|
+
// the recent history before doing anything else.
|
|
230
257
|
if (inputHistory.length > 0) {
|
|
231
258
|
const nextIndex = Math.max(0, historyIndex - 1);
|
|
232
259
|
if (nextIndex !== historyIndex || draft !== inputHistory[nextIndex]) {
|
|
233
260
|
setHistoryIndex(nextIndex);
|
|
234
|
-
|
|
261
|
+
const nextValue = inputHistory[nextIndex] || "";
|
|
262
|
+
setDraft(nextValue);
|
|
263
|
+
setCompletionSuppressedDraft(nextValue || null);
|
|
235
264
|
setDraftVersion((v) => v + 1);
|
|
236
|
-
return;
|
|
237
265
|
}
|
|
238
266
|
}
|
|
239
|
-
if (agentSelectionMode) {
|
|
240
|
-
setAgentSelectionMode(false);
|
|
241
|
-
setSelectedAgentIndex(-1);
|
|
242
|
-
}
|
|
243
267
|
}, [inputHistory, historyIndex, draft, agentSelectionMode]);
|
|
244
268
|
|
|
245
269
|
const onArrowSideAtEmpty = useCallback((direction) => {
|
|
@@ -253,11 +277,73 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
253
277
|
setSelectedAgentIndex(next);
|
|
254
278
|
}, [agents, agentSelectionMode, selectedAgentIndex]);
|
|
255
279
|
|
|
280
|
+
const { UCODE_COMMAND_REGISTRY, UCODE_COMMAND_TREE } = require("../../code/commands");
|
|
281
|
+
const { listSessionSummaries } = require("../../code/sessionStore");
|
|
282
|
+
const { suggestUcodeModels, applyUcodeModelCommand } = require("../../code/modelCommand");
|
|
283
|
+
let resumeSessions = [];
|
|
284
|
+
try {
|
|
285
|
+
resumeSessions = listSessionSummaries(props.workspaceRoot || process.cwd(), { limit: 40 });
|
|
286
|
+
} catch {
|
|
287
|
+
resumeSessions = [];
|
|
288
|
+
}
|
|
289
|
+
const modelSuggestions = suggestUcodeModels(props.state || {});
|
|
290
|
+
|
|
291
|
+
const completions = fmt.buildCompletions({
|
|
292
|
+
text: draft,
|
|
293
|
+
agents: agents.map((a) => String((a && (a.fullId || a.id || a.nickname)) || "")).filter(Boolean),
|
|
294
|
+
agentLabels: agents.map((a) => getAgentLabel(a)),
|
|
295
|
+
commands: UCODE_COMMAND_REGISTRY,
|
|
296
|
+
commandTree: UCODE_COMMAND_TREE,
|
|
297
|
+
argumentLists: {
|
|
298
|
+
"/resume": resumeSessions,
|
|
299
|
+
"/model": modelSuggestions,
|
|
300
|
+
},
|
|
301
|
+
limit: 20,
|
|
302
|
+
});
|
|
303
|
+
const completionsOpen = completions.length > 0 && draft !== completionSuppressedDraft;
|
|
304
|
+
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
if (completions.length === 0) {
|
|
307
|
+
if (completionIndex !== 0) setCompletionIndex(0);
|
|
308
|
+
if (completionWindowStart !== 0) setCompletionWindowStart(0);
|
|
309
|
+
} else if (completionIndex >= completions.length) {
|
|
310
|
+
setCompletionIndex(completions.length - 1);
|
|
311
|
+
setCompletionWindowStart(Math.max(0, completions.length - POPUP_PAGE_SIZE));
|
|
312
|
+
}
|
|
313
|
+
}, [completions.length, completionIndex, completionWindowStart]);
|
|
314
|
+
|
|
315
|
+
const acceptCompletion = useCallback(() => {
|
|
316
|
+
if (!completionsOpen) return false;
|
|
317
|
+
const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
|
|
318
|
+
if (item) {
|
|
319
|
+
setDraft(item.replace);
|
|
320
|
+
setCompletionSuppressedDraft(item.hasChildren ? null : item.replace);
|
|
321
|
+
setDraftVersion((v) => v + 1);
|
|
322
|
+
}
|
|
323
|
+
setCompletionIndex(0);
|
|
324
|
+
return true;
|
|
325
|
+
}, [completionsOpen, completions, completionIndex]);
|
|
326
|
+
|
|
256
327
|
const appendLogLine = useCallback((text, kind = "assistant") => {
|
|
328
|
+
const raw = String(text == null ? "" : text);
|
|
329
|
+
let renderedLines = [raw];
|
|
330
|
+
if (MARKDOWN_LOG_KINDS.has(kind)) {
|
|
331
|
+
try {
|
|
332
|
+
renderedLines = fmt.renderLogLinesWithMarkdownAnsi(raw, markdownStateRef.current);
|
|
333
|
+
if (!Array.isArray(renderedLines) || renderedLines.length === 0) {
|
|
334
|
+
renderedLines = [raw];
|
|
335
|
+
}
|
|
336
|
+
} catch {
|
|
337
|
+
renderedLines = [raw];
|
|
338
|
+
}
|
|
339
|
+
}
|
|
257
340
|
setLogLines((prev) => {
|
|
258
|
-
const
|
|
259
|
-
|
|
260
|
-
|
|
341
|
+
const next = prev.slice();
|
|
342
|
+
for (const line of renderedLines) {
|
|
343
|
+
const id = `l-${lineSeqRef.current}`;
|
|
344
|
+
lineSeqRef.current += 1;
|
|
345
|
+
next.push({ id, text: String(line || ""), kind });
|
|
346
|
+
}
|
|
261
347
|
return next.length > 1000 ? next.slice(-1000) : next;
|
|
262
348
|
});
|
|
263
349
|
}, []);
|
|
@@ -274,7 +360,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
274
360
|
const flushActiveMerge = useCallback(() => {
|
|
275
361
|
setActiveMerge((current) => {
|
|
276
362
|
if (!current) return null;
|
|
277
|
-
appendLogLine(renderMergeText(current));
|
|
363
|
+
appendLogLine(renderMergeText(current), "tool");
|
|
278
364
|
return null;
|
|
279
365
|
});
|
|
280
366
|
}, [appendLogLine, renderMergeText]);
|
|
@@ -285,7 +371,7 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
285
371
|
const resObj = payload && typeof payload === "object" ? payload : (entry && entry.result) || {};
|
|
286
372
|
const phase = String((entry && entry.phase) || "").trim().toLowerCase();
|
|
287
373
|
const isError = phase === "error" || resObj.ok === false;
|
|
288
|
-
const detail =
|
|
374
|
+
const detail = fmt.normalizeToolLogDetail(tool, entry && entry.args, resObj);
|
|
289
375
|
const errorText = String((entry && entry.error) || resObj.error || "").trim();
|
|
290
376
|
const toolEntry = fmt.normalizeToolMergeEntry({ tool, detail, isError, errorText });
|
|
291
377
|
|
|
@@ -370,6 +456,37 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
370
456
|
case "error":
|
|
371
457
|
appendLogText(result.output || "");
|
|
372
458
|
return;
|
|
459
|
+
case "status": {
|
|
460
|
+
try {
|
|
461
|
+
const { summarizeSessionUsage, formatSessionUsageStatus } = require("../../code/usageStore");
|
|
462
|
+
const usageSummary = summarizeSessionUsage({
|
|
463
|
+
workspaceRoot: runtimeWorkspace,
|
|
464
|
+
sessionId: (props.state && props.state.sessionId) || "",
|
|
465
|
+
});
|
|
466
|
+
appendLogText(formatSessionUsageStatus(usageSummary), "system");
|
|
467
|
+
} catch (err) {
|
|
468
|
+
appendLogText(`Error: ${err && err.message ? err.message : "status failed"}`, "error");
|
|
469
|
+
}
|
|
470
|
+
return;
|
|
471
|
+
}
|
|
472
|
+
case "model": {
|
|
473
|
+
const applied = applyUcodeModelCommand(props.state || {}, result);
|
|
474
|
+
appendLogText(applied.output || "", applied.ok ? "system" : "error");
|
|
475
|
+
if (applied.ok && result.action === "set" && typeof props.persistSessionState === "function") {
|
|
476
|
+
try {
|
|
477
|
+
const persisted = props.persistSessionState(props.state);
|
|
478
|
+
if (persisted && persisted.ok === false) {
|
|
479
|
+
appendLogText(
|
|
480
|
+
`Error: failed to persist session ${(props.state && props.state.sessionId) || ""}: ${persisted.error || "unknown error"}`,
|
|
481
|
+
"error"
|
|
482
|
+
);
|
|
483
|
+
}
|
|
484
|
+
} catch {
|
|
485
|
+
// persist is best-effort after a successful model switch
|
|
486
|
+
}
|
|
487
|
+
}
|
|
488
|
+
return;
|
|
489
|
+
}
|
|
373
490
|
case "ubus": {
|
|
374
491
|
setStatus({ message: "Checking bus messages...", type: "typing", showTimer: false, startedAt: Date.now() });
|
|
375
492
|
try {
|
|
@@ -415,7 +532,31 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
415
532
|
appendLogText(`Error: ${(resumed && resumed.error) || "resume failed"}`, "error");
|
|
416
533
|
return;
|
|
417
534
|
}
|
|
418
|
-
|
|
535
|
+
// Rebuild the visible log from the restored session transcript so
|
|
536
|
+
// the user sees prior turns instead of only a status toast.
|
|
537
|
+
markdownStateRef.current = { inCodeBlock: false };
|
|
538
|
+
const history = fmt.buildUcodeSessionLogEntries(
|
|
539
|
+
Array.isArray(props.state && props.state.nlMessages) ? props.state.nlMessages : [],
|
|
540
|
+
{ markdownState: markdownStateRef.current, idPrefix: "h", startSeq: 0 },
|
|
541
|
+
);
|
|
542
|
+
const bannerEntries = banner.concat([""]).map((line, idx) => ({
|
|
543
|
+
id: `b-${idx}`,
|
|
544
|
+
text: line,
|
|
545
|
+
}));
|
|
546
|
+
const notice = {
|
|
547
|
+
id: `h-resume-${Date.now().toString(36)}`,
|
|
548
|
+
text: `Resumed session ${resumed.sessionId} (${resumed.restoredMessages} messages).`,
|
|
549
|
+
kind: "system",
|
|
550
|
+
};
|
|
551
|
+
const nextLines = bannerEntries.concat(history.entries).concat([notice]);
|
|
552
|
+
setLogLines(nextLines.length > 1000 ? nextLines.slice(-1000) : nextLines);
|
|
553
|
+
lineSeqRef.current = Math.max(
|
|
554
|
+
bannerEntries.length + 1,
|
|
555
|
+
Number(history.nextSeq) || 0,
|
|
556
|
+
nextLines.length,
|
|
557
|
+
);
|
|
558
|
+
setActiveMerge(null);
|
|
559
|
+
lastMergeRef.current = null;
|
|
419
560
|
return;
|
|
420
561
|
}
|
|
421
562
|
case "tool": {
|
|
@@ -739,18 +880,107 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
739
880
|
|
|
740
881
|
const statusText = useMemoStatusText(React, status, spinnerTick, getBackgroundSuffix());
|
|
741
882
|
|
|
742
|
-
// Top-level
|
|
743
|
-
//
|
|
883
|
+
// Top-level catches Ctrl+C / Ctrl+O, plus completion popup navigation
|
|
884
|
+
// while a slash/agent menu is open.
|
|
744
885
|
useInput((input, key) => {
|
|
745
886
|
if (key.ctrl && input === "c") { exit(); return; }
|
|
746
887
|
if (key.ctrl && input === "o") { expandLastMerge(); return; }
|
|
888
|
+
if (!completionsOpen) return;
|
|
889
|
+
if (key.upArrow) {
|
|
890
|
+
setCompletionIndex((i) => {
|
|
891
|
+
const next = (i - 1 + completions.length) % completions.length;
|
|
892
|
+
setCompletionWindowStart((ws) => {
|
|
893
|
+
if (next < ws) return next;
|
|
894
|
+
if (next === completions.length - 1) {
|
|
895
|
+
return Math.max(0, completions.length - POPUP_PAGE_SIZE);
|
|
896
|
+
}
|
|
897
|
+
return ws;
|
|
898
|
+
});
|
|
899
|
+
return next;
|
|
900
|
+
});
|
|
901
|
+
return;
|
|
902
|
+
}
|
|
903
|
+
if (key.downArrow) {
|
|
904
|
+
setCompletionIndex((i) => {
|
|
905
|
+
const next = (i + 1) % completions.length;
|
|
906
|
+
setCompletionWindowStart((ws) => {
|
|
907
|
+
if (next === 0) return 0;
|
|
908
|
+
if (next >= ws + POPUP_PAGE_SIZE) return next - POPUP_PAGE_SIZE + 1;
|
|
909
|
+
return ws;
|
|
910
|
+
});
|
|
911
|
+
return next;
|
|
912
|
+
});
|
|
913
|
+
return;
|
|
914
|
+
}
|
|
915
|
+
if (key.return) {
|
|
916
|
+
// Leaf completions (e.g. /resume <session>) run immediately on Enter.
|
|
917
|
+
// Parents with children only fill the draft so the next menu can open.
|
|
918
|
+
const item = completions[Math.max(0, Math.min(completions.length - 1, completionIndex))];
|
|
919
|
+
if (item && !item.hasChildren) {
|
|
920
|
+
const cmd = String(item.replace || "").trim();
|
|
921
|
+
setCompletionIndex(0);
|
|
922
|
+
setCompletionSuppressedDraft(null);
|
|
923
|
+
if (cmd) submit(cmd);
|
|
924
|
+
return;
|
|
925
|
+
}
|
|
926
|
+
acceptCompletion();
|
|
927
|
+
return;
|
|
928
|
+
}
|
|
929
|
+
if (key.tab) {
|
|
930
|
+
acceptCompletion();
|
|
931
|
+
return;
|
|
932
|
+
}
|
|
933
|
+
if (key.escape) {
|
|
934
|
+
setCompletionSuppressedDraft(null);
|
|
935
|
+
setDraft("");
|
|
936
|
+
setDraftVersion((v) => v + 1);
|
|
937
|
+
}
|
|
747
938
|
}, { isActive: interactive });
|
|
748
939
|
|
|
749
940
|
return h(Box, { flexDirection: "column", width: "100%" },
|
|
750
941
|
h(Box, { flexDirection: "column", width: "100%" },
|
|
751
|
-
...
|
|
752
|
-
|
|
753
|
-
|
|
942
|
+
...(() => {
|
|
943
|
+
// Re-render raw markdown at paint time so leftover ** / ### from
|
|
944
|
+
// older append paths or nested `**code**` patterns still resolve.
|
|
945
|
+
const mdState = { inCodeBlock: false };
|
|
946
|
+
return logLines.map((item, idx) => {
|
|
947
|
+
let text = item.text || " ";
|
|
948
|
+
if (MARKDOWN_LOG_KINDS.has(item.kind) && /(?:\*\*|__|^\s*#{1,6}\s|^\s*`{3})/m.test(text)) {
|
|
949
|
+
try {
|
|
950
|
+
const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
|
|
951
|
+
if (Array.isArray(rendered) && rendered.length > 0) {
|
|
952
|
+
text = rendered.length === 1 ? rendered[0] : rendered.join("\n");
|
|
953
|
+
}
|
|
954
|
+
} catch {
|
|
955
|
+
// keep original
|
|
956
|
+
}
|
|
957
|
+
} else if (MARKDOWN_LOG_KINDS.has(item.kind) && mdState.inCodeBlock) {
|
|
958
|
+
try {
|
|
959
|
+
const rendered = fmt.renderLogLinesWithMarkdownAnsi(text, mdState);
|
|
960
|
+
if (Array.isArray(rendered) && rendered[0] != null) text = rendered[0];
|
|
961
|
+
} catch {
|
|
962
|
+
// keep original
|
|
963
|
+
}
|
|
964
|
+
}
|
|
965
|
+
const textEl = h(Text, { ...resolveLogLineTextProps(item.kind) }, text || " ");
|
|
966
|
+
// Give user turns a blank line above/below so › prompts don't
|
|
967
|
+
// sit flush against system/tool rows. Multi-line user blocks
|
|
968
|
+
// only pad the outer edges.
|
|
969
|
+
if (item.kind === "user") {
|
|
970
|
+
const prev = logLines[idx - 1];
|
|
971
|
+
const next = logLines[idx + 1];
|
|
972
|
+
const marginTop = !prev || prev.kind !== "user" ? 1 : 0;
|
|
973
|
+
const marginBottom = !next || next.kind !== "user" ? 1 : 0;
|
|
974
|
+
return h(Box, {
|
|
975
|
+
key: item.id,
|
|
976
|
+
width: "100%",
|
|
977
|
+
marginTop,
|
|
978
|
+
marginBottom,
|
|
979
|
+
}, textEl);
|
|
980
|
+
}
|
|
981
|
+
return h(Text, { key: item.id, ...resolveLogLineTextProps(item.kind) }, text || " ");
|
|
982
|
+
});
|
|
983
|
+
})()
|
|
754
984
|
),
|
|
755
985
|
activeMerge ? h(Box, null,
|
|
756
986
|
h(Text, { color: activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
|
|
@@ -762,13 +992,55 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
762
992
|
h(Box, { flexGrow: 1 }),
|
|
763
993
|
h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
|
|
764
994
|
),
|
|
995
|
+
completionsOpen ? (() => {
|
|
996
|
+
const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
|
|
997
|
+
const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
|
|
998
|
+
const visible = completions.slice(start, end);
|
|
999
|
+
const cols = Math.max(8, size.cols || 80);
|
|
1000
|
+
// Frame the popup with a top rule; MultilineInput's borderTop is the
|
|
1001
|
+
// matching bottom rule, so we intentionally omit a trailing ─ here.
|
|
1002
|
+
return h(Box, { flexDirection: "column", width: "100%" },
|
|
1003
|
+
h(Text, { color: "gray" }, "─".repeat(cols)),
|
|
1004
|
+
...visible.map((s, idxInWindow) => {
|
|
1005
|
+
const idx = start + idxInWindow;
|
|
1006
|
+
const selected = idx === completionIndex;
|
|
1007
|
+
// Keep label+description in one Text. Splitting into sibling
|
|
1008
|
+
// Text nodes with wrap:"truncate" lets Yoga shrink the label
|
|
1009
|
+
// and mid-cut commands (e.g. "/help" → "/he p").
|
|
1010
|
+
const line = s.description
|
|
1011
|
+
? `${s.label} ${s.description}`
|
|
1012
|
+
: String(s.label || "");
|
|
1013
|
+
return h(Box, { key: `cmp-${idx}`, width: "100%" },
|
|
1014
|
+
h(Text, {
|
|
1015
|
+
color: selected ? "cyan" : "gray",
|
|
1016
|
+
inverse: selected,
|
|
1017
|
+
wrap: "truncate",
|
|
1018
|
+
}, line),
|
|
1019
|
+
);
|
|
1020
|
+
}),
|
|
1021
|
+
);
|
|
1022
|
+
})() : null,
|
|
765
1023
|
h(Box, { width: "100%" },
|
|
766
1024
|
h(MultilineInput, {
|
|
767
1025
|
value: draft,
|
|
768
1026
|
valueVersion: draftVersion,
|
|
769
|
-
onChange: (next) =>
|
|
770
|
-
|
|
1027
|
+
onChange: (next) => {
|
|
1028
|
+
if (completionSuppressedDraft !== null && next !== completionSuppressedDraft) {
|
|
1029
|
+
setCompletionSuppressedDraft(null);
|
|
1030
|
+
}
|
|
1031
|
+
setDraft(next);
|
|
1032
|
+
},
|
|
1033
|
+
onSubmit: (value) => {
|
|
1034
|
+
setCompletionSuppressedDraft(null);
|
|
1035
|
+
submit(value);
|
|
1036
|
+
},
|
|
771
1037
|
onCancel: () => {
|
|
1038
|
+
if (completionsOpen) {
|
|
1039
|
+
setCompletionSuppressedDraft(null);
|
|
1040
|
+
setDraft("");
|
|
1041
|
+
setDraftVersion((v) => v + 1);
|
|
1042
|
+
return;
|
|
1043
|
+
}
|
|
772
1044
|
// If a task is in flight, Esc requests cancellation. Otherwise
|
|
773
1045
|
// it clears the agent selection (matches blessed). The text
|
|
774
1046
|
// value is left alone so the user doesn't lose what they typed.
|
|
@@ -795,11 +1067,12 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
|
|
|
795
1067
|
onArrowRightAtEmpty: () => onArrowSideAtEmpty("right"),
|
|
796
1068
|
width: Math.max(20, (size.cols || 80) - 4),
|
|
797
1069
|
interactive,
|
|
1070
|
+
interceptArrowsAndEnter: completionsOpen,
|
|
798
1071
|
placeholder: "",
|
|
799
1072
|
promptPrefix: targetAgent ? `›@${getAgentLabel(targetAgent)} ` : "› ",
|
|
800
|
-
//
|
|
801
|
-
//
|
|
802
|
-
//
|
|
1073
|
+
// Completions render ABOVE the input. Only the Agents footer is
|
|
1074
|
+
// below — counting popup rows here parks the hardware cursor up
|
|
1075
|
+
// into the menu (ghost block on /status etc.).
|
|
803
1076
|
linesBelowInput: 1,
|
|
804
1077
|
// During model/tool activity ucode redraws the status line every
|
|
805
1078
|
// spinner frame. Keeping the hardware cursor hidden avoids a
|
|
@@ -900,8 +1173,21 @@ function collapseThinkingTail(text, maxChars = 80) {
|
|
|
900
1173
|
const collapsed = String(text || "").replace(/\s+/g, " ").trim();
|
|
901
1174
|
const parsed = Number(maxChars);
|
|
902
1175
|
const limit = Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : 80;
|
|
903
|
-
if (collapsed
|
|
904
|
-
|
|
1176
|
+
if (!collapsed) return "";
|
|
1177
|
+
|
|
1178
|
+
// Prefer the latest markdown emphasis / section so the status line shows the
|
|
1179
|
+
// current thought instead of a mid-word tail of an earlier heading.
|
|
1180
|
+
let candidate = collapsed;
|
|
1181
|
+
const boldParts = collapsed.match(/\*\*[^*]+\*\*/g);
|
|
1182
|
+
if (boldParts && boldParts.length > 0) {
|
|
1183
|
+
candidate = boldParts[boldParts.length - 1].replace(/\*/g, "").trim() || candidate;
|
|
1184
|
+
} else {
|
|
1185
|
+
const clauses = collapsed.split(/(?<=[.!?。!?])\s+/).map((part) => part.trim()).filter(Boolean);
|
|
1186
|
+
if (clauses.length > 1) candidate = clauses[clauses.length - 1];
|
|
1187
|
+
}
|
|
1188
|
+
|
|
1189
|
+
if (candidate.length <= limit) return candidate;
|
|
1190
|
+
return `…${candidate.slice(-(limit - 1))}`;
|
|
905
1191
|
}
|
|
906
1192
|
|
|
907
1193
|
function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
|
|
@@ -1,5 +1,9 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
|
|
3
|
+
const { renderMarkdownLinesAnsi } = require("../format/markdownRenderer");
|
|
4
|
+
|
|
5
|
+
const MARKDOWN_BODY_KINDS = new Set(["assistant", "agent", "plain", "error", "success"]);
|
|
6
|
+
|
|
3
7
|
function stripBlessedTags(text = "") {
|
|
4
8
|
return String(text || "")
|
|
5
9
|
.replace(/\x1b\[[0-9;?]*[ -/]*[@-~]/g, "")
|
|
@@ -26,6 +30,39 @@ function compactDividerLabel(text = "") {
|
|
|
26
30
|
return label || String(text || "").trim() || "section";
|
|
27
31
|
}
|
|
28
32
|
|
|
33
|
+
function splitSpeakerBody(raw = "") {
|
|
34
|
+
const source = String(raw || "");
|
|
35
|
+
const dotIdx = source.indexOf(" · ");
|
|
36
|
+
if (dotIdx >= 0) {
|
|
37
|
+
return {
|
|
38
|
+
speaker: source.slice(0, dotIdx).trim(),
|
|
39
|
+
body: source.slice(dotIdx + 3),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
const colonMatch = source.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
|
|
43
|
+
if (colonMatch) {
|
|
44
|
+
return { speaker: colonMatch[1], body: colonMatch[2] || "" };
|
|
45
|
+
}
|
|
46
|
+
return { speaker: "", body: source };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function formatChatLogBody(body = "", kind = "plain", markdownState = null) {
|
|
50
|
+
const text = String(body || "");
|
|
51
|
+
if (!MARKDOWN_BODY_KINDS.has(kind)) return text;
|
|
52
|
+
// Structural markers / empty rows stay untouched.
|
|
53
|
+
if (!text.trim()) return text;
|
|
54
|
+
try {
|
|
55
|
+
const state = markdownState && typeof markdownState === "object"
|
|
56
|
+
? markdownState
|
|
57
|
+
: { inCodeBlock: false };
|
|
58
|
+
const lines = renderMarkdownLinesAnsi(text, state);
|
|
59
|
+
if (!Array.isArray(lines) || lines.length === 0) return text;
|
|
60
|
+
return lines.length === 1 ? lines[0] : lines.join("\n");
|
|
61
|
+
} catch {
|
|
62
|
+
return text;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
29
66
|
function classifyChatLogLine(text = "") {
|
|
30
67
|
const raw = stripBlessedTags(text).replace(/\r/g, "");
|
|
31
68
|
const clean = stripMarkdownDecorators(raw);
|
|
@@ -41,23 +78,47 @@ function classifyChatLogLine(text = "") {
|
|
|
41
78
|
return { kind: "meta", marker: "·", speaker: "", body: clean };
|
|
42
79
|
}
|
|
43
80
|
if (/^(error:|✗|failed\b)/i.test(trimmed)) {
|
|
44
|
-
|
|
81
|
+
const rawBody = raw.replace(/^(error:\s*)/i, "");
|
|
82
|
+
return {
|
|
83
|
+
kind: "error",
|
|
84
|
+
marker: "!",
|
|
85
|
+
speaker: "error",
|
|
86
|
+
body: rawBody || clean.replace(/^(error:\s*)/i, ""),
|
|
87
|
+
};
|
|
45
88
|
}
|
|
46
89
|
if (/^(✓|✔|done\b|closed\b)/i.test(trimmed)) {
|
|
47
|
-
|
|
90
|
+
const rawBody = raw.replace(/^[✓✔]\s*/, "");
|
|
91
|
+
return {
|
|
92
|
+
kind: "success",
|
|
93
|
+
marker: "✓",
|
|
94
|
+
speaker: "",
|
|
95
|
+
body: rawBody || clean.replace(/^[✓✔]\s*/, ""),
|
|
96
|
+
};
|
|
48
97
|
}
|
|
49
|
-
const
|
|
50
|
-
if (
|
|
51
|
-
const
|
|
98
|
+
const cleanDot = clean.match(/^([^·\n]{1,64})\s+·\s+(.*)$/);
|
|
99
|
+
if (cleanDot) {
|
|
100
|
+
const parts = splitSpeakerBody(raw);
|
|
101
|
+
const speaker = stripMarkdownDecorators(parts.speaker || cleanDot[1]).trim();
|
|
52
102
|
const lower = speaker.toLowerCase();
|
|
53
103
|
const kind = lower === "ufoo" ? "assistant" : "agent";
|
|
54
|
-
return {
|
|
104
|
+
return {
|
|
105
|
+
kind,
|
|
106
|
+
marker: kind === "assistant" ? "◆" : "•",
|
|
107
|
+
speaker,
|
|
108
|
+
body: parts.body != null ? parts.body : (cleanDot[2] || " "),
|
|
109
|
+
};
|
|
55
110
|
}
|
|
56
|
-
const
|
|
57
|
-
if (
|
|
58
|
-
|
|
111
|
+
const cleanColon = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
|
|
112
|
+
if (cleanColon) {
|
|
113
|
+
const parts = splitSpeakerBody(raw);
|
|
114
|
+
return {
|
|
115
|
+
kind: "agent",
|
|
116
|
+
marker: "•",
|
|
117
|
+
speaker: stripMarkdownDecorators(parts.speaker || cleanColon[1]).trim(),
|
|
118
|
+
body: parts.body != null ? parts.body : (cleanColon[2] || " "),
|
|
119
|
+
};
|
|
59
120
|
}
|
|
60
|
-
return { kind: "plain", marker: "", speaker: "", body: clean };
|
|
121
|
+
return { kind: "plain", marker: "", speaker: "", body: raw || clean };
|
|
61
122
|
}
|
|
62
123
|
|
|
63
124
|
function defaultMarkerForKind(kind = "", speaker = "") {
|
|
@@ -71,12 +132,19 @@ function defaultMarkerForKind(kind = "", speaker = "") {
|
|
|
71
132
|
return speaker ? "•" : "";
|
|
72
133
|
}
|
|
73
134
|
|
|
74
|
-
function buildChatLogLineModel(input = "") {
|
|
135
|
+
function buildChatLogLineModel(input = "", options = {}) {
|
|
136
|
+
const markdownState = options && options.markdownState;
|
|
137
|
+
|
|
75
138
|
if (input && typeof input === "object" && !input.kind) {
|
|
76
|
-
return buildChatLogLineModel(chatLogEntryText(input));
|
|
139
|
+
return buildChatLogLineModel(chatLogEntryText(input), options);
|
|
77
140
|
}
|
|
78
141
|
|
|
79
142
|
if (input && typeof input === "object" && input.kind) {
|
|
143
|
+
// Prefer original text when present so markdown can be (re)applied with
|
|
144
|
+
// a shared fence state — e.g. Static decoration.
|
|
145
|
+
if (input.text != null && String(input.text).length > 0 && markdownState) {
|
|
146
|
+
return buildChatLogLineModel(String(input.text), options);
|
|
147
|
+
}
|
|
80
148
|
const kind = String(input.kind || "plain");
|
|
81
149
|
const speaker = String(input.speaker || "");
|
|
82
150
|
const marker = input.marker != null ? String(input.marker) : defaultMarkerForKind(kind, speaker);
|
|
@@ -84,6 +152,9 @@ function buildChatLogLineModel(input = "") {
|
|
|
84
152
|
? String(input.bodyText)
|
|
85
153
|
: String(input.body != null ? input.body : chatLogEntryText(input));
|
|
86
154
|
const body = kind === "plain" ? compactContinuationIndent(rawBody || " ") : (rawBody || " ");
|
|
155
|
+
const bodyText = markdownState
|
|
156
|
+
? formatChatLogBody(body, kind, markdownState)
|
|
157
|
+
: body;
|
|
87
158
|
return {
|
|
88
159
|
kind,
|
|
89
160
|
marker,
|
|
@@ -92,7 +163,7 @@ function buildChatLogLineModel(input = "") {
|
|
|
92
163
|
markerText: input.markerText != null
|
|
93
164
|
? String(input.markerText)
|
|
94
165
|
: (speaker ? `${marker || " "} ` : `${marker || " "} `),
|
|
95
|
-
bodyText
|
|
166
|
+
bodyText,
|
|
96
167
|
};
|
|
97
168
|
}
|
|
98
169
|
|
|
@@ -104,7 +175,7 @@ function buildChatLogLineModel(input = "") {
|
|
|
104
175
|
return {
|
|
105
176
|
...row,
|
|
106
177
|
markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
|
|
107
|
-
bodyText: body,
|
|
178
|
+
bodyText: formatChatLogBody(body, row.kind, markdownState || { inCodeBlock: false }),
|
|
108
179
|
};
|
|
109
180
|
}
|
|
110
181
|
|
|
@@ -113,12 +184,15 @@ function normalizeEntryInput(input) {
|
|
|
113
184
|
return { text: input };
|
|
114
185
|
}
|
|
115
186
|
|
|
116
|
-
function createChatLogEntry(input = "", id = "") {
|
|
187
|
+
function createChatLogEntry(input = "", id = "", options = {}) {
|
|
117
188
|
const source = normalizeEntryInput(input);
|
|
118
189
|
const text = String(source.text != null ? source.text : chatLogEntryText(source));
|
|
119
|
-
const
|
|
120
|
-
?
|
|
121
|
-
:
|
|
190
|
+
const markdownState = options && options.markdownState
|
|
191
|
+
? options.markdownState
|
|
192
|
+
: { inCodeBlock: false };
|
|
193
|
+
const row = source.kind && !(options && options.markdownState)
|
|
194
|
+
? buildChatLogLineModel({ ...source, text }, { markdownState })
|
|
195
|
+
: buildChatLogLineModel(text, { markdownState });
|
|
122
196
|
const meta = source.meta && typeof source.meta === "object" && !Array.isArray(source.meta)
|
|
123
197
|
? { ...source.meta }
|
|
124
198
|
: {};
|
|
@@ -160,13 +234,17 @@ function buildChatLogGroups(items = []) {
|
|
|
160
234
|
const source = Array.isArray(items) ? items : [];
|
|
161
235
|
const groups = [];
|
|
162
236
|
let current = null;
|
|
237
|
+
const markdownState = { inCodeBlock: false };
|
|
163
238
|
for (let index = 0; index < source.length; index += 1) {
|
|
164
239
|
const item = source[index] || {};
|
|
165
240
|
const itemId = item && typeof item === "object" && item.id ? item.id : `log-${index}`;
|
|
166
|
-
const
|
|
241
|
+
const text = item && typeof item === "object" && item.text != null
|
|
242
|
+
? String(item.text)
|
|
243
|
+
: chatLogEntryText(item);
|
|
244
|
+
const row = buildChatLogLineModel(text, { markdownState });
|
|
167
245
|
const entry = {
|
|
168
246
|
id: itemId,
|
|
169
|
-
text
|
|
247
|
+
text,
|
|
170
248
|
row,
|
|
171
249
|
sourceType: item && typeof item === "object" ? String(item.sourceType || item.type || "") : "",
|
|
172
250
|
meta: item && typeof item === "object" && item.meta ? item.meta : {},
|
|
@@ -196,7 +274,10 @@ module.exports = {
|
|
|
196
274
|
compactDividerLabel,
|
|
197
275
|
classifyChatLogLine,
|
|
198
276
|
buildChatLogLineModel,
|
|
199
|
-
|
|
277
|
+
formatChatLogBody,
|
|
200
278
|
createChatLogEntry,
|
|
201
279
|
chatLogEntryText,
|
|
280
|
+
canAppendToChatLogGroup,
|
|
281
|
+
buildChatLogGroups,
|
|
282
|
+
MARKDOWN_BODY_KINDS,
|
|
202
283
|
};
|