u-foo 2.5.8 → 2.5.10
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 +2 -0
- package/README.zh-CN.md +3 -1
- package/bin/ufoo.js +6 -1
- package/bin/ukimi.js +70 -0
- package/package.json +2 -1
- package/src/agents/activity/activityDetector.js +7 -0
- package/src/agents/launch/launcher.js +6 -5
- package/src/agents/launch/readyDetector.js +21 -0
- package/src/agents/prompts/defaultBootstrap.js +20 -3
- package/src/agents/providers/credentials/index.js +7 -0
- package/src/agents/providers/credentials/kimi.js +342 -0
- package/src/agents/providers/directAuthStatus.js +94 -0
- package/src/app/chat/commandExecutor.js +10 -5
- package/src/app/chat/commands.js +2 -0
- package/src/app/chat/dashboardView.js +1 -0
- package/src/app/chat/multiWindow/index.js +88 -46
- package/src/app/chat/multiWindow/renderer.js +57 -8
- package/src/app/cli/run.js +5 -3
- package/src/code/nativeRunner.js +57 -6
- package/src/config.js +8 -0
- package/src/orchestration/groups/validateTemplate.js +1 -1
- package/src/runtime/daemon/groupOrchestrator.js +6 -0
- package/src/runtime/daemon/index.js +12 -3
- package/src/runtime/daemon/ops.js +7 -1
- package/src/runtime/daemon/providerSessions.js +41 -2
- package/src/tools/schemaFixtures.js +1 -1
- package/src/ui/format/index.js +14 -2
- package/src/ui/ink/ChatApp.js +321 -64
- package/src/ui/ink/chatReducer.js +32 -8
- package/src/ui/runInk.js +18 -9
package/src/ui/ink/ChatApp.js
CHANGED
|
@@ -22,7 +22,7 @@ const { runInk } = require("../runInk");
|
|
|
22
22
|
const fmt = require("../format");
|
|
23
23
|
const { createMultilineInput } = require("./MultilineInput");
|
|
24
24
|
const { createDashboardBar } = require("./DashboardBar");
|
|
25
|
-
const { reducer, createInitialState } = require("./chatReducer");
|
|
25
|
+
const { reducer, createInitialState, activeStreamText } = require("./chatReducer");
|
|
26
26
|
const {
|
|
27
27
|
stripBlessedTags,
|
|
28
28
|
compactDividerLabel,
|
|
@@ -396,13 +396,117 @@ function normalizeInkLogLines(text = "") {
|
|
|
396
396
|
return clean.split(/\r?\n/);
|
|
397
397
|
}
|
|
398
398
|
|
|
399
|
+
// Stream deltas are batched into one dispatch per window (see
|
|
400
|
+
// createInkStreamState) so a fast stream can't force 30+ full-tree
|
|
401
|
+
// re-renders per second.
|
|
402
|
+
const STREAM_FLUSH_INTERVAL_MS = 80;
|
|
403
|
+
|
|
404
|
+
// Burst-coalescing sender: the first call fires immediately, calls inside
|
|
405
|
+
// the window collapse into a single trailing send. Used for daemon STATUS
|
|
406
|
+
// requests, which arrive in bursts (bus traffic + router callbacks) and
|
|
407
|
+
// each trigger a dashboard re-render.
|
|
408
|
+
function createThrottledSender(send, windowMs = 500) {
|
|
409
|
+
let lastSentAt = 0;
|
|
410
|
+
let timer = null;
|
|
411
|
+
const fire = () => {
|
|
412
|
+
timer = null;
|
|
413
|
+
lastSentAt = Date.now();
|
|
414
|
+
send();
|
|
415
|
+
};
|
|
416
|
+
return () => {
|
|
417
|
+
const now = Date.now();
|
|
418
|
+
const elapsed = now - lastSentAt;
|
|
419
|
+
if (elapsed >= windowMs) {
|
|
420
|
+
if (timer) {
|
|
421
|
+
clearTimeout(timer);
|
|
422
|
+
timer = null;
|
|
423
|
+
}
|
|
424
|
+
lastSentAt = now;
|
|
425
|
+
send();
|
|
426
|
+
return;
|
|
427
|
+
}
|
|
428
|
+
if (!timer) {
|
|
429
|
+
timer = setTimeout(fire, windowMs - elapsed);
|
|
430
|
+
if (typeof timer.unref === "function") timer.unref();
|
|
431
|
+
}
|
|
432
|
+
};
|
|
433
|
+
}
|
|
434
|
+
|
|
435
|
+
// Kinds whose log entries render as a margin-bottom "transcript cell" in
|
|
436
|
+
// buildChatLogGroups. Kept in sync with canAppendToChatLogGroup in
|
|
437
|
+
// chatLogModel.js.
|
|
438
|
+
const STATIC_GROUPABLE_KINDS = new Set(["assistant", "agent", "success", "error", "meta", "plain"]);
|
|
439
|
+
|
|
440
|
+
// Shared row colors for both the dynamic (stream) and <Static> renderers.
|
|
441
|
+
const CHAT_LOG_ROW_PALETTE = {
|
|
442
|
+
assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
|
|
443
|
+
agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
|
|
444
|
+
error: { marker: "red", speaker: "red", body: "red", bold: true },
|
|
445
|
+
success: { marker: "green", speaker: "green", body: "green", bold: false },
|
|
446
|
+
divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
|
|
447
|
+
banner: { marker: "cyan", speaker: "cyan", body: "cyan", bold: true },
|
|
448
|
+
meta: { marker: "gray", speaker: "gray", body: "gray", bold: false },
|
|
449
|
+
plain: { marker: "gray", speaker: "gray", body: undefined, bold: false },
|
|
450
|
+
};
|
|
451
|
+
|
|
452
|
+
// Decorate one finalized log entry with the grouping facts the <Static>
|
|
453
|
+
// renderer needs. Grouping is a deterministic left-to-right fold (same
|
|
454
|
+
// rules as buildChatLogGroups), so once an entry is decorated its flags
|
|
455
|
+
// never change — which is exactly what Static's append-only rendering
|
|
456
|
+
// requires. `marginBefore` reproduces the old group marginBottom as a
|
|
457
|
+
// margin-top on the next entry, because per-item rendering can't know a
|
|
458
|
+
// group's end until the following entry arrives.
|
|
459
|
+
function decorateStaticLogEntry(prev, entry) {
|
|
460
|
+
const row = buildChatLogLineModel(entry);
|
|
461
|
+
const continuation = Boolean(
|
|
462
|
+
prev
|
|
463
|
+
&& (row.kind === "plain" || row.kind === "spacer")
|
|
464
|
+
&& STATIC_GROUPABLE_KINDS.has(prev.groupKind)
|
|
465
|
+
);
|
|
466
|
+
const groupKind = continuation ? prev.groupKind : row.kind;
|
|
467
|
+
// A gap belongs between visual blocks: only on entries that START a new
|
|
468
|
+
// block, and only when the previous block was a transcript group (whose
|
|
469
|
+
// old dynamic renderer contributed a trailing marginBottom).
|
|
470
|
+
const marginBefore = Boolean(!continuation && prev && STATIC_GROUPABLE_KINDS.has(prev.groupKind));
|
|
471
|
+
return { entry, row, groupKind, continuation, marginBefore };
|
|
472
|
+
}
|
|
473
|
+
|
|
399
474
|
function createInkStreamState({
|
|
400
475
|
dispatch,
|
|
401
476
|
appendHistory,
|
|
402
477
|
displayNameForPublisher = (value) => value,
|
|
478
|
+
flushIntervalMs = STREAM_FLUSH_INTERVAL_MS,
|
|
403
479
|
} = {}) {
|
|
404
480
|
const streams = new Map();
|
|
405
481
|
const pendingDeliveries = new Map();
|
|
482
|
+
// Delta batches awaiting dispatch, keyed like `streams`. Deltas arrive per
|
|
483
|
+
// daemon chunk (dozens per second); dispatching each one re-renders the
|
|
484
|
+
// whole Ink tree, so we accumulate for a short window and flush one
|
|
485
|
+
// stream/delta action per publisher per window.
|
|
486
|
+
const pendingDeltas = new Map();
|
|
487
|
+
let flushTimer = null;
|
|
488
|
+
|
|
489
|
+
function flushDeltas() {
|
|
490
|
+
if (flushTimer) {
|
|
491
|
+
clearTimeout(flushTimer);
|
|
492
|
+
flushTimer = null;
|
|
493
|
+
}
|
|
494
|
+
if (pendingDeltas.size === 0) return;
|
|
495
|
+
for (const batch of pendingDeltas.values()) {
|
|
496
|
+
dispatch({
|
|
497
|
+
type: "stream/delta",
|
|
498
|
+
publisher: batch.publisher,
|
|
499
|
+
delta: batch.parts.join(""),
|
|
500
|
+
});
|
|
501
|
+
}
|
|
502
|
+
pendingDeltas.clear();
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
function scheduleFlush() {
|
|
506
|
+
if (flushTimer) return;
|
|
507
|
+
flushTimer = setTimeout(flushDeltas, flushIntervalMs);
|
|
508
|
+
if (typeof flushTimer.unref === "function") flushTimer.unref();
|
|
509
|
+
}
|
|
406
510
|
|
|
407
511
|
function deliveryKey(agentId, agentLabel) {
|
|
408
512
|
return String(agentId || agentLabel || "").trim();
|
|
@@ -454,7 +558,7 @@ function createInkStreamState({
|
|
|
454
558
|
displayName,
|
|
455
559
|
prefix,
|
|
456
560
|
continuationPrefix,
|
|
457
|
-
|
|
561
|
+
parts: [],
|
|
458
562
|
meta: meta || {},
|
|
459
563
|
};
|
|
460
564
|
streams.set(key, state);
|
|
@@ -464,19 +568,30 @@ function createInkStreamState({
|
|
|
464
568
|
|
|
465
569
|
function appendStreamDelta(state, delta) {
|
|
466
570
|
if (!state || !delta) return;
|
|
467
|
-
|
|
468
|
-
|
|
571
|
+
const text = String(delta || "");
|
|
572
|
+
state.parts.push(text);
|
|
573
|
+
let batch = pendingDeltas.get(state.publisher);
|
|
574
|
+
if (!batch) {
|
|
575
|
+
batch = { publisher: state.displayName || state.publisher, parts: [] };
|
|
576
|
+
pendingDeltas.set(state.publisher, batch);
|
|
577
|
+
}
|
|
578
|
+
batch.parts.push(text);
|
|
579
|
+
scheduleFlush();
|
|
469
580
|
}
|
|
470
581
|
|
|
471
582
|
function finalizeStream(publisher, meta, reason = "") {
|
|
472
583
|
const key = String(publisher || "bus");
|
|
473
584
|
const state = streams.get(key);
|
|
474
585
|
if (!state) return;
|
|
586
|
+
// Flush first so the trailing deltas land on activeStream before the
|
|
587
|
+
// stream/end fold reads them.
|
|
588
|
+
flushDeltas();
|
|
475
589
|
dispatch({ type: "stream/end" });
|
|
476
590
|
if (typeof appendHistory === "function") {
|
|
591
|
+
const full = state.parts.join("");
|
|
477
592
|
const text = state.displayName
|
|
478
|
-
? `${state.displayName}: ${
|
|
479
|
-
:
|
|
593
|
+
? `${state.displayName}: ${full}`
|
|
594
|
+
: full;
|
|
480
595
|
appendHistory("bus", text, { ...(meta || state.meta || {}), stream_done: true, stream_reason: reason });
|
|
481
596
|
}
|
|
482
597
|
streams.delete(key);
|
|
@@ -494,6 +609,7 @@ function createInkStreamState({
|
|
|
494
609
|
appendStreamDelta,
|
|
495
610
|
finalizeStream,
|
|
496
611
|
hasStream,
|
|
612
|
+
flushDeltas,
|
|
497
613
|
};
|
|
498
614
|
}
|
|
499
615
|
|
|
@@ -1003,6 +1119,53 @@ function isAnimatedStatusType(type = "") {
|
|
|
1003
1119
|
return value !== "done" && value !== "success" && value !== "error" && value !== "idle" && value !== "none";
|
|
1004
1120
|
}
|
|
1005
1121
|
|
|
1122
|
+
// The status bar owns its spinner tick: the 100ms animation timer lives in
|
|
1123
|
+
// this leaf component instead of the ChatApp root, so animating the spinner
|
|
1124
|
+
// re-renders one line of text rather than the whole tree (previously every
|
|
1125
|
+
// tick re-rendered the full log area, which Ink erased and rewrote at
|
|
1126
|
+
// 10fps — the visible flicker).
|
|
1127
|
+
function createChatStatusLine({ React, ink }) {
|
|
1128
|
+
const { useEffect, useState } = React;
|
|
1129
|
+
const { Box, Text } = ink;
|
|
1130
|
+
const h = React.createElement;
|
|
1131
|
+
return function ChatStatusLine({ status, version }) {
|
|
1132
|
+
const message = String((status && status.message) || "");
|
|
1133
|
+
const animated = Boolean(message)
|
|
1134
|
+
&& isAnimatedStatusType(inferStatusType(message, status && status.type));
|
|
1135
|
+
const [tick, setTick] = useState(0);
|
|
1136
|
+
useEffect(() => {
|
|
1137
|
+
if (!animated) return undefined;
|
|
1138
|
+
const timer = setInterval(() => setTick((t) => t + 1), 100);
|
|
1139
|
+
return () => clearInterval(timer);
|
|
1140
|
+
}, [animated]);
|
|
1141
|
+
return h(Box, { marginTop: 1, width: "100%" },
|
|
1142
|
+
h(Text, { color: "gray" }, computeStatusText(status, animated ? tick : 0)),
|
|
1143
|
+
h(Box, { flexGrow: 1 }),
|
|
1144
|
+
h(Text, { color: "gray" }, `v${version}`),
|
|
1145
|
+
);
|
|
1146
|
+
};
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
// Same spinner isolation for the internal-agent view's status row.
|
|
1150
|
+
function createInternalStatusLine({ React, ink }) {
|
|
1151
|
+
const { useEffect, useState } = React;
|
|
1152
|
+
const { Text } = ink;
|
|
1153
|
+
const h = React.createElement;
|
|
1154
|
+
return function InternalStatusLine({ view, maxWidth }) {
|
|
1155
|
+
const status = internalStatusLabel(view && view.status);
|
|
1156
|
+
const active = status !== "ready";
|
|
1157
|
+
const [tick, setTick] = useState(0);
|
|
1158
|
+
useEffect(() => {
|
|
1159
|
+
if (!active) return undefined;
|
|
1160
|
+
const timer = setInterval(() => setTick((t) => t + 1), 100);
|
|
1161
|
+
return () => clearInterval(timer);
|
|
1162
|
+
}, [active]);
|
|
1163
|
+
const color = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
|
|
1164
|
+
const text = computeInternalStatusText(view || {}, active ? tick : 0);
|
|
1165
|
+
return h(Text, { color, wrap: "truncate" }, fitPlainLine(text, maxWidth));
|
|
1166
|
+
};
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1006
1169
|
function inkKeyToRaw(input, key) {
|
|
1007
1170
|
if (key.ctrl && input) {
|
|
1008
1171
|
const code = input.charCodeAt(0) - 96;
|
|
@@ -1023,11 +1186,13 @@ function inkKeyToRaw(input, key) {
|
|
|
1023
1186
|
}
|
|
1024
1187
|
|
|
1025
1188
|
function createChatApp({ React, ink, props, interactive = true }) {
|
|
1026
|
-
const { useReducer, useEffect, useState, useCallback, useRef } = React;
|
|
1189
|
+
const { useReducer, useEffect, useState, useCallback, useRef, useMemo } = React;
|
|
1027
1190
|
const { Box, Text, Static, useInput, useApp, useStdout } = ink;
|
|
1028
1191
|
const h = React.createElement;
|
|
1029
1192
|
const MultilineInput = createMultilineInput({ React, ink });
|
|
1030
1193
|
const DashboardBar = createDashboardBar({ React, ink });
|
|
1194
|
+
const ChatStatusLine = createChatStatusLine({ React, ink });
|
|
1195
|
+
const InternalStatusLine = createInternalStatusLine({ React, ink });
|
|
1031
1196
|
|
|
1032
1197
|
// Build the initial log: chat history if there is any, otherwise an
|
|
1033
1198
|
// ASCII banner with project / mode / version info. We resolve history
|
|
@@ -1052,7 +1217,6 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1052
1217
|
})
|
|
1053
1218
|
);
|
|
1054
1219
|
const [size, setSize] = useState({ cols: 0, rows: 0 });
|
|
1055
|
-
const [spinnerTick, setSpinnerTick] = useState(0);
|
|
1056
1220
|
const [currentProjectRoot, setCurrentProjectRoot] = useState(props.activeProjectRoot || props.projectRoot || "");
|
|
1057
1221
|
const [internalAgentView, setInternalAgentView] = useState(() => createInternalAgentViewState());
|
|
1058
1222
|
const [multiWindowActive, setMultiWindowActive] = useState(false);
|
|
@@ -1556,13 +1720,24 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1556
1720
|
return true;
|
|
1557
1721
|
};
|
|
1558
1722
|
|
|
1723
|
+
// STATUS requests arrive in bursts (every bus message routes through
|
|
1724
|
+
// the router's requestStatus plus command callbacks); each response
|
|
1725
|
+
// dispatches dashboard updates. Coalesce bursts into one send per
|
|
1726
|
+
// window so a busy bus can't spin the render loop.
|
|
1727
|
+
const statusRequestThrottlerRef = useRef(null);
|
|
1728
|
+
if (!statusRequestThrottlerRef.current) {
|
|
1729
|
+
statusRequestThrottlerRef.current = createThrottledSender(() => {
|
|
1730
|
+
try {
|
|
1731
|
+
const { IPC_REQUEST_TYPES } = require("../../runtime/contracts/eventContract");
|
|
1732
|
+
const conn = props.daemonConnection;
|
|
1733
|
+
if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
|
|
1734
|
+
} catch { /* ignore */ }
|
|
1735
|
+
}, 500);
|
|
1736
|
+
}
|
|
1559
1737
|
const requestDaemonStatus = useCallback(() => {
|
|
1560
|
-
|
|
1561
|
-
|
|
1562
|
-
|
|
1563
|
-
if (conn && typeof conn.send === "function") conn.send({ type: IPC_REQUEST_TYPES.STATUS });
|
|
1564
|
-
} catch { /* ignore */ }
|
|
1565
|
-
}, [props.daemonConnection]);
|
|
1738
|
+
const throttled = statusRequestThrottlerRef.current;
|
|
1739
|
+
if (throttled) throttled();
|
|
1740
|
+
}, []);
|
|
1566
1741
|
|
|
1567
1742
|
const updateDashboardFromStatus = useCallback((data = {}) => {
|
|
1568
1743
|
const activeIds = Array.isArray(data.active) ? data.active : [];
|
|
@@ -1661,17 +1836,30 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1661
1836
|
hasStream: (...args) => streamState.hasStream(...args),
|
|
1662
1837
|
setTransientAgentState: (agentId, value, options = {}) => {
|
|
1663
1838
|
if (!agentId || !value) return;
|
|
1839
|
+
const detail = options.detail || "";
|
|
1840
|
+
// Activity updates fire per agent event; skip the dispatch (and
|
|
1841
|
+
// the re-render it implies) when nothing actually changed.
|
|
1842
|
+
const current = stateRef.current || {};
|
|
1843
|
+
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : null;
|
|
1844
|
+
const existing = (metaMap && metaMap.get(agentId)) || {};
|
|
1845
|
+
if (existing.activity_state === value && String(existing.activity_detail || "") === detail) {
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1664
1848
|
dispatch({
|
|
1665
1849
|
type: "agents/patchMeta",
|
|
1666
1850
|
agentId,
|
|
1667
1851
|
patch: {
|
|
1668
1852
|
activity_state: value,
|
|
1669
|
-
activity_detail:
|
|
1853
|
+
activity_detail: detail,
|
|
1670
1854
|
},
|
|
1671
1855
|
});
|
|
1672
1856
|
},
|
|
1673
1857
|
clearTransientAgentState: (agentId) => {
|
|
1674
1858
|
if (!agentId) return;
|
|
1859
|
+
const current = stateRef.current || {};
|
|
1860
|
+
const metaMap = current.activeAgentMeta instanceof Map ? current.activeAgentMeta : null;
|
|
1861
|
+
const existing = (metaMap && metaMap.get(agentId)) || {};
|
|
1862
|
+
if (!existing.activity_state && !existing.activity_detail) return;
|
|
1675
1863
|
dispatch({
|
|
1676
1864
|
type: "agents/patchMeta",
|
|
1677
1865
|
agentId,
|
|
@@ -1868,16 +2056,6 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
1868
2056
|
return () => clearInterval(timer);
|
|
1869
2057
|
}, [interactive, props.globalMode, currentProjectRoot, refreshGlobalProjects]);
|
|
1870
2058
|
|
|
1871
|
-
useEffect(() => {
|
|
1872
|
-
const internalStatus = state.viewingAgentId ? internalStatusLabel(internalAgentView.status) : "ready";
|
|
1873
|
-
const internalActive = internalStatus !== "ready";
|
|
1874
|
-
const statusType = inferStatusType(state.status.message, state.status.type);
|
|
1875
|
-
const statusAnimated = state.status.message && isAnimatedStatusType(statusType);
|
|
1876
|
-
if ((!statusAnimated) && !internalActive) return undefined;
|
|
1877
|
-
const timer = setInterval(() => setSpinnerTick((t) => t + 1), 100);
|
|
1878
|
-
return () => clearInterval(timer);
|
|
1879
|
-
}, [state.status.message, state.status.type, state.viewingAgentId, internalAgentView.status]);
|
|
1880
|
-
|
|
1881
2059
|
const selectedProject = state.selectedProjectIndex >= 0 ? state.projects[state.selectedProjectIndex] : null;
|
|
1882
2060
|
const selectedProjectRoot = state.selectedProjectRoot || resolveProjectRowRoot(selectedProject);
|
|
1883
2061
|
const currentProject = state.projects.find((row) => resolveProjectRowRoot(row) === currentProjectRoot) || null;
|
|
@@ -3159,7 +3337,11 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3159
3337
|
}
|
|
3160
3338
|
}, { isActive: interactive });
|
|
3161
3339
|
|
|
3162
|
-
|
|
3340
|
+
// Chrome text for multi-window mode only — the visible status bar is the
|
|
3341
|
+
// ChatStatusLine component below, which owns its spinner tick. The
|
|
3342
|
+
// multi-window controller re-renders on its own events, so a static
|
|
3343
|
+
// first-frame indicator here matches what was effectively shown before.
|
|
3344
|
+
const statusText = computeStatusText(state.status, 0);
|
|
3163
3345
|
const inputWidth = Math.max(20, (size.cols || 80) - 4);
|
|
3164
3346
|
const promptPrefix = (() => {
|
|
3165
3347
|
const projectPrefix = inCommittedProjectScope && currentProjectLabel ? `${currentProjectLabel} ` : "";
|
|
@@ -3224,6 +3406,54 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3224
3406
|
}
|
|
3225
3407
|
}, [multiWindowActive, completionsOpen, completions.length, completionIndex, completionWindowStart]);
|
|
3226
3408
|
|
|
3409
|
+
// Append-only feed for the <Static> log area. Ink's Static renders each
|
|
3410
|
+
// item exactly once (permanently, above the live frame), which is what
|
|
3411
|
+
// stops the 10fps full-log erase/rewrite — but it also means items must
|
|
3412
|
+
// be immutable and the array must never shrink. The reducer's LOG_CAP
|
|
3413
|
+
// truncates from the head of state.logLines, so we can't pass it
|
|
3414
|
+
// directly; instead we copy only the newly appended tail (tracked via
|
|
3415
|
+
// the monotonic lineSeq) into our own list. log/clear resets lineSeq,
|
|
3416
|
+
// which bumps `generation` and remounts the Static (its internal cursor
|
|
3417
|
+
// would otherwise point past the shrunk array). The fresh array per
|
|
3418
|
+
// batch matters too: Static memoizes on the items reference.
|
|
3419
|
+
const staticLogRef = useRef({ items: [], lastSeq: 0, generation: 0 });
|
|
3420
|
+
const staticLog = useMemo(() => {
|
|
3421
|
+
const prev = staticLogRef.current || { items: [], lastSeq: 0, generation: 0 };
|
|
3422
|
+
let { items, lastSeq, generation } = prev;
|
|
3423
|
+
if (state.lineSeq < lastSeq) {
|
|
3424
|
+
items = [];
|
|
3425
|
+
lastSeq = 0;
|
|
3426
|
+
generation += 1;
|
|
3427
|
+
}
|
|
3428
|
+
const newCount = state.lineSeq - lastSeq;
|
|
3429
|
+
if (newCount > 0) {
|
|
3430
|
+
const fresh = state.logLines.slice(-newCount);
|
|
3431
|
+
items = items.slice();
|
|
3432
|
+
for (const entry of fresh) {
|
|
3433
|
+
items.push(decorateStaticLogEntry(items[items.length - 1], entry));
|
|
3434
|
+
}
|
|
3435
|
+
lastSeq = state.lineSeq;
|
|
3436
|
+
}
|
|
3437
|
+
const next = { items, lastSeq, generation };
|
|
3438
|
+
staticLogRef.current = next;
|
|
3439
|
+
return next;
|
|
3440
|
+
}, [state.logLines, state.lineSeq]);
|
|
3441
|
+
|
|
3442
|
+
// Grouped view of the in-flight stream, recomputed only when the stream
|
|
3443
|
+
// itself advances (the batched flush above keeps that cadence low)
|
|
3444
|
+
// instead of on every unrelated render.
|
|
3445
|
+
const activeStreamGroups = useMemo(() => {
|
|
3446
|
+
if (!state.activeStream) return null;
|
|
3447
|
+
const lines = activeStreamText(state.activeStream).split(/\r?\n/);
|
|
3448
|
+
const prefix = state.activeStream.publisher
|
|
3449
|
+
? `${state.activeStream.publisher}: `
|
|
3450
|
+
: "";
|
|
3451
|
+
return buildChatLogGroups(lines.map((line, idx) => ({
|
|
3452
|
+
id: `s-${idx}`,
|
|
3453
|
+
text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
|
|
3454
|
+
})));
|
|
3455
|
+
}, [state.activeStream]);
|
|
3456
|
+
|
|
3227
3457
|
if (multiWindowActive) {
|
|
3228
3458
|
return null;
|
|
3229
3459
|
}
|
|
@@ -3234,17 +3464,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3234
3464
|
if (row.kind === "spacer") {
|
|
3235
3465
|
return h(Text, { key, color: "gray" }, " ");
|
|
3236
3466
|
}
|
|
3237
|
-
const
|
|
3238
|
-
assistant: { marker: "cyan", speaker: "white", body: undefined, bold: true },
|
|
3239
|
-
agent: { marker: "cyan", speaker: "cyan", body: undefined, bold: false },
|
|
3240
|
-
error: { marker: "red", speaker: "red", body: "red", bold: true },
|
|
3241
|
-
success: { marker: "green", speaker: "green", body: "green", bold: false },
|
|
3242
|
-
divider: { marker: "gray", speaker: "gray", body: "gray", bold: false },
|
|
3243
|
-
banner: { marker: "cyan", speaker: "cyan", body: "cyan", bold: true },
|
|
3244
|
-
meta: { marker: "gray", speaker: "gray", body: "gray", bold: false },
|
|
3245
|
-
plain: { marker: "gray", speaker: "gray", body: undefined, bold: false },
|
|
3246
|
-
};
|
|
3247
|
-
const colors = palette[row.kind] || palette.plain;
|
|
3467
|
+
const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
|
|
3248
3468
|
if (row.kind === "divider") {
|
|
3249
3469
|
return h(Box, { key, marginBottom: 1 },
|
|
3250
3470
|
h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
|
|
@@ -3289,17 +3509,51 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3289
3509
|
...entries.map((entry) => renderChatLogEntry(entry, group)));
|
|
3290
3510
|
};
|
|
3291
3511
|
|
|
3292
|
-
|
|
3293
|
-
|
|
3294
|
-
|
|
3512
|
+
// Renderer for one finalized (append-only) <Static> log item. Mirrors
|
|
3513
|
+
// renderChatLogEntry visually; spacing differs because per-item
|
|
3514
|
+
// rendering can't wrap a group in one margin-bottom Box — instead the
|
|
3515
|
+
// decoration pass flags `marginBefore` on whatever entry follows a
|
|
3516
|
+
// transcript group.
|
|
3517
|
+
const renderStaticChatLogItem = (item) => {
|
|
3518
|
+
const { row, groupKind, continuation, marginBefore } = item;
|
|
3519
|
+
const key = item.entry && item.entry.id ? item.entry.id : `log-${row.body}`;
|
|
3520
|
+
const marginTop = marginBefore ? 1 : 0;
|
|
3521
|
+
if (row.kind === "spacer") {
|
|
3522
|
+
return h(Box, { key, marginTop },
|
|
3523
|
+
h(Text, { color: "gray" }, " "));
|
|
3524
|
+
}
|
|
3525
|
+
const colors = CHAT_LOG_ROW_PALETTE[row.kind] || CHAT_LOG_ROW_PALETTE.plain;
|
|
3526
|
+
if (row.kind === "divider") {
|
|
3527
|
+
return h(Box, { key, marginTop, marginBottom: 1 },
|
|
3528
|
+
h(Text, { color: colors.body, wrap: "truncate" }, ` ${compactDividerLabel(row.body)}`),
|
|
3529
|
+
);
|
|
3530
|
+
}
|
|
3531
|
+
if (row.kind === "banner") {
|
|
3532
|
+
return h(Box, { key, marginTop },
|
|
3533
|
+
h(Text, { color: colors.body, bold: true, wrap: "truncate" }, row.body),
|
|
3534
|
+
);
|
|
3535
|
+
}
|
|
3536
|
+
const markerText = continuation
|
|
3537
|
+
? (groupKind === "assistant" || groupKind === "agent" ? " " : " ")
|
|
3538
|
+
: row.markerText;
|
|
3539
|
+
return h(Box, { key, width: "100%", marginTop },
|
|
3540
|
+
h(Text, { color: colors.marker, bold: row.kind === "error" }, markerText),
|
|
3541
|
+
h(Text, { color: colors.body, wrap: "wrap" },
|
|
3542
|
+
row.speaker && !continuation
|
|
3543
|
+
? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
|
|
3544
|
+
: null,
|
|
3545
|
+
row.speaker && !continuation
|
|
3546
|
+
? h(Text, { color: "gray" }, " · ")
|
|
3547
|
+
: null,
|
|
3548
|
+
row.bodyText,
|
|
3549
|
+
),
|
|
3550
|
+
);
|
|
3551
|
+
};
|
|
3295
3552
|
|
|
3296
3553
|
if (state.viewingAgentId) {
|
|
3297
3554
|
const maxWidth = Math.max(20, size.cols || 80);
|
|
3298
3555
|
const logRows = Math.max(1, (size.rows || 24) - 5);
|
|
3299
3556
|
const visibleRows = buildInternalLogRows(internalAgentView.lines || [], maxWidth, logRows);
|
|
3300
|
-
const status = internalStatusLabel(internalAgentView.status);
|
|
3301
|
-
const internalStatusText = computeInternalStatusText(internalAgentView, spinnerTick);
|
|
3302
|
-
const internalStatusColor = status === "blocked" ? "red" : (status === "ready" ? "gray" : "cyan");
|
|
3303
3557
|
const inputText = String(internalAgentView.input || "");
|
|
3304
3558
|
const cursor = Math.max(0, Math.min(inputText.length, Number(internalAgentView.cursor) || 0));
|
|
3305
3559
|
const beforeCursor = inputText.slice(0, cursor);
|
|
@@ -3347,8 +3601,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3347
3601
|
}, (row && row.text) || " ");
|
|
3348
3602
|
}),
|
|
3349
3603
|
),
|
|
3350
|
-
h(
|
|
3351
|
-
fitPlainLine(internalStatusText, maxWidth)),
|
|
3604
|
+
h(InternalStatusLine, { view: internalAgentView, maxWidth }),
|
|
3352
3605
|
h(Text, { color: "gray", wrap: "truncate" }, "─".repeat(maxWidth)),
|
|
3353
3606
|
h(Box, { width: "100%" },
|
|
3354
3607
|
h(Text, { color: "magenta" }, "› "),
|
|
@@ -3367,31 +3620,30 @@ function createChatApp({ React, ink, props, interactive = true }) {
|
|
|
3367
3620
|
);
|
|
3368
3621
|
}
|
|
3369
3622
|
|
|
3623
|
+
const lastStaticItem = staticLog.items[staticLog.items.length - 1] || null;
|
|
3624
|
+
|
|
3370
3625
|
return h(Box, { flexDirection: "column", width: "100%" },
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3626
|
+
// Finalized log entries live in <Static>: each item is written to the
|
|
3627
|
+
// terminal exactly once (above the live frame) and never re-rendered,
|
|
3628
|
+
// so spinner ticks and keystrokes no longer erase/rewrite the whole
|
|
3629
|
+
// scrollback. key=generation remounts the Static after log/clear.
|
|
3630
|
+
h(Static, {
|
|
3631
|
+
key: `chat-log-${staticLog.generation}`,
|
|
3632
|
+
items: staticLog.items,
|
|
3633
|
+
}, (item) => renderStaticChatLogItem(item)),
|
|
3634
|
+
// Reproduces the trailing marginBottom the last transcript group used
|
|
3635
|
+
// to contribute in the dynamic layout.
|
|
3636
|
+
lastStaticItem && STATIC_GROUPABLE_KINDS.has(lastStaticItem.groupKind)
|
|
3637
|
+
? h(Text, { color: "gray" }, " ")
|
|
3638
|
+
: null,
|
|
3374
3639
|
state.activeMerge ? h(Box, null,
|
|
3375
3640
|
h(Text, { color: state.activeMerge.entries.some((e) => e.isError) ? "red" : "cyan" },
|
|
3376
3641
|
fmt.buildToolMergeRowText(state.activeMerge.entries)),
|
|
3377
3642
|
) : null,
|
|
3378
|
-
|
|
3379
|
-
...(()
|
|
3380
|
-
const lines = String(state.activeStream.text || "").split(/\r?\n/);
|
|
3381
|
-
const prefix = state.activeStream.publisher
|
|
3382
|
-
? `${state.activeStream.publisher}: `
|
|
3383
|
-
: "";
|
|
3384
|
-
return renderChatLogGroups(lines.map((line, idx) => ({
|
|
3385
|
-
id: `s-${idx}`,
|
|
3386
|
-
text: idx === 0 ? `${prefix}${line}` : ` ${line}`,
|
|
3387
|
-
})));
|
|
3388
|
-
})(),
|
|
3643
|
+
activeStreamGroups ? h(Box, { flexDirection: "column" },
|
|
3644
|
+
...activeStreamGroups.map(renderChatLogGroup).filter(Boolean),
|
|
3389
3645
|
) : null,
|
|
3390
|
-
h(
|
|
3391
|
-
h(Text, { color: "gray" }, statusText),
|
|
3392
|
-
h(Box, { flexGrow: 1 }),
|
|
3393
|
-
h(Text, { color: "gray" }, `v${fmt.UCODE_VERSION}`),
|
|
3394
|
-
),
|
|
3646
|
+
h(ChatStatusLine, { status: state.status, version: fmt.UCODE_VERSION }),
|
|
3395
3647
|
completionsOpen ? (() => {
|
|
3396
3648
|
const start = Math.min(completionWindowStart, Math.max(0, completions.length - POPUP_PAGE_SIZE));
|
|
3397
3649
|
const end = Math.min(completions.length, start + POPUP_PAGE_SIZE);
|
|
@@ -3664,6 +3916,11 @@ async function runChatInk(projectRoot, options = {}) {
|
|
|
3664
3916
|
module.exports = {
|
|
3665
3917
|
runChatInk,
|
|
3666
3918
|
createChatApp,
|
|
3919
|
+
createChatStatusLine,
|
|
3920
|
+
createInternalStatusLine,
|
|
3921
|
+
createInkStreamState,
|
|
3922
|
+
createThrottledSender,
|
|
3923
|
+
decorateStaticLogEntry,
|
|
3667
3924
|
bootstrapEnvironment,
|
|
3668
3925
|
buildDirectBusSendRequest,
|
|
3669
3926
|
buildPromptIpcRequest,
|
|
@@ -44,6 +44,7 @@ const DEFAULT_PROVIDER_OPTIONS = [
|
|
|
44
44
|
{ label: "codex", value: "codex-cli" },
|
|
45
45
|
{ label: "claude", value: "claude-cli" },
|
|
46
46
|
{ label: "agy", value: "agy-cli" },
|
|
47
|
+
{ label: "kimi", value: "kimi-cli" },
|
|
47
48
|
];
|
|
48
49
|
function projectRootOf(row = {}) {
|
|
49
50
|
return String((row && (row.root || row.project_root || row.projectRoot)) || "");
|
|
@@ -133,7 +134,9 @@ function createInitialState({ banner = [], globalMode = false, globalScope = "co
|
|
|
133
134
|
// activeStream is the in-flight chunk-by-chunk publisher message (set
|
|
134
135
|
// while the daemon is streaming). Rendered live below <Static>;
|
|
135
136
|
// promoted to <Static> when the stream finishes the same way the
|
|
136
|
-
// tool-merge group is.
|
|
137
|
+
// tool-merge group is. Text accumulates in `chunks` (joined on read via
|
|
138
|
+
// activeStreamText) so each delta dispatch is O(1) instead of O(n)
|
|
139
|
+
// string concatenation.
|
|
137
140
|
activeStream: null,
|
|
138
141
|
inputHistory: [],
|
|
139
142
|
historyIndex: 0,
|
|
@@ -166,6 +169,15 @@ function freezeMergeIntoLog(state) {
|
|
|
166
169
|
return appendLog({ ...state, activeMerge: null }, summary);
|
|
167
170
|
}
|
|
168
171
|
|
|
172
|
+
// Joined text of the in-flight stream. Deltas accumulate in `chunks` so the
|
|
173
|
+
// reducer never does per-delta string concatenation (O(n²) over a stream);
|
|
174
|
+
// readers pay a single O(n) join when they actually need the text.
|
|
175
|
+
function activeStreamText(stream) {
|
|
176
|
+
if (!stream || typeof stream !== "object") return "";
|
|
177
|
+
if (Array.isArray(stream.chunks)) return stream.chunks.join("");
|
|
178
|
+
return String(stream.text || "");
|
|
179
|
+
}
|
|
180
|
+
|
|
169
181
|
function reducer(state, action) {
|
|
170
182
|
if (!action || !action.type) return state;
|
|
171
183
|
switch (action.type) {
|
|
@@ -244,9 +256,17 @@ function reducer(state, action) {
|
|
|
244
256
|
case "agents/patchMeta": {
|
|
245
257
|
const agentId = String(action.agentId || "").trim();
|
|
246
258
|
if (!agentId) return state;
|
|
259
|
+
const patch = action.patch || {};
|
|
260
|
+
const patchKeys = Object.keys(patch);
|
|
261
|
+
const current = (state.activeAgentMeta instanceof Map ? state.activeAgentMeta.get(agentId) : null) || {};
|
|
262
|
+
// Skip no-op patches: activity updates stream in at a high rate and an
|
|
263
|
+
// unchanged patch would still mint a new Map + state, forcing a full
|
|
264
|
+
// re-render of the Ink tree.
|
|
265
|
+
if (patchKeys.every((key) => stableJson(current[key]) === stableJson(patch[key]))) {
|
|
266
|
+
return state;
|
|
267
|
+
}
|
|
247
268
|
const meta = new Map(state.activeAgentMeta instanceof Map ? state.activeAgentMeta : []);
|
|
248
|
-
|
|
249
|
-
meta.set(agentId, { ...current, ...(action.patch || {}) });
|
|
269
|
+
meta.set(agentId, { ...current, ...patch });
|
|
250
270
|
return { ...state, activeAgentMeta: meta };
|
|
251
271
|
}
|
|
252
272
|
case "agents/select":
|
|
@@ -396,26 +416,30 @@ function reducer(state, action) {
|
|
|
396
416
|
case "stream/begin":
|
|
397
417
|
return {
|
|
398
418
|
...state,
|
|
399
|
-
activeStream: { publisher: action.publisher || "",
|
|
419
|
+
activeStream: { publisher: action.publisher || "", chunks: [] },
|
|
400
420
|
};
|
|
401
421
|
case "stream/delta": {
|
|
422
|
+
const delta = String(action.delta || "");
|
|
402
423
|
if (!state.activeStream) {
|
|
403
424
|
return {
|
|
404
425
|
...state,
|
|
405
|
-
activeStream: { publisher: action.publisher || "",
|
|
426
|
+
activeStream: { publisher: action.publisher || "", chunks: [delta] },
|
|
406
427
|
};
|
|
407
428
|
}
|
|
429
|
+
const chunks = Array.isArray(state.activeStream.chunks)
|
|
430
|
+
? state.activeStream.chunks
|
|
431
|
+
: [String(state.activeStream.text || "")];
|
|
408
432
|
return {
|
|
409
433
|
...state,
|
|
410
434
|
activeStream: {
|
|
411
435
|
...state.activeStream,
|
|
412
|
-
|
|
436
|
+
chunks: chunks.concat([delta]),
|
|
413
437
|
},
|
|
414
438
|
};
|
|
415
439
|
}
|
|
416
440
|
case "stream/end": {
|
|
417
441
|
if (!state.activeStream) return state;
|
|
418
|
-
const lines =
|
|
442
|
+
const lines = activeStreamText(state.activeStream).split(/\r?\n/);
|
|
419
443
|
const prefix = state.activeStream.publisher
|
|
420
444
|
? `${state.activeStream.publisher}: `
|
|
421
445
|
: "";
|
|
@@ -434,4 +458,4 @@ function reducer(state, action) {
|
|
|
434
458
|
}
|
|
435
459
|
}
|
|
436
460
|
|
|
437
|
-
module.exports = { reducer, createInitialState, DASHBOARD_VIEWS };
|
|
461
|
+
module.exports = { reducer, createInitialState, DASHBOARD_VIEWS, activeStreamText };
|