u-foo 2.4.2 → 2.4.4

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "u-foo",
3
- "version": "2.4.2",
3
+ "version": "2.4.4",
4
4
  "description": "Multi-Agent Workspace Protocol. Just add u. claude → uclaude, codex → ucodex.",
5
5
  "license": "SEE LICENSE IN LICENSE",
6
6
  "homepage": "https://ufoo.dev",
@@ -407,11 +407,11 @@ function classifyChatLogLine(text = "") {
407
407
  const speaker = dotMatch[1].trim();
408
408
  const lower = speaker.toLowerCase();
409
409
  const kind = lower === "ufoo" ? "assistant" : "agent";
410
- return { kind, marker: kind === "assistant" ? "◆" : "", speaker, body: dotMatch[2] || " " };
410
+ return { kind, marker: kind === "assistant" ? "◆" : "", speaker, body: dotMatch[2] || " " };
411
411
  }
412
412
  const colonMatch = clean.match(/^([A-Za-z0-9_.:@/-]{1,42}):\s+(.*)$/);
413
413
  if (colonMatch) {
414
- return { kind: "agent", marker: "", speaker: colonMatch[1], body: colonMatch[2] || " " };
414
+ return { kind: "agent", marker: "", speaker: colonMatch[1], body: colonMatch[2] || " " };
415
415
  }
416
416
  if (/^(CHAT|UCODE)\s+·/i.test(trimmed)) {
417
417
  return { kind: "meta", marker: "·", speaker: "", body: clean };
@@ -419,6 +419,16 @@ function classifyChatLogLine(text = "") {
419
419
  return { kind: "plain", marker: "│", speaker: "", body: clean };
420
420
  }
421
421
 
422
+ function buildChatLogLineModel(text = "") {
423
+ const row = classifyChatLogLine(text);
424
+ const hasSpeaker = Boolean(row.speaker);
425
+ return {
426
+ ...row,
427
+ markerText: hasSpeaker ? `${row.marker || " "} ` : `${row.marker || " "} `,
428
+ bodyText: row.body || " ",
429
+ };
430
+ }
431
+
422
432
  function createInkStreamState({
423
433
  dispatch,
424
434
  appendHistory,
@@ -1010,8 +1020,14 @@ function inferStatusType(text = "", requestedType = "") {
1010
1020
  const type = String(requestedType || "").trim().toLowerCase();
1011
1021
  if (type === "done" || type === "success" || type === "error" || type === "idle") return type;
1012
1022
  const clean = stripBlessedTags(String(text || "")).trim();
1013
- if (/^[✓✔]/.test(clean) || /\bdone\b/i.test(clean) || /\bprocessed\b/i.test(clean)) return "done";
1014
- if (/^[✗!]/.test(clean) || /\berror\b/i.test(clean) || /\bfailed\b/i.test(clean)) return "error";
1023
+ if (/^[✗!]/.test(clean) || /\b(error|failed|failure|offline)\b/i.test(clean) || /失败|错误/.test(clean)) return "error";
1024
+ if (
1025
+ /^[✓✔]/.test(clean) ||
1026
+ /^(done|closed|complete|completed|finished|success|succeeded|ready)\b/i.test(clean) ||
1027
+ /\b(processed|reconnected|switched|saved)\b/i.test(clean) ||
1028
+ /\bdone\s*$/i.test(clean) ||
1029
+ /完成|成功|已处理|已保存|已切换|已连接/.test(clean)
1030
+ ) return "done";
1015
1031
  return type || "typing";
1016
1032
  }
1017
1033
 
@@ -1123,12 +1139,13 @@ function createChatApp({ React, ink, props, interactive = true }) {
1123
1139
  dispatch({ type: "status/idle" });
1124
1140
  return;
1125
1141
  }
1142
+ const type = inferStatusType(clean, options.type || "typing");
1126
1143
  dispatch({
1127
1144
  type: "status/set",
1128
1145
  payload: {
1129
1146
  message: clean,
1130
- type: inferStatusType(clean, options.type || "typing"),
1131
- showTimer: options.showTimer === true,
1147
+ type,
1148
+ showTimer: options.showTimer === true && isAnimatedStatusType(type),
1132
1149
  startedAt: options.startedAt || Date.now(),
1133
1150
  },
1134
1151
  });
@@ -1312,8 +1329,10 @@ function createChatApp({ React, ink, props, interactive = true }) {
1312
1329
 
1313
1330
  useEffect(() => {
1314
1331
  if (!stdout) return undefined;
1315
- const update = () =>
1316
- setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
1332
+ const update = () => {
1333
+ const next = { cols: stdout.columns || 0, rows: stdout.rows || 0 };
1334
+ setSize((prev) => (prev.cols === next.cols && prev.rows === next.rows ? prev : next));
1335
+ };
1317
1336
  update();
1318
1337
  stdout.on("resize", update);
1319
1338
  return () => stdout.off("resize", update);
@@ -1881,7 +1900,8 @@ function createChatApp({ React, ink, props, interactive = true }) {
1881
1900
  useEffect(() => {
1882
1901
  const internalStatus = state.viewingAgentId ? internalStatusLabel(internalAgentView.status) : "ready";
1883
1902
  const internalActive = internalStatus !== "ready";
1884
- const statusAnimated = state.status.message && isAnimatedStatusType(state.status.type);
1903
+ const statusType = inferStatusType(state.status.message, state.status.type);
1904
+ const statusAnimated = state.status.message && isAnimatedStatusType(statusType);
1885
1905
  if ((!statusAnimated) && !internalActive) return undefined;
1886
1906
  const timer = setInterval(() => setSpinnerTick((t) => t + 1), 100);
1887
1907
  return () => clearInterval(timer);
@@ -1922,13 +1942,10 @@ function createChatApp({ React, ink, props, interactive = true }) {
1922
1942
  dispatch({ type: "log/append", text: `Error: ${err && err.message ? err.message : err}` });
1923
1943
  }
1924
1944
  if (statusText) {
1925
- dispatch({
1926
- type: "status/set",
1927
- payload: { message: statusText, type: "typing", showTimer: false, startedAt: Date.now() },
1928
- });
1945
+ setStatusText(statusText, { type: "typing", showTimer: false });
1929
1946
  }
1930
1947
  if (restart) restartDaemonBestEffort();
1931
- }, [restartDaemonBestEffort]);
1948
+ }, [restartDaemonBestEffort, setStatusText]);
1932
1949
 
1933
1950
  const clearUfooAgentIdentity = useCallback(() => {
1934
1951
  try {
@@ -3241,7 +3258,7 @@ function createChatApp({ React, ink, props, interactive = true }) {
3241
3258
  }
3242
3259
 
3243
3260
  const renderChatLogLine = (item) => {
3244
- const row = classifyChatLogLine((item && item.text) || "");
3261
+ const row = buildChatLogLineModel((item && item.text) || "");
3245
3262
  const key = item && item.id ? item.id : `log-${row.body}`;
3246
3263
  if (row.kind === "spacer") {
3247
3264
  return h(Text, { key, color: "gray" }, " ");
@@ -3268,16 +3285,16 @@ function createChatApp({ React, ink, props, interactive = true }) {
3268
3285
  );
3269
3286
  }
3270
3287
  return h(Box, { key, width: "100%", marginBottom: 1 },
3271
- h(Box, { width: 2 },
3272
- h(Text, { color: colors.marker, bold: row.kind === "error" }, row.marker || " "),
3288
+ h(Text, { color: colors.marker, bold: row.kind === "error" }, row.markerText),
3289
+ h(Text, { color: colors.body, wrap: "wrap" },
3290
+ row.speaker
3291
+ ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3292
+ : null,
3293
+ row.speaker
3294
+ ? h(Text, { color: "gray" }, " · ")
3295
+ : null,
3296
+ row.bodyText,
3273
3297
  ),
3274
- row.speaker
3275
- ? h(Text, { color: colors.speaker, bold: colors.bold }, row.speaker)
3276
- : null,
3277
- row.speaker
3278
- ? h(Text, { color: "gray" }, " · ")
3279
- : null,
3280
- h(Text, { color: colors.body, wrap: "wrap" }, row.body || " "),
3281
3298
  );
3282
3299
  };
3283
3300
 
@@ -3501,7 +3518,7 @@ function buildDashHints(state, targetAgentLabel) {
3501
3518
  function computeStatusText(status, spinnerTick) {
3502
3519
  const message = String((status && status.message) || "");
3503
3520
  if (!message) return "CHAT · Ready";
3504
- const type = String((status && status.type) || "thinking");
3521
+ const type = inferStatusType(message, status && status.type);
3505
3522
  if (type === "done" || type === "success") {
3506
3523
  const clean = stripBlessedTags(message).trim();
3507
3524
  return /^[✓✔]/.test(clean) ? clean : `✓ ${clean}`;
@@ -3656,6 +3673,7 @@ module.exports = {
3656
3673
  buildPromptIpcRequest,
3657
3674
  chatHistoryOptionsForScope,
3658
3675
  classifyChatLogLine,
3676
+ buildChatLogLineModel,
3659
3677
  createInkMultiWindowToggle,
3660
3678
  resolveActiveAgentId,
3661
3679
  resolveInjectSockPathForAgent,
@@ -482,11 +482,19 @@ function createMultilineInput({ React, ink }) {
482
482
  return undefined;
483
483
  }
484
484
  patchStdoutForIME(out);
485
+ const targetRowsUp = __imeCursor.lastFrameHadNewline
486
+ ? rowsBelowCursor
487
+ : Math.max(0, rowsBelowCursor - 1);
488
+ const alreadyParked = __imeCursor.active === true
489
+ && __imeCursor.parkRowsUp === rowsBelowCursor
490
+ && __imeCursor.parkCol === cursorTermCol
491
+ && __imeCursor.movedUpRows === targetRowsUp;
485
492
  // Publish the desired park target so the stdout monkey-patch can
486
493
  // re-park after every throttled ink frame write.
487
494
  __imeCursor.active = true;
488
495
  __imeCursor.parkRowsUp = rowsBelowCursor;
489
496
  __imeCursor.parkCol = cursorTermCol;
497
+ if (alreadyParked) return undefined;
490
498
  // Park immediately — covers cases where ink has nothing to render
491
499
  // (output unchanged) and won't fire a frame write at all, and keeps
492
500
  // the caret visible between frames. Combine hide + restore + park +
@@ -496,7 +504,7 @@ function createMultilineInput({ React, ink }) {
496
504
  // CRITICAL: the move-up amount must match the anchor that movedUpRows
497
505
  // was measured against. If the last frame ended without '\n' (the
498
506
  // full-screen path), the anchor is one row higher than the log-update
499
- // case, so we use rowsUpFromAnchor() rather than parkRowsUp directly.
507
+ // case, so we use targetRowsUp rather than parkRowsUp directly.
500
508
  // Otherwise restoring down by movedUpRows then moving up parkRowsUp
501
509
  // overshoots by one and leaves the hardware cursor one row above the
502
510
  // inverse caret — the residual "ghost cursor" symptom.
@@ -505,7 +513,7 @@ function createMultilineInput({ React, ink }) {
505
513
  combined += `\x1b[${__imeCursor.movedUpRows}B`;
506
514
  __imeCursor.movedUpRows = 0;
507
515
  }
508
- combined += applyParkSequence(rowsUpFromAnchor());
516
+ combined += applyParkSequence(targetRowsUp);
509
517
  out.write(combined);
510
518
  return undefined;
511
519
  });
@@ -643,8 +643,10 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
643
643
 
644
644
  useEffect(() => {
645
645
  if (!stdout) return undefined;
646
- const update = () =>
647
- setSize({ cols: stdout.columns || 0, rows: stdout.rows || 0 });
646
+ const update = () => {
647
+ const next = { cols: stdout.columns || 0, rows: stdout.rows || 0 };
648
+ setSize((prev) => (prev.cols === next.cols && prev.rows === next.rows ? prev : next));
649
+ };
648
650
  update();
649
651
  stdout.on("resize", update);
650
652
  return () => stdout.off("resize", update);
@@ -652,7 +654,11 @@ function createUcodeApp({ React, ink, props, interactive = true }) {
652
654
 
653
655
  // Drive the spinner + elapsed-timer redraws while a task is in flight.
654
656
  useEffect(() => {
655
- if (!status.message || status.type === "none") return undefined;
657
+ const statusType = inferStatusType(status.message, status.type);
658
+ if (!status.message || statusType === "none" || statusType === "idle" ||
659
+ statusType === "done" || statusType === "success" || statusType === "error") {
660
+ return undefined;
661
+ }
656
662
  const timer = setInterval(() => {
657
663
  setSpinnerTick((t) => t + 1);
658
664
  if (status.showTimer) setNowTick((t) => t + 1);
@@ -792,6 +798,22 @@ function runUcodeInkTui(props = {}) {
792
798
 
793
799
  module.exports = { runUcodeInkTui, createUcodeApp, computeStatusText };
794
800
 
801
+ function inferStatusType(text = "", requestedType = "") {
802
+ const type = String(requestedType || "").trim().toLowerCase();
803
+ if (type === "done" || type === "success" || type === "error" || type === "idle" || type === "none") {
804
+ return type;
805
+ }
806
+ const clean = String(text || "").trim();
807
+ if (/^[✗!]/.test(clean) || /\b(error|failed|failure)\b/i.test(clean) || /失败|错误/.test(clean)) return "error";
808
+ if (
809
+ /^[✓✔]/.test(clean) ||
810
+ /^(done|complete|completed|finished|success|succeeded|ready)\b/i.test(clean) ||
811
+ /\bdone\s*$/i.test(clean) ||
812
+ /完成|成功/.test(clean)
813
+ ) return "done";
814
+ return type || "thinking";
815
+ }
816
+
795
817
  /**
796
818
  * Pure status-line text builder used by the React component (and unit
797
819
  * tests). Returns "UCODE · Ready" while idle and a spinner+message+timer
@@ -802,7 +824,16 @@ function computeStatusText(status, spinnerTick, backgroundSuffix = "") {
802
824
  const message = String((status && status.message) || "");
803
825
  const suffix = String(backgroundSuffix || "");
804
826
  if (!message) return `UCODE · Ready${suffix}`;
805
- const type = String((status && status.type) || "thinking");
827
+ const type = inferStatusType(message, status && status.type);
828
+ if (type === "done" || type === "success") {
829
+ const clean = message.trim();
830
+ return `${/^[✓✔]/.test(clean) ? clean : `✓ ${clean}`}${suffix}`;
831
+ }
832
+ if (type === "error") {
833
+ const clean = message.trim();
834
+ return `${/^[✗!]/.test(clean) ? clean : `✗ ${clean}`}${suffix}`;
835
+ }
836
+ if (type === "idle" || type === "none") return `${message.trim() || "UCODE · Ready"}${suffix}`;
806
837
  const indicators = fmt.STATUS_INDICATORS[type] || fmt.STATUS_INDICATORS.thinking;
807
838
  const indicator = indicators[Math.max(0, Math.floor(Number(spinnerTick) || 0)) % indicators.length];
808
839
  const startedAt = Number.isFinite(status && status.startedAt) ? status.startedAt : 0;
@@ -48,6 +48,53 @@ function projectRootOf(row = {}) {
48
48
  return String((row && (row.root || row.project_root || row.projectRoot)) || "");
49
49
  }
50
50
 
51
+ function stableJson(value) {
52
+ if (value === null || typeof value !== "object") return JSON.stringify(value);
53
+ if (Array.isArray(value)) return `[${value.map(stableJson).join(",")}]`;
54
+ const keys = Object.keys(value).sort();
55
+ return `{${keys.map((key) => `${JSON.stringify(key)}:${stableJson(value[key])}`).join(",")}}`;
56
+ }
57
+
58
+ function shallowArrayEqual(left = [], right = []) {
59
+ if (left === right) return true;
60
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
61
+ if (left.length !== right.length) return false;
62
+ for (let i = 0; i < left.length; i += 1) {
63
+ if (left[i] !== right[i]) return false;
64
+ }
65
+ return true;
66
+ }
67
+
68
+ function listPayloadEqual(left = [], right = []) {
69
+ if (left === right) return true;
70
+ if (!Array.isArray(left) || !Array.isArray(right)) return false;
71
+ if (left.length !== right.length) return false;
72
+ for (let i = 0; i < left.length; i += 1) {
73
+ if (stableJson(left[i]) !== stableJson(right[i])) return false;
74
+ }
75
+ return true;
76
+ }
77
+
78
+ function mapPayloadEqual(left, right) {
79
+ if (left === right) return true;
80
+ if (!(left instanceof Map) || !(right instanceof Map)) return false;
81
+ if (left.size !== right.size) return false;
82
+ for (const [key, value] of left.entries()) {
83
+ if (!right.has(key)) return false;
84
+ if (stableJson(value) !== stableJson(right.get(key))) return false;
85
+ }
86
+ return true;
87
+ }
88
+
89
+ function statusPayloadEqual(left, right) {
90
+ const normalize = (value = {}) => {
91
+ const normalized = { ...value };
92
+ if (normalized.showTimer !== true) normalized.startedAt = 0;
93
+ return normalized;
94
+ };
95
+ return stableJson(normalize(left)) === stableJson(normalize(right));
96
+ }
97
+
51
98
  function createInitialState({ banner = [], globalMode = false, globalScope = "controller", settings = {} } = {}) {
52
99
  const initialLaunchMode = settings.launchMode || "auto";
53
100
  const initialAgentProvider = settings.agentProvider || "codex-cli";
@@ -174,6 +221,14 @@ function reducer(state, action) {
174
221
  } else if (nextIdx >= ids.length) {
175
222
  nextIdx = ids.length - 1;
176
223
  }
224
+ if (
225
+ shallowArrayEqual(state.agents, ids) &&
226
+ mapPayloadEqual(state.activeAgentMeta, meta) &&
227
+ state.selectedAgentIndex === nextIdx &&
228
+ state.agentSelectionMode === nextMode
229
+ ) {
230
+ return state;
231
+ }
177
232
  return {
178
233
  ...state,
179
234
  agents: ids,
@@ -216,12 +271,22 @@ function reducer(state, action) {
216
271
  const selectedIndex = selectedRoot
217
272
  ? list.findIndex((row) => projectRootOf(row) === selectedRoot)
218
273
  : -1;
274
+ const nextActiveRoot = action.activeProjectRoot || state.activeProjectRoot;
275
+ if (
276
+ listPayloadEqual(state.projects, list) &&
277
+ state.selectedProjectRoot === (selectedIndex >= 0 ? selectedRoot : "") &&
278
+ state.selectedProjectIndex === selectedIndex &&
279
+ state.activeProjectRoot === nextActiveRoot &&
280
+ state.emptyProjectsDownArmed === (list.length === 0 ? state.emptyProjectsDownArmed : false)
281
+ ) {
282
+ return state;
283
+ }
219
284
  return {
220
285
  ...state,
221
286
  projects: list,
222
287
  selectedProjectRoot: selectedIndex >= 0 ? selectedRoot : "",
223
288
  selectedProjectIndex: selectedIndex,
224
- activeProjectRoot: action.activeProjectRoot || state.activeProjectRoot,
289
+ activeProjectRoot: nextActiveRoot,
225
290
  emptyProjectsDownArmed: list.length === 0 ? state.emptyProjectsDownArmed : false,
226
291
  };
227
292
  }
@@ -240,8 +305,11 @@ function reducer(state, action) {
240
305
  return { ...state, projectListWindowStart: Math.max(0, action.windowStart | 0) };
241
306
  case "scope/set":
242
307
  return { ...state, globalScope: action.scope === "project" ? "project" : "controller" };
243
- case "status/set":
244
- return { ...state, status: { ...state.status, ...action.payload } };
308
+ case "status/set": {
309
+ const nextStatus = { ...state.status, ...action.payload };
310
+ if (statusPayloadEqual(state.status, nextStatus)) return state;
311
+ return { ...state, status: nextStatus };
312
+ }
245
313
  case "status/idle":
246
314
  return { ...state, status: { message: "", type: "thinking", showTimer: false, startedAt: 0 } };
247
315
  case "history/push": {
@@ -311,10 +379,16 @@ function reducer(state, action) {
311
379
  return { ...state, selectedProviderIndex: Math.max(0, action.index | 0) };
312
380
  case "cronIndex/set":
313
381
  return { ...state, selectedCronIndex: Math.max(-1, action.index | 0) };
314
- case "cron/set":
315
- return { ...state, cronTasks: Array.isArray(action.list) ? action.list : [] };
316
- case "loop/set":
317
- return { ...state, loopSummary: action.summary && typeof action.summary === "object" ? action.summary : null };
382
+ case "cron/set": {
383
+ const list = Array.isArray(action.list) ? action.list : [];
384
+ if (listPayloadEqual(state.cronTasks, list)) return state;
385
+ return { ...state, cronTasks: list };
386
+ }
387
+ case "loop/set": {
388
+ const summary = action.summary && typeof action.summary === "object" ? action.summary : null;
389
+ if (stableJson(state.loopSummary) === stableJson(summary)) return state;
390
+ return { ...state, loopSummary: summary };
391
+ }
318
392
  case "stream/begin":
319
393
  return {
320
394
  ...state,