u-foo 3.0.5 → 3.0.7

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.
@@ -287,78 +287,94 @@ function buildPlanDag(planGraph = {}) {
287
287
  };
288
288
  }
289
289
 
290
- function waveStepLabel(waveIndex = 0, nodeIndex = 0, waveSize = 1) {
291
- const step = Math.max(1, Math.floor(Number(waveIndex) || 0) + 1);
292
- if (waveSize <= 1) return String(step);
293
- const letter = String.fromCharCode(97 + Math.max(0, Math.min(25, Math.floor(Number(nodeIndex) || 0))));
294
- return `${step}${letter}`;
290
+ function countDagProgress(dag = {}) {
291
+ const nodes = Array.isArray(dag.nodes) ? dag.nodes : [];
292
+ const total = nodes.length;
293
+ const done = nodes.filter((node) => node && node.kind === "done").length;
294
+ return { done, total };
295
295
  }
296
296
 
297
- function formatParallelWaveLines(wave = [], waveIndex = 0, titleMax = 40) {
298
- const lines = [];
299
- const size = wave.length;
300
- wave.forEach((node, nodeIndex) => {
301
- const label = waveStepLabel(waveIndex, nodeIndex, size);
302
- const body = `${label} ${node.mark} ${truncate(node.title, titleMax)}`;
303
- if (nodeIndex === 0) {
304
- lines.push(` ┌─ ${body}`);
305
- if (size > 1) lines.push("──┤");
306
- return;
307
- }
308
- if (nodeIndex === size - 1) {
309
- lines.push(` └─ ${body}`);
310
- return;
297
+ function titleMaxForCols(cols = 80, reserved = 12) {
298
+ return Math.max(12, Math.min(48, Math.floor(Number(cols) || 80) - reserved));
299
+ }
300
+
301
+ function pickFocusActiveNodes(dag = {}) {
302
+ const nodes = Array.isArray(dag.nodes) ? dag.nodes : [];
303
+ const active = nodes.filter((node) => node && node.kind === "active");
304
+ if (active.length > 0) return active;
305
+
306
+ const waves = Array.isArray(dag.waves) ? dag.waves : [];
307
+ for (const wave of waves) {
308
+ const incomplete = (Array.isArray(wave) ? wave : []).filter((node) => (
309
+ node
310
+ && node.kind !== "done"
311
+ && node.kind !== "cancelled"
312
+ ));
313
+ if (incomplete.length === 0) continue;
314
+ const ready = incomplete.filter((node) => String(node.status || "").toLowerCase() === "ready");
315
+ return ready.length > 0 ? ready : incomplete.slice(0, 1);
316
+ }
317
+ return [];
318
+ }
319
+
320
+ function pickUpcomingNodes(dag = {}, activeIds = new Set(), limit = 2) {
321
+ const upcoming = [];
322
+ const waves = Array.isArray(dag.waves) ? dag.waves : [];
323
+ for (const wave of waves) {
324
+ for (const node of (Array.isArray(wave) ? wave : [])) {
325
+ if (!node || activeIds.has(node.id)) continue;
326
+ if (node.kind === "done" || node.kind === "cancelled") continue;
327
+ upcoming.push(node);
328
+ if (upcoming.length >= limit) return upcoming;
311
329
  }
312
- lines.push(` ├─ ${body}`);
313
- });
314
- return lines;
330
+ if (upcoming.length >= limit) break;
331
+ }
332
+ return upcoming;
333
+ }
334
+
335
+ function clipRoadmapLines(lines = [], maxRows = 10) {
336
+ const list = Array.isArray(lines) ? lines : [];
337
+ const limit = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : 10;
338
+ if (list.length <= limit) return list.slice();
339
+ const clipped = list.slice(0, Math.max(1, limit - 1));
340
+ clipped.push(`… +${list.length - clipped.length} more`);
341
+ return clipped;
315
342
  }
316
343
 
317
344
  /**
318
- * Build markdown (linear list) or ASCII flowchart (parallel waves) from planGraph JSON.
345
+ * Default auto band: progress + current task(s) + next titles.
346
+ * No ASCII tree, no 4a/4b labels.
319
347
  */
320
- function buildRoadmapMarkdown(planGraph = {}, {
348
+ function buildFocusRoadmap(planGraph = {}, {
321
349
  cols = 80,
322
350
  taskRunLine = "",
323
- maxRows = 10,
351
+ maxRows = 4,
324
352
  } = {}) {
325
353
  const dag = buildPlanDag(planGraph);
326
354
  if (dag.nodes.length === 0) {
327
355
  return { markdown: "", lines: [], dag };
328
356
  }
329
357
 
330
- const titleMax = Math.max(12, Math.min(48, Math.floor(Number(cols) || 80) - 12));
331
- const objective = truncate(String(planGraph.objective || "").trim(), titleMax);
332
- const lines = [objective ? `**Plan** · ${objective}` : "**Plan**"];
358
+ const titleMax = titleMaxForCols(cols, 8);
359
+ const { done, total } = countDagProgress(dag);
360
+ const lines = [`**Plan** · ${done}/${total}`];
333
361
 
334
- if (dag.linear) {
335
- dag.waves.forEach((wave, waveIndex) => {
336
- const node = wave[0];
337
- lines.push(`${waveIndex + 1}. ${node.mark} ${truncate(node.title, titleMax)}`);
338
- });
339
- } else {
340
- dag.waves.forEach((wave, waveIndex) => {
341
- if (wave.length === 1) {
342
- const node = wave[0];
343
- lines.push(`${waveStepLabel(waveIndex, 0, 1)} ${node.mark} ${truncate(node.title, titleMax)}`);
344
- return;
345
- }
346
- for (const line of formatParallelWaveLines(wave, waveIndex, Math.max(8, titleMax - 4))) {
347
- lines.push(line);
348
- }
349
- });
362
+ const active = pickFocusActiveNodes(dag);
363
+ const activeIds = new Set(active.map((node) => node.id));
364
+ for (const node of active) {
365
+ lines.push(`${node.mark} ${truncate(node.title, titleMax)}`);
350
366
  }
351
367
 
352
- const extra = String(taskRunLine || "").trim();
353
- if (extra) lines.push(extra);
354
-
355
- const limit = Number.isFinite(maxRows) && maxRows > 0 ? Math.floor(maxRows) : 10;
356
- let clipped = lines.slice(0, Math.max(1, limit));
357
- if (lines.length > clipped.length) {
358
- clipped = clipped.slice(0, Math.max(1, limit - 1));
359
- clipped.push(`… +${lines.length - clipped.length} more`);
368
+ const upcoming = pickUpcomingNodes(dag, activeIds, 2);
369
+ if (upcoming.length > 0) {
370
+ const titles = upcoming.map((node) => truncate(node.title, Math.max(8, Math.floor(titleMax / upcoming.length))));
371
+ lines.push(`接下来 · ${titles.join(" · ")}`);
360
372
  }
361
373
 
374
+ const extra = String(taskRunLine || "").trim();
375
+ if (extra) lines.push(truncate(extra, Math.max(24, titleMax + 8)));
376
+
377
+ const clipped = clipRoadmapLines(lines, maxRows);
362
378
  return {
363
379
  markdown: clipped.join("\n"),
364
380
  lines: clipped,
@@ -366,6 +382,89 @@ function buildRoadmapMarkdown(planGraph = {}, {
366
382
  };
367
383
  }
368
384
 
385
+ /**
386
+ * Expanded (/plan focus): flat numbered list; parallel waves share a step number.
387
+ */
388
+ function buildExpandedRoadmap(planGraph = {}, {
389
+ cols = 80,
390
+ taskRunLine = "",
391
+ maxRows = 16,
392
+ } = {}) {
393
+ const dag = buildPlanDag(planGraph);
394
+ if (dag.nodes.length === 0) {
395
+ return { markdown: "", lines: [], dag };
396
+ }
397
+
398
+ const titleMax = titleMaxForCols(cols, 14);
399
+ const { done, total } = countDagProgress(dag);
400
+ const objective = truncate(String(planGraph.objective || "").trim(), Math.max(12, titleMax - 8));
401
+ const header = objective
402
+ ? `**Plan** · ${done}/${total} · ${objective}`
403
+ : `**Plan** · ${done}/${total}`;
404
+ const body = [];
405
+
406
+ dag.waves.forEach((wave, waveIndex) => {
407
+ const step = waveIndex + 1;
408
+ for (const node of wave) {
409
+ body.push(`${node.mark} ${step} ${truncate(node.title, titleMax)}`);
410
+ }
411
+ });
412
+
413
+ const extra = String(taskRunLine || "").trim();
414
+ if (extra) body.push(truncate(extra, Math.max(24, titleMax + 8)));
415
+
416
+ const budget = Math.max(1, (Number.isFinite(maxRows) ? Math.floor(maxRows) : 16) - 1);
417
+ let clippedBody = body;
418
+ if (body.length > budget) {
419
+ let windowStart = 0;
420
+ while (
421
+ windowStart < body.length
422
+ && (body[windowStart].startsWith("✓") || body[windowStart].startsWith("⊘"))
423
+ ) {
424
+ windowStart += 1;
425
+ }
426
+ // Keep one completed row before the live window for context.
427
+ windowStart = Math.max(0, windowStart - 1);
428
+ const window = body.slice(windowStart);
429
+ if (window.length <= budget) {
430
+ clippedBody = windowStart > 0
431
+ ? [`… +${windowStart} more`, ...window]
432
+ : window;
433
+ } else {
434
+ const kept = window.slice(0, Math.max(1, budget - 1));
435
+ const omittedAfter = body.length - (windowStart + kept.length);
436
+ clippedBody = windowStart > 0
437
+ ? [`… +${windowStart} more`, ...kept.slice(0, Math.max(1, budget - 2)), `… +${omittedAfter} more`]
438
+ : [...kept, `… +${omittedAfter} more`];
439
+ // If double ellipsis blew the budget, fall back to simple clip.
440
+ if (clippedBody.length > budget) {
441
+ clippedBody = clipRoadmapLines(body, budget);
442
+ }
443
+ }
444
+ }
445
+
446
+ const lines = [header, ...clippedBody];
447
+ return {
448
+ markdown: lines.join("\n"),
449
+ lines,
450
+ dag,
451
+ };
452
+ }
453
+
454
+ /**
455
+ * Build roadmap markdown from planGraph JSON.
456
+ * variant=focus (default auto band) or expanded (/plan focus).
457
+ */
458
+ function buildRoadmapMarkdown(planGraph = {}, options = {}) {
459
+ const variant = String(options.variant || "focus").trim().toLowerCase() === "expanded"
460
+ ? "expanded"
461
+ : "focus";
462
+ if (variant === "expanded") {
463
+ return buildExpandedRoadmap(planGraph, options);
464
+ }
465
+ return buildFocusRoadmap(planGraph, options);
466
+ }
467
+
369
468
  function buildDebugLines(executionState = null, planGraph = {}) {
370
469
  const lines = [];
371
470
  const pg = planGraph && typeof planGraph === "object" ? planGraph : {};
@@ -491,27 +590,27 @@ function buildPlanUiProjection(executionState = null, options = {}) {
491
590
  )];
492
591
  roadmapMarkdown = "";
493
592
  } else {
494
- // auto + expanded: JSON DAG roadmap markdown
593
+ // auto progress-focus; expanded → flat numbered list (no ASCII tree)
594
+ const variant = bandMode === "expanded" ? "expanded" : "focus";
495
595
  const maxRows = Number.isFinite(options.maxBandRows)
496
596
  ? options.maxBandRows
497
- : (bandMode === "expanded" ? 16 : 10);
597
+ : (variant === "expanded" ? 16 : 4);
498
598
  const roadmap = buildRoadmapMarkdown(pg, {
499
599
  cols,
500
600
  taskRunLine: taskRunSuffix,
501
601
  maxRows,
602
+ variant,
502
603
  });
503
604
  planDag = roadmap.dag;
504
605
  roadmapMarkdown = roadmap.markdown;
505
606
  bandLines = roadmap.lines.slice();
506
- if (bandMode === "expanded" && tree.length > 0) {
507
- // Keep tree as fallback detail only when roadmap empty (shouldn't happen).
508
- if (bandLines.length === 0) {
509
- const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
510
- bandLines = [truncate(title, Math.max(24, cols - 2))];
511
- for (const row of tree) {
512
- bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
513
- }
607
+ if (bandLines.length === 0 && tree.length > 0) {
608
+ const title = pg.objective ? `Plan · ${pg.objective}` : "Plan";
609
+ bandLines = [truncate(title, Math.max(24, cols - 2))];
610
+ for (const row of tree) {
611
+ bandLines.push(truncate(formatTreeLine(row), Math.max(24, cols - 2)));
514
612
  }
613
+ roadmapMarkdown = "";
515
614
  }
516
615
  }
517
616
  }
@@ -585,5 +684,7 @@ module.exports = {
585
684
  statusToMark,
586
685
  buildPlanDag,
587
686
  buildRoadmapMarkdown,
687
+ buildFocusRoadmap,
688
+ buildExpandedRoadmap,
588
689
  buildPlanUiProjection,
589
690
  };
@@ -56,6 +56,7 @@ function buildImmutablePrefix() {
56
56
  "- After an accepted plan_graph create or patch, Runtime automatically advances ready tool nodes. Never invent or request an execute_graph tool.",
57
57
  "- Do not call plan_graph or task_run together with read, read_image, write, edit, bash, or artifact_read in the same assistant turn.",
58
58
  "- When an active graph is waiting on a task, advance that node through plan_graph instead of bypassing it with direct workspace tools: use patch.expand_node for execution.kind=expand, control.complete_task (nodeId) for execution.kind=inline_llm, or control.start_task for execution.kind=task_loop.",
59
+ "- TaskLoop start returns childGraphId. While that TaskRun is waiting_model on child root, patch with graphId=<childGraphId> and expand_node nodeId=root (add tool children). Do not ask the user to /plan off.",
59
60
  "- Do not end a turn with text only while the plan is still waiting on a task; expand, start, or complete that node. Runtime will auto-continue if you stop early, but prefer advancing in the same turn.",
60
61
  "- control.complete_task with nodeId completes a waiting_llm inline_llm task for the current Graph owner. control.complete_task with taskRunId (or task_run complete) is reserved for the owning TaskLoop. Do not directly complete expand or aggregate tasks.",
61
62
  "- While Plan Mode is ON, workspace mutations must be represented as plan_graph tool nodes or performed inside a running TaskRun/task_loop.",
@@ -387,15 +387,40 @@ function resolveUserInteraction(executionState = null, rawText = "") {
387
387
  };
388
388
  }
389
389
 
390
- function formatInteractionPromptLines(pending = null) {
390
+ function wrapLabeledBlock(label = "", prompt = "", cols = 80) {
391
+ const title = String(label || "Question").trim() || "Question";
392
+ const text = String(prompt || "").replace(/\s+/g, " ").trim();
393
+ const width = Math.max(24, Math.min(120, Math.floor(Number(cols) || 80) - 2));
394
+ if (!text) return [`${title}:`];
395
+
396
+ const prefix = `${title}: `;
397
+ const firstWidth = Math.max(8, width - prefix.length);
398
+ const contPrefix = " ";
399
+ const contWidth = Math.max(8, width - contPrefix.length);
400
+ const lines = [];
401
+
402
+ let offset = 0;
403
+ const firstChunk = text.slice(0, firstWidth);
404
+ lines.push(`${prefix}${firstChunk}`);
405
+ offset = firstChunk.length;
406
+ while (offset < text.length) {
407
+ const chunk = text.slice(offset, offset + contWidth);
408
+ lines.push(`${contPrefix}${chunk}`);
409
+ offset += chunk.length;
410
+ }
411
+ return lines;
412
+ }
413
+
414
+ function formatInteractionPromptLines(pending = null, options = {}) {
391
415
  if (!pending) return [];
416
+ const cols = Number(options.cols) > 0 ? Number(options.cols) : 80;
392
417
  const lines = [];
393
418
  const kind = pending.kind || "chat";
394
419
  if (kind === "approval") {
395
- lines.push(`Approval: ${pending.prompt}`);
420
+ lines.push(...wrapLabeledBlock("Approval", pending.prompt, cols));
396
421
  lines.push(" [yes] Yes [no] No or type a free-text reply");
397
422
  } else if (kind === "choice") {
398
- lines.push(`Choice: ${pending.prompt}`);
423
+ lines.push(...wrapLabeledBlock("Choice", pending.prompt, cols));
399
424
  for (const opt of pending.options || []) {
400
425
  lines.push(` [${opt.key}] ${opt.label}`);
401
426
  }
@@ -403,7 +428,7 @@ function formatInteractionPromptLines(pending = null) {
403
428
  lines.push(" or type a free-text reply");
404
429
  }
405
430
  } else {
406
- lines.push(`Question: ${pending.prompt}`);
431
+ lines.push(...wrapLabeledBlock("Question", pending.prompt, cols));
407
432
  lines.push(" (type your reply)");
408
433
  }
409
434
  return lines;
@@ -0,0 +1,117 @@
1
+ "use strict";
2
+
3
+ /**
4
+ * Context-window helpers for the ucode TUI meter (used / limit in K).
5
+ *
6
+ * usedTokens comes from the latest model request's prompt occupancy.
7
+ * limitTokens is resolved from the model id (provider catalogs rarely
8
+ * expose a reliable context_window field).
9
+ */
10
+
11
+ function toTokenCount(value) {
12
+ const parsed = Number(value);
13
+ if (!Number.isFinite(parsed) || parsed <= 0) return 0;
14
+ return Math.floor(parsed);
15
+ }
16
+
17
+ /**
18
+ * Prompt-side tokens currently occupying the context window.
19
+ * Anthropic splits input / cache_read / cache_creation; OpenAI folds
20
+ * cache hits into prompt_tokens (cached_tokens is a subset).
21
+ */
22
+ function contextTokensFromUsage(usage = null) {
23
+ if (!usage || typeof usage !== "object") return 0;
24
+ const input = toTokenCount(usage.input);
25
+ const cacheRead = toTokenCount(usage.cacheRead);
26
+ const cacheCreation = toTokenCount(usage.cacheCreation);
27
+ if (cacheCreation > 0) return input + cacheRead + cacheCreation;
28
+ // Anthropic exclusive split: input can be smaller than cache_read alone.
29
+ if (cacheRead > 0 && input < cacheRead) return input + cacheRead + cacheCreation;
30
+ // OpenAI-compatible: prompt_tokens already includes cached tokens.
31
+ return input;
32
+ }
33
+
34
+ function resolveModelContextLimit(model = "", options = {}) {
35
+ const override = toTokenCount(options.limit || options.contextLimit);
36
+ if (override > 0) return override;
37
+
38
+ const id = String(model || "").trim().toLowerCase();
39
+ if (!id) return 200000;
40
+
41
+ if (/\b1m\b|1000000|million|1\.0m/.test(id)) return 1000000;
42
+ if (/256k/.test(id)) return 256000;
43
+ if (/128k/.test(id)) return 128000;
44
+ if (/64k/.test(id)) return 64000;
45
+ if (/32k/.test(id)) return 32000;
46
+
47
+ if (/claude|anthropic|opus|sonnet|haiku/.test(id)) return 200000;
48
+ if (/gemini|gemma/.test(id)) return 1000000;
49
+ if (/kimi|moonshot|k2\.|k2-|k3/.test(id)) return 256000;
50
+ if (/gpt-5|o3|o4|codex/.test(id)) return 200000;
51
+ if (/gpt-4\.1|gpt-4o|gpt-4-turbo|o1/.test(id)) return 128000;
52
+ if (/gpt-4|gpt-3\.5/.test(id)) return 128000;
53
+
54
+ return 200000;
55
+ }
56
+
57
+ function formatTokensK(tokens = 0) {
58
+ const n = Math.max(0, Math.floor(Number(tokens) || 0));
59
+ if (n < 1000) return String(n);
60
+ const k = n / 1000;
61
+ if (k >= 100) return `${Math.round(k)}K`;
62
+ const tenths = Math.round(k * 10) / 10;
63
+ if (Number.isInteger(tenths)) return `${tenths}K`;
64
+ return `${tenths.toFixed(1)}K`;
65
+ }
66
+
67
+ function formatContextMeter({ usedTokens = 0, limitTokens = 0 } = {}) {
68
+ const used = Math.max(0, Math.floor(Number(usedTokens) || 0));
69
+ const limit = Math.max(0, Math.floor(Number(limitTokens) || 0));
70
+ if (limit > 0) return `${formatTokensK(used)} / ${formatTokensK(limit)}`;
71
+ return formatTokensK(used);
72
+ }
73
+
74
+ function buildContextMeter({
75
+ usage = null,
76
+ usedTokens = null,
77
+ model = "",
78
+ limitTokens = null,
79
+ } = {}) {
80
+ const used = usedTokens != null
81
+ ? toTokenCount(usedTokens)
82
+ : contextTokensFromUsage(usage);
83
+ const limit = resolveModelContextLimit(model, { limit: limitTokens });
84
+ return {
85
+ usedTokens: used,
86
+ limitTokens: limit,
87
+ model: String(model || "").trim(),
88
+ label: formatContextMeter({ usedTokens: used, limitTokens: limit }),
89
+ updatedAt: new Date().toISOString(),
90
+ };
91
+ }
92
+
93
+ function normalizeContextMeter(value = null, model = "") {
94
+ const source = value && typeof value === "object" ? value : {};
95
+ const used = toTokenCount(source.usedTokens);
96
+ const limit = resolveModelContextLimit(
97
+ String(source.model || model || "").trim(),
98
+ { limit: source.limitTokens },
99
+ );
100
+ return {
101
+ usedTokens: used,
102
+ limitTokens: limit,
103
+ model: String(source.model || model || "").trim(),
104
+ label: formatContextMeter({ usedTokens: used, limitTokens: limit }),
105
+ updatedAt: String(source.updatedAt || "").trim(),
106
+ };
107
+ }
108
+
109
+ module.exports = {
110
+ toTokenCount,
111
+ contextTokensFromUsage,
112
+ resolveModelContextLimit,
113
+ formatTokensK,
114
+ formatContextMeter,
115
+ buildContextMeter,
116
+ normalizeContextMeter,
117
+ };