pi-dag-workflow 0.0.0-stage → 0.7.0
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/CHANGELOG.md +454 -0
- package/LICENSE +21 -0
- package/README.md +211 -2
- package/README.zh-CN.md +212 -0
- package/docs/COMMUNICATION.md +129 -0
- package/docs/RELEASING.md +66 -0
- package/package.json +70 -4
- package/src/agents/commands/child.ts +148 -0
- package/src/agents/commands/register.ts +404 -0
- package/src/agents/domain/context.ts +183 -0
- package/src/agents/domain/diagnostics.ts +32 -0
- package/src/agents/domain/owner.ts +448 -0
- package/src/agents/domain/profiles.ts +216 -0
- package/src/agents/domain/runtime.ts +603 -0
- package/src/agents/domain/schema.ts +15 -0
- package/src/agents/index.ts +5 -0
- package/src/agents/io/contracts.ts +50 -0
- package/src/agents/io/host.ts +16 -0
- package/src/agents/io/notices.ts +27 -0
- package/src/agents/io/persistence.ts +210 -0
- package/src/agents/io/process.ts +100 -0
- package/src/agents/io/profile-file.ts +16 -0
- package/src/agents/io/skills.ts +46 -0
- package/src/goal/commands/register.ts +235 -0
- package/src/goal/domain/failure.ts +14 -0
- package/src/goal/domain/owner.ts +575 -0
- package/src/goal/domain/policy.ts +8 -0
- package/src/goal/domain/prompt.ts +57 -0
- package/src/goal/domain/state.ts +176 -0
- package/src/goal/index.ts +5 -0
- package/src/goal/io/host.ts +8 -0
- package/src/plan/commands/register.ts +101 -0
- package/src/plan/domain/owner.ts +51 -0
- package/src/plan/domain/policy.ts +27 -0
- package/src/plan/domain/state.ts +15 -0
- package/src/plan/index.ts +13 -0
- package/src/plan/io/records.ts +3 -0
- package/src/retry/commands/menu.ts +158 -0
- package/src/retry/commands/register.ts +26 -0
- package/src/retry/domain/owner.ts +418 -0
- package/src/retry/domain/state.ts +71 -0
- package/src/retry/index.ts +5 -0
- package/src/retry/io/host.ts +15 -0
- package/src/shared/agent-view.ts +18 -0
- package/src/shared/completion.ts +111 -0
- package/src/shared/config-file.ts +106 -0
- package/src/shared/config.ts +112 -0
- package/src/shared/dag/cache.ts +26 -0
- package/src/shared/dag/graph.ts +170 -0
- package/src/shared/dag/layout.ts +130 -0
- package/src/shared/goal-view.ts +2 -0
- package/src/shared/i18n.ts +62 -0
- package/src/shared/message-types.ts +12 -0
- package/src/shared/messages.en.ts +1290 -0
- package/src/shared/passive-message.ts +50 -0
- package/src/shared/pi-cli.ts +29 -0
- package/src/shared/profile-launch.ts +110 -0
- package/src/shared/state-types.ts +5 -0
- package/src/shared/task-schema.ts +39 -0
- package/src/shared/task-types.ts +25 -0
- package/src/shared/terminal.ts +19 -0
- package/src/shared/tool-info.ts +8 -0
- package/src/shared/ui/filter.ts +144 -0
- package/src/shared/ui/menu.ts +447 -0
- package/src/shared/ui/panel.ts +768 -0
- package/src/shared/ui/presentation.ts +624 -0
- package/src/shared/ui/render-cache.ts +63 -0
- package/src/shared/ui/render.ts +641 -0
- package/src/shared/ui/symbols.ts +136 -0
- package/src/team/commands/bootstrap.ts +52 -0
- package/src/team/commands/controls.ts +91 -0
- package/src/team/commands/history.ts +100 -0
- package/src/team/commands/menu.ts +455 -0
- package/src/team/commands/register.ts +1321 -0
- package/src/team/domain/inbox.ts +178 -0
- package/src/team/domain/launcher.ts +355 -0
- package/src/team/domain/lifecycle.ts +34 -0
- package/src/team/domain/teams.ts +161 -0
- package/src/team/index.ts +4 -0
- package/src/team/io/inbox.ts +255 -0
- package/src/team/io/launcher.ts +270 -0
- package/src/team/io/lock.ts +19 -0
- package/src/team/io/registry.ts +477 -0
- package/src/team/io/runtime.ts +353 -0
- package/src/team/io/session-activity.ts +185 -0
- package/src/team/io/session-delete.ts +98 -0
- package/src/team/io/session-history.ts +153 -0
- package/src/team/io/session-tail.ts +132 -0
- package/src/team/io/sid-lock.ts +115 -0
- package/src/team/io/sid-session.ts +98 -0
- package/src/todos/commands/checkpoints.ts +31 -0
- package/src/todos/commands/register.ts +395 -0
- package/src/todos/domain/checkpoint-state.ts +32 -0
- package/src/todos/domain/checkpoint-summary.ts +146 -0
- package/src/todos/domain/cleanup.ts +25 -0
- package/src/todos/domain/owner.ts +109 -0
- package/src/todos/domain/presets.ts +192 -0
- package/src/todos/domain/state.ts +287 -0
- package/src/todos/index.ts +15 -0
- package/src/todos/io/records.ts +8 -0
- package/src/ui/index.ts +4 -0
- package/src/workflow/commands/connect.ts +81 -0
- package/src/workflow/commands/view.ts +121 -0
- package/src/workflow/domain/features.ts +5 -0
- package/src/workflow/domain/view-owner.ts +18 -0
- package/src/workflow/domain/view-state.ts +12 -0
- package/src/workflow/index.ts +225 -0
- package/src/workflow/io/session.ts +48 -0
|
@@ -0,0 +1,641 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sliceByColumn, stripTerminalSequences, truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";
|
|
3
|
+
import { dagStructure, type DagStructure } from "../dag/cache.ts";
|
|
4
|
+
import { dagLayout } from "../dag/layout.ts";
|
|
5
|
+
import type { Todo, TodoFilter, WorkflowState } from "../task-types.ts";
|
|
6
|
+
import { translatedTaskStatusLabels as statusLabel } from '../state-types.ts';
|
|
7
|
+
import { chinese, type Translator } from "../i18n.ts";
|
|
8
|
+
import { glyph } from "./symbols.ts";
|
|
9
|
+
import { filteredView, latestBoundJobs, type TaskView } from "./filter.ts";
|
|
10
|
+
import { objectId, snapshotCache, weakMemo } from "./render-cache.ts";
|
|
11
|
+
import { jobProfileLabel, modelBudget, modelLabel } from "./presentation.ts";
|
|
12
|
+
|
|
13
|
+
// Failed and cancelled are native Todo outcomes: a cross and a slash keep the row honest without
|
|
14
|
+
// inventing a new shape for the three original states.
|
|
15
|
+
const icons: Record<Todo["status"], string> = { pending: "○", in_progress: "◐", completed: "✓", failed: "✗", cancelled: "⊘", deleted: "×" };
|
|
16
|
+
const colors: Record<Todo["status"], ThemeColor> = { pending: "muted", in_progress: "accent", completed: "success", failed: "error", cancelled: "dim", deleted: "dim" };
|
|
17
|
+
|
|
18
|
+
export { clean, displayText, notify } from "../terminal.ts";
|
|
19
|
+
import { clean } from "../terminal.ts";
|
|
20
|
+
const columns = (width: number): number => Number.isFinite(width) ? Math.max(0, Math.floor(width)) : 0;
|
|
21
|
+
const clip = (text: string, width: number): string => stripTerminalSequences(truncateToWidth(text, Math.max(0, width), "…"));
|
|
22
|
+
const tint = (text: string, color: ThemeColor, theme?: Theme): string => theme ? theme.fg(color, text) : text;
|
|
23
|
+
const bounded = (line: string, width: number, theme?: Theme): string => {
|
|
24
|
+
const result = truncateToWidth(line, width, "…");
|
|
25
|
+
return theme ? result : stripTerminalSequences(result);
|
|
26
|
+
};
|
|
27
|
+
const reference = (task: Todo, dependencies: readonly number[]): string => `#${task.id}${dependencies.length ? `<-${dependencies.map((id) => `#${id}`).join(",")}` : ""}`;
|
|
28
|
+
/** Completed/active scan shared by every header of one immutable snapshot. */
|
|
29
|
+
const headerCounts = weakMemo((tasks: readonly Todo[]) => {
|
|
30
|
+
let completed = 0;
|
|
31
|
+
let active = false;
|
|
32
|
+
for (const task of tasks) {
|
|
33
|
+
if (task.status === "completed") completed++;
|
|
34
|
+
else if (task.status === "pending" || task.status === "in_progress") active = true;
|
|
35
|
+
}
|
|
36
|
+
return { completed, root: active ? "●" : "○" };
|
|
37
|
+
});
|
|
38
|
+
/** Fragment instances carry their preset name, run and step position in task metadata. */
|
|
39
|
+
function fragmentTag(task: Todo): string {
|
|
40
|
+
const preset = task.metadata?.preset;
|
|
41
|
+
if (typeof preset !== "string") return "";
|
|
42
|
+
const run = task.metadata?.run;
|
|
43
|
+
const step = task.metadata?.step;
|
|
44
|
+
// Tasks written before step numbers existed fall back to their run, never to a wrong position.
|
|
45
|
+
const position = typeof step === "number" ? step : typeof run === "number" ? run : undefined;
|
|
46
|
+
if (position === undefined) return "";
|
|
47
|
+
const suffix = typeof run === "number" && run > 1 ? `·r${run}` : "";
|
|
48
|
+
return `${clean(preset)}#${position}${suffix}`;
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
interface TaskStatics { subject: string; active: string; owner: string; fragment: string }
|
|
52
|
+
/** Cleaned per-task text, tied to Todo identity: an unchanged task is reused by the next snapshot. */
|
|
53
|
+
const staticsFor = weakMemo((task: Todo): TaskStatics => ({ subject: clean(task.subject), active: clean(task.activeForm ?? ""), owner: clean(task.owner ?? ""), fragment: fragmentTag(task) }));
|
|
54
|
+
|
|
55
|
+
function projection(tasks: readonly Todo[]): DagStructure { return dagStructure(tasks); }
|
|
56
|
+
|
|
57
|
+
/** Goal is display input from a real focus controller, not a second Goal state store. */
|
|
58
|
+
function header(state: WorkflowState, tasks: readonly Todo[], width: number, theme?: Theme, goalTitle?: string, dag = false, msg: Translator = chinese, filter: TodoFilter = "full"): string[] {
|
|
59
|
+
const goal = clean(goalTitle ?? "");
|
|
60
|
+
// An active filter keeps the header visible when nothing matches, so the empty list explains itself.
|
|
61
|
+
if (!tasks.length && !state.plan && !goal && filter === "full") return [];
|
|
62
|
+
const counts = headerCounts(tasks);
|
|
63
|
+
const done = counts.completed;
|
|
64
|
+
let todoBlock = `${counts.root} ${glyph('todos')} Todos (${done}/${tasks.length})${dag ? " DAG" : ""}`;
|
|
65
|
+
let planBlock = state.plan ? glyph('plan') + " " + msg("Plan [只读]") : "";
|
|
66
|
+
let goalBlock = goal ? glyph('goal') + ` Goal: ${goal}` : "";
|
|
67
|
+
const size = () => visibleWidth([todoBlock, planBlock, goalBlock].filter(Boolean).join(" · "));
|
|
68
|
+
if (size() > width) {
|
|
69
|
+
todoBlock = `${counts.root} ${glyph('todos')} Todos ${done}/${tasks.length}${dag ? " DAG" : ""}`;
|
|
70
|
+
if (planBlock) planBlock = glyph('plan') + " Plan";
|
|
71
|
+
if (goalBlock) {
|
|
72
|
+
const used = visibleWidth([todoBlock, planBlock].filter(Boolean).join(" · ")) + 3;
|
|
73
|
+
goalBlock = clip(goalBlock, width - used);
|
|
74
|
+
}
|
|
75
|
+
if (size() > width && planBlock) planBlock = "Plan";
|
|
76
|
+
}
|
|
77
|
+
// One distinct theme color per block: Todo keeps the main accent, Plan marks the restricted
|
|
78
|
+
// read-only state, and Goal carries the active objective.
|
|
79
|
+
const blocks = [tint(todoBlock, "accent", theme)];
|
|
80
|
+
if (planBlock) blocks.push(tint(planBlock, "error", theme));
|
|
81
|
+
if (goalBlock) blocks.push(tint(goalBlock, "success", theme));
|
|
82
|
+
const line = blocks.join(tint(" · ", "dim", theme));
|
|
83
|
+
if (filter === "full") return [bounded(line, width, theme)];
|
|
84
|
+
// The scope block is secondary: it is dropped rather than squeezing the Todo, Plan and Goal blocks.
|
|
85
|
+
const scope = tint(msg`筛选:${filter === 'pending' ? msg('未完成') : statusLabel(msg)[filter]}`, "muted", theme);
|
|
86
|
+
const withScope = `${line}${tint(" · ", "dim", theme)}${scope}`;
|
|
87
|
+
return [bounded(visibleWidth(withScope) <= width ? withScope : line, width, theme)];
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
interface TreeRow { task: Todo; prefix: string; dependencies: number[] }
|
|
91
|
+
export const flatRows = (tasks: readonly Todo[], more = false): TreeRow[] => tasks.map((task, index) => ({ task, prefix: index === tasks.length - 1 && !more ? "└─ " : "├─ ", dependencies: task.blockedBy }));
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* Chronological spanning forest of real edges, drawn strictly in creation order. Scanning the
|
|
95
|
+
* selected window once, each task attaches to the deepest still-open ancestor that is a real
|
|
96
|
+
* predecessor; that branch is then truncated so a parent always precedes its children and every
|
|
97
|
+
* subtree stays contiguous. Tasks without an open predecessor start a new root. Edges that were
|
|
98
|
+
* closed or cropped stay honest as left-hand references; hidden/extra predecessors are never
|
|
99
|
+
* invented as ancestry. Two linear passes keep the work O(tasks + edges) in the common case.
|
|
100
|
+
*/
|
|
101
|
+
export function pathRows(selected: readonly Todo[], width: number, more = false): TreeRow[] {
|
|
102
|
+
const parents = new Map<number, number>();
|
|
103
|
+
const depth = new Map<number, number>();
|
|
104
|
+
const path: number[] = [];
|
|
105
|
+
const open = new Map<number, number>();
|
|
106
|
+
let maximum = 0;
|
|
107
|
+
for (const task of selected) {
|
|
108
|
+
let at = -1;
|
|
109
|
+
for (const id of task.blockedBy) {
|
|
110
|
+
const index = open.get(id);
|
|
111
|
+
if (index !== undefined && index > at) at = index;
|
|
112
|
+
}
|
|
113
|
+
if (at >= 0) {
|
|
114
|
+
const parent = path[at]!;
|
|
115
|
+
parents.set(task.id, parent);
|
|
116
|
+
for (let index = path.length - 1; index > at; index--) open.delete(path[index]!);
|
|
117
|
+
path.length = at + 1;
|
|
118
|
+
depth.set(task.id, depth.get(parent)! + 1);
|
|
119
|
+
} else {
|
|
120
|
+
open.clear();
|
|
121
|
+
path.length = 0;
|
|
122
|
+
depth.set(task.id, 0);
|
|
123
|
+
}
|
|
124
|
+
maximum = Math.max(maximum, depth.get(task.id)!);
|
|
125
|
+
open.set(task.id, path.length);
|
|
126
|
+
path.push(task.id);
|
|
127
|
+
}
|
|
128
|
+
const idWidth = selected.reduce((max, task) => Math.max(max, String(task.id).length + 1), 0);
|
|
129
|
+
// First drop column alignment (below); only flatten when the path cannot fit at all.
|
|
130
|
+
if (maximum * 3 + 3 + idWidth + 9 > width) return flatRows(selected, more);
|
|
131
|
+
|
|
132
|
+
// A child is last when no later sibling shares its parent; a truncated "more" marker keeps
|
|
133
|
+
// the final root open so the hidden-count line reads as a continuation.
|
|
134
|
+
const lastChild = new Map<number, number>();
|
|
135
|
+
for (const task of selected) lastChild.set(parents.get(task.id) ?? 0, task.id);
|
|
136
|
+
const last = new Set(lastChild.values());
|
|
137
|
+
if (more) {
|
|
138
|
+
const root = lastChild.get(0);
|
|
139
|
+
if (root !== undefined) last.delete(root);
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const rows: TreeRow[] = [];
|
|
143
|
+
open.clear();
|
|
144
|
+
path.length = 0;
|
|
145
|
+
for (const task of selected) {
|
|
146
|
+
let at = -1;
|
|
147
|
+
for (const id of task.blockedBy) {
|
|
148
|
+
const index = open.get(id);
|
|
149
|
+
if (index !== undefined && index > at) at = index;
|
|
150
|
+
}
|
|
151
|
+
const parent = at >= 0 ? path[at] : undefined;
|
|
152
|
+
let prefix = "";
|
|
153
|
+
for (let index = 0; index <= at; index++) prefix += last.has(path[index]!) ? " " : "│ ";
|
|
154
|
+
prefix += last.has(task.id) ? "└─ " : "├─ ";
|
|
155
|
+
rows.push({ task, prefix, dependencies: parent !== undefined ? task.blockedBy.filter((id) => id !== parent) : task.blockedBy });
|
|
156
|
+
if (at >= 0) {
|
|
157
|
+
for (let index = path.length - 1; index > at; index--) open.delete(path[index]!);
|
|
158
|
+
path.length = at + 1;
|
|
159
|
+
} else {
|
|
160
|
+
open.clear();
|
|
161
|
+
path.length = 0;
|
|
162
|
+
}
|
|
163
|
+
open.set(task.id, path.length);
|
|
164
|
+
path.push(task.id);
|
|
165
|
+
}
|
|
166
|
+
return rows;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
export type { AgentView } from "../agent-view.ts";
|
|
170
|
+
import type { AgentView } from "../agent-view.ts";
|
|
171
|
+
const agentLabels = (msg: Translator): Record<AgentView["status"], string> => ({ starting: glyph('starting') + " " + msg("启动中"), running: glyph('verify') + " " + msg("运行中"), waiting: glyph('waiting') + " " + msg("等待回复"), completed: glyph('complete') + " " + msg("已返回"), failed: glyph('failed') + " " + msg("失败"), cancelled: glyph('stop') + " " + msg("已取消"), interrupted: glyph('interrupted') + " " + msg("已中断") });
|
|
172
|
+
const agentColors: Record<AgentView["status"], ThemeColor> = { starting: "accent", running: "accent", waiting: "warning", completed: "success", failed: "error", cancelled: "dim", interrupted: "warning" };
|
|
173
|
+
const pendingReport = (job: AgentView): boolean => job.status === 'completed' && job.reportDelivery === 'pending';
|
|
174
|
+
const agentLabel = (job: AgentView, msg: Translator): string => (pendingReport(job) ? glyph('verify') + ' ' + msg('待交付') : agentLabels(msg)[job.status]);
|
|
175
|
+
const agentColor = (job: AgentView): ThemeColor => pendingReport(job) ? 'warning' : agentColors[job.status];
|
|
176
|
+
/**
|
|
177
|
+
* Live labels name the tool (MCP shortened after the last separator, clipped to 10 columns) and
|
|
178
|
+
* count real elapsed time without a cap: `59s`, then `1m40s`, and `1h2m` from the first hour.
|
|
179
|
+
* A missing or non-finite timestamp adds no time instead of NaN; a backwards clock clamps to 0,
|
|
180
|
+
* and the zero case adds nothing so a just-started tool shows only its name.
|
|
181
|
+
*/
|
|
182
|
+
function activityLabel(activity: NonNullable<AgentView["activity"]>, now: number, msg: Translator = chinese): string {
|
|
183
|
+
if (activity.kind === "thinking") return glyph('thinking') + " " + msg("思考中");
|
|
184
|
+
if (activity.kind === "output") return glyph('edit') + " " + msg("输出中");
|
|
185
|
+
if (activity.tool) {
|
|
186
|
+
const raw = clean(activity.tool);
|
|
187
|
+
const name = raw.startsWith("mcp__") ? raw.split("__").at(-1)! : raw.includes(":") ? raw.split(":").at(-1)! : raw;
|
|
188
|
+
const clipped = clip(name, 10);
|
|
189
|
+
const since = activity.since;
|
|
190
|
+
let time = "";
|
|
191
|
+
if (since !== undefined && Number.isFinite(since) && Number.isFinite(now)) {
|
|
192
|
+
const seconds = Math.max(0, Math.floor((now - since) / 1000));
|
|
193
|
+
const minutes = Math.floor(seconds / 60);
|
|
194
|
+
if (minutes >= 60) time = `${Math.floor(minutes / 60)}h${minutes % 60}m`;
|
|
195
|
+
else if (seconds >= 100) time = `${minutes}m${seconds % 60}s`;
|
|
196
|
+
else if (seconds > 0) time = `${seconds}s`;
|
|
197
|
+
}
|
|
198
|
+
return `${glyph('tool')} ${clipped}${time ? ` ${time}` : ""}`;
|
|
199
|
+
}
|
|
200
|
+
return glyph('tool') + " " + msg("工具");
|
|
201
|
+
}
|
|
202
|
+
const ACTIVE_LIVE = new Set(["starting", "running"]);
|
|
203
|
+
/**
|
|
204
|
+
* Acceptance projection for a job bound to a Todo row. The canonical Todo state wins the moment
|
|
205
|
+
* the task is completed, and a reopened (stale) report falls back to it too instead of implying a
|
|
206
|
+
* fresh handoff. Unfinished non-stale jobs keep live/failure labels; only terminal completion maps
|
|
207
|
+
* to the handoff (待交付) or the waiting-to-verify (待核验) stage. Unbound jobs keep agentLabel.
|
|
208
|
+
*/
|
|
209
|
+
const boundLabel = (task: Todo, job: AgentView, msg: Translator, resumable = false): string => {
|
|
210
|
+
if (task.status === "completed") return glyph('complete') + " " + msg("已完成");
|
|
211
|
+
// A native terminal status is already verified; a job cannot relabel it as a fresh handoff.
|
|
212
|
+
if (task.status === "failed" || task.status === "cancelled") return statusLabel(msg)[task.status];
|
|
213
|
+
// An explicit dismissal retires the attempt without changing the canonical task state.
|
|
214
|
+
if (resumable) return msg("可接续");
|
|
215
|
+
if (ACTIVE_LIVE.has(job.status) || job.status === "waiting") return agentLabels(msg)[job.status];
|
|
216
|
+
if (job.taskReportStale) return statusLabel(msg)[task.status];
|
|
217
|
+
if (job.status === "completed") return job.reportEmpty ? glyph('verify') + " " + msg("已结束·无报告") : (job.reportDelivery === "pending" ? glyph('verify') + " " + msg("待交付") : glyph('verify') + " " + msg("待核验"));
|
|
218
|
+
return agentLabels(msg)[job.status];
|
|
219
|
+
};
|
|
220
|
+
const boundColor = (task: Todo, job: AgentView, resumable = false): ThemeColor => {
|
|
221
|
+
if (task.status === "completed") return agentColors.completed;
|
|
222
|
+
if (task.status === "failed" || task.status === "cancelled") return colors[task.status];
|
|
223
|
+
if (resumable) return "accent";
|
|
224
|
+
if (ACTIVE_LIVE.has(job.status) || job.status === "waiting") return agentColors[job.status];
|
|
225
|
+
if (job.taskReportStale) return colors[task.status];
|
|
226
|
+
if (job.status === "completed") return "warning";
|
|
227
|
+
return agentColors[job.status];
|
|
228
|
+
};
|
|
229
|
+
/** Rows of the standalone section that carries jobs without a Todo of their own. */
|
|
230
|
+
const AGENT_ROWS = 4;
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* A job bound to a Todo reports on that task row. A job spawned without one has no row to appear
|
|
234
|
+
* on, so the panel lists it separately: live work first, then reports waiting for handoff, then
|
|
235
|
+
* closed jobs that still explain themselves. Delivered reports stay out.
|
|
236
|
+
*/
|
|
237
|
+
function agentRank(job: AgentView): number {
|
|
238
|
+
if (ACTIVE_LIVE.has(job.status) || job.status === "waiting") return 0;
|
|
239
|
+
return pendingReport(job) ? 1 : 2;
|
|
240
|
+
}
|
|
241
|
+
|
|
242
|
+
/** Same meaning as the Todo root mark: solid while the block still holds unfinished business. */
|
|
243
|
+
function agentRootMark(jobs: readonly AgentView[]): string {
|
|
244
|
+
return jobs.some((job) => ACTIVE_LIVE.has(job.status) || job.status === "waiting" || pendingReport(job)) ? "●" : "○";
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
function agentSection(jobs: readonly AgentView[], width: number, theme: Theme | undefined, msg: Translator, view: TaskView, limit: number, modelMax = 18, iconStyle: import('./symbols.ts').IconStyle = 'nerd-font'): string[] {
|
|
248
|
+
// Membership comes from the cached filter view; the live fields always come from this frame.
|
|
249
|
+
const unbound = jobs.filter((job) => view.standaloneIds.has(job.id));
|
|
250
|
+
if (!unbound.length) return [];
|
|
251
|
+
const ordered = [...unbound].sort((a, b) => agentRank(a) - agentRank(b));
|
|
252
|
+
const shown = limit === Infinity ? ordered : ordered.slice(0, limit);
|
|
253
|
+
const count = unbound.length;
|
|
254
|
+
// Plain mode has no group glyph, so the parts are joined instead of spaced: no empty gap.
|
|
255
|
+
const title = (label: string) => [agentRootMark(unbound), glyph('group', iconStyle), label].filter(part => part !== '').join(' ');
|
|
256
|
+
const full = title(msg`Standalone Agents · ${count}`);
|
|
257
|
+
// Narrow terminals keep the distinction and the count instead of clipping both away.
|
|
258
|
+
const compact = title(msg`Standalone · ${count}`);
|
|
259
|
+
const lines = [bounded(tint(visibleWidth(full) <= width ? full : compact, "accent", theme), width, theme)];
|
|
260
|
+
for (const [index, job] of shown.entries()) {
|
|
261
|
+
const prefix = index === shown.length - 1 && shown.length === ordered.length ? "└─ " : "├─ ";
|
|
262
|
+
const live = job.activity && ACTIVE_LIVE.has(job.status) ? job.activity : undefined;
|
|
263
|
+
const status = `[${live ? activityLabel(live, Date.now(), msg) : agentLabel(job, msg)}]`;
|
|
264
|
+
const statusWidth = visibleWidth(status);
|
|
265
|
+
// Narrow terminals keep the job and its state; the profile and the excerpt are presentation.
|
|
266
|
+
// Preserve the widget's full-label-or-drop contract; panels pass a bounded budget.
|
|
267
|
+
const profile = modelLabel(jobProfileLabel(job), Math.min(modelMax, modelBudget(width)));
|
|
268
|
+
const fullOwner = `${clean(job.id)}${profile ? ` · ${profile}` : ''}`;
|
|
269
|
+
const compact = visibleWidth(prefix + fullOwner) + statusWidth + 2 > width;
|
|
270
|
+
const owner = compact ? clean(job.id) : fullOwner;
|
|
271
|
+
const head = tint(prefix, "dim", theme) + tint(owner, "muted", theme);
|
|
272
|
+
const room = width - visibleWidth(prefix + owner) - statusWidth - 2;
|
|
273
|
+
const excerpt = room >= 4 ? clip(clean(job.label ?? ""), room) : "";
|
|
274
|
+
const text = excerpt ? ` ${tint(excerpt, "accent", theme)}` : "";
|
|
275
|
+
const gap = " ".repeat(Math.max(1, width - visibleWidth(prefix + owner) - visibleWidth(text) - statusWidth));
|
|
276
|
+
lines.push(bounded(head + text + gap + tint(status, agentColor(job), theme), width, theme));
|
|
277
|
+
}
|
|
278
|
+
if (ordered.length > shown.length) lines.push(tint(clip(msg`└─ … 隐藏 ${ordered.length - shown.length} 项`, width), "dim", theme));
|
|
279
|
+
return lines;
|
|
280
|
+
}
|
|
281
|
+
interface RowSegment { readonly color?: ThemeColor; readonly text: string }
|
|
282
|
+
interface RowPlan { readonly segments: readonly RowSegment[]; readonly fits: boolean }
|
|
283
|
+
interface RowGeometry { readonly byTodo: Map<number, AgentView>; readonly refWidth: number; readonly prefixWidth: number; readonly align: boolean }
|
|
284
|
+
interface RowFrame extends RowGeometry { readonly plans: readonly (RowPlan | null)[] }
|
|
285
|
+
|
|
286
|
+
const planOf = (segments: RowSegment[], width: number): RowPlan => {
|
|
287
|
+
let plain = "";
|
|
288
|
+
for (const segment of segments) plain += segment.text;
|
|
289
|
+
return { segments, fits: visibleWidth(plain) <= width };
|
|
290
|
+
};
|
|
291
|
+
/**
|
|
292
|
+
* Theme-independent row geometry: tree+id+refs, icon, title and the right-aligned owner/status
|
|
293
|
+
* suffix are laid out (and clipped) once per frame. Only `tint` runs per paint, so a theme change
|
|
294
|
+
* repaints colors without recomputing widths and truncation.
|
|
295
|
+
*/
|
|
296
|
+
function planRow(row: TreeRow, job: AgentView | undefined, frame: RowGeometry, width: number, msg: Translator, view: TaskView, now: number): RowPlan {
|
|
297
|
+
const { task, prefix, dependencies } = row;
|
|
298
|
+
const statics = staticsFor(task);
|
|
299
|
+
// A job failure or cancellation overrides an unfinished Todo; otherwise the Todo owns the row.
|
|
300
|
+
const effective = view.effective.get(task.id);
|
|
301
|
+
const display = effective === "failed" || effective === "cancelled" ? effective : task.status;
|
|
302
|
+
const resumable = view.resumableIds.has(task.id);
|
|
303
|
+
const icon = icons[display];
|
|
304
|
+
const iconColor = job ? boundColor(task, job, resumable) : colors[display];
|
|
305
|
+
// Three blocks: (1) tree+id+refs rendered exactly as-is, byte-identical to the compact form;
|
|
306
|
+
// (2) status icon+title merged as one unit starting at a unified column (fill sits after
|
|
307
|
+
// the reference, never inside block 1); (3) right-aligned [owner][status] suffix.
|
|
308
|
+
const refBudget = Math.max(0, width - (frame.align ? frame.prefixWidth : visibleWidth(prefix)) - visibleWidth(icon) - 2);
|
|
309
|
+
const budget = Math.min(refBudget, frame.align ? frame.refWidth : Math.max(visibleWidth(`#${task.id}`), Math.floor(width * 0.38)));
|
|
310
|
+
const ref = clip(reference(task, dependencies), budget);
|
|
311
|
+
const lead = frame.align ? " ".repeat(frame.prefixWidth - visibleWidth(prefix) + Math.max(0, budget - visibleWidth(ref))) : "";
|
|
312
|
+
const head: RowSegment[] = [{ color: "dim", text: prefix + ref + lead }, { text: " " }, { color: iconColor, text: icon }];
|
|
313
|
+
const available = width - visibleWidth(prefix + ref + lead) - visibleWidth(icon) - 2;
|
|
314
|
+
if (available <= 0) return planOf(head, width);
|
|
315
|
+
const title = statics.subject || msg("(无标题)");
|
|
316
|
+
const profile = job ? jobProfileLabel(job) : '';
|
|
317
|
+
const owner = job && !job.taskReportStale ? `${clean(job.id)}${profile ? ` · ${profile}` : ''}` : statics.owner || msg("主会话");
|
|
318
|
+
const fragmentText = statics.fragment ? `[${statics.fragment}]` : "";
|
|
319
|
+
// A completed Todo owns the label: late live or terminal job states must not contradict the task.
|
|
320
|
+
const live = job && task.status !== "completed" && job.activity && ACTIVE_LIVE.has(job.status) ? job.activity : undefined;
|
|
321
|
+
const label = job ? live ? activityLabel(live, now, msg) : boundLabel(task, job, msg, resumable) : statusLabel(msg)[task.status];
|
|
322
|
+
const minTitle = Math.min(6, visibleWidth(title));
|
|
323
|
+
const statusBudget = available - minTitle - 1;
|
|
324
|
+
const variants = live?.kind === "tool" ? [label, label.replace(/ (?:\d+[hms])+$/, ""), glyph("tool")] : [label];
|
|
325
|
+
const ownerText = `[${owner}]`;
|
|
326
|
+
// Tail order is fragment, owner, status; narrow terminals drop the fragment first, then the
|
|
327
|
+
// profile/model, then the owner, and keep the state that explains the row.
|
|
328
|
+
const tails = [
|
|
329
|
+
...(statics.fragment ? [[fragmentText, ownerText]] : []),
|
|
330
|
+
[ownerText],
|
|
331
|
+
...(job && !job.taskReportStale && profile ? [[`[${clean(job.id)}]`]] : []),
|
|
332
|
+
[],
|
|
333
|
+
];
|
|
334
|
+
let suffixHead = "";
|
|
335
|
+
let status = "";
|
|
336
|
+
for (const parts of tails) {
|
|
337
|
+
const prefixText = parts.length ? parts.join(" ") + " " : "";
|
|
338
|
+
const value = variants.find((candidate) => visibleWidth(prefixText) + visibleWidth(candidate) + 2 <= statusBudget);
|
|
339
|
+
if (value !== undefined) { suffixHead = prefixText; status = `[${value}]`; break; }
|
|
340
|
+
}
|
|
341
|
+
const suffix = suffixHead + status;
|
|
342
|
+
const titleBudget = available - (suffix ? visibleWidth(suffix) + 1 : 0);
|
|
343
|
+
let body = clip(title, titleBudget);
|
|
344
|
+
const active = display === "in_progress" ? statics.active : "";
|
|
345
|
+
const activeBudget = titleBudget - visibleWidth(title) - visibleWidth(" · ");
|
|
346
|
+
if (active && activeBudget >= 1) body = `${title} · ${clip(active, activeBudget)}`;
|
|
347
|
+
const gap = suffix ? frame.align ? " ".repeat(Math.max(1, available - visibleWidth(body) - visibleWidth(suffix))) : " " : "";
|
|
348
|
+
return planOf([
|
|
349
|
+
...head,
|
|
350
|
+
{ text: " " },
|
|
351
|
+
{ color: display === 'completed' ? 'dim' : 'accent', text: body },
|
|
352
|
+
...(gap ? [{ text: gap }] : []),
|
|
353
|
+
{ color: "muted", text: suffixHead },
|
|
354
|
+
{ color: job ? boundColor(task, job, resumable) : "muted", text: status },
|
|
355
|
+
], width);
|
|
356
|
+
}
|
|
357
|
+
const paintPlan = (plan: RowPlan, width: number, theme: Theme | undefined): string => {
|
|
358
|
+
const line = plan.segments.map((segment) => segment.color ? tint(segment.text, segment.color, theme) : segment.text).join("");
|
|
359
|
+
// Geometry already fit; only a defensive truncation of the (possibly pathological) overflow runs.
|
|
360
|
+
return plan.fits ? line : bounded(line, width, theme);
|
|
361
|
+
};
|
|
362
|
+
function buildFrame(rows: readonly TreeRow[], width: number, jobs: readonly AgentView[], msg: Translator, view: TaskView): RowFrame {
|
|
363
|
+
const byTodo = new Map(jobs.filter((job) => job.todoId !== undefined).map((job) => [job.todoId!, job]));
|
|
364
|
+
const refWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(reference(row.task, row.dependencies))), 0);
|
|
365
|
+
const prefixWidth = rows.reduce((max, row) => Math.max(max, visibleWidth(row.prefix)), 0);
|
|
366
|
+
// Alignment is optional presentation, never worth sacrificing the path or readable text.
|
|
367
|
+
const align = prefixWidth + refWidth + 4 + 6 + visibleWidth(`[${msg('主会话')}] [${msg('进行中')}]`) <= width;
|
|
368
|
+
const geometry: RowGeometry = { byTodo, refWidth, prefixWidth, align };
|
|
369
|
+
const plans = rows.map((row) => {
|
|
370
|
+
const job = byTodo.get(row.task.id);
|
|
371
|
+
// A live tool label embeds elapsed time, so that one row is laid out again on every paint.
|
|
372
|
+
const live = job && row.task.status !== "completed" && job.activity && ACTIVE_LIVE.has(job.status);
|
|
373
|
+
return live ? null : planRow(row, job, geometry, width, msg, view, 0);
|
|
374
|
+
});
|
|
375
|
+
return { ...geometry, plans };
|
|
376
|
+
}
|
|
377
|
+
const frameCache = snapshotCache<RowFrame>(4);
|
|
378
|
+
/** Only bound jobs draw on a row; their display-affecting fields (never elapsed time) key the frame. */
|
|
379
|
+
const boundJobSignature = (jobs: readonly AgentView[]): string => JSON.stringify(jobs.filter((job) => job.todoId !== undefined).map((job) => [job.id, job.todoId, job.status, job.reportDelivery ?? null, job.reportEmpty ? 1 : 0, job.taskReportStale ? 1 : 0, job.dismissed ? 1 : 0, job.profile, job.model?.provider ?? null, job.model?.id ?? null, job.label ?? null, job.activity?.kind ?? null, job.activity?.tool ?? null, job.activity?.since ?? null]));
|
|
380
|
+
function frameFor(rows: readonly TreeRow[], width: number, jobs: readonly AgentView[], msg: Translator, view: TaskView): RowFrame {
|
|
381
|
+
return frameCache(rows, `${width}\u0000${objectId(msg)}\u0000${objectId(view)}\u0000${boundJobSignature(jobs)}`, () => buildFrame(rows, width, jobs, msg, view));
|
|
382
|
+
}
|
|
383
|
+
function paintRows(rows: readonly TreeRow[], frame: RowFrame, width: number, theme: Theme | undefined, msg: Translator, view: TaskView): string[] {
|
|
384
|
+
// No live row means no clock read, exactly like the uncached renderer.
|
|
385
|
+
const now = frame.plans.some((plan) => plan === null) ? Date.now() : 0;
|
|
386
|
+
return rows.map((row, index) => {
|
|
387
|
+
const planned = frame.plans[index];
|
|
388
|
+
const plan = planned ?? planRow(row, frame.byTodo.get(row.task.id), frame, width, msg, view, now);
|
|
389
|
+
return paintPlan(plan, width, theme);
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
|
|
393
|
+
interface TaskViewOptions { maxRows?: number; agentRows?: number; priorityIds?: ReadonlySet<number>; dagHeader?: boolean; theme?: Theme; goalTitle?: string | undefined; jobs?: readonly AgentView[]; msg?: Translator; filter?: TodoFilter; modelMax?: number; iconStyle?: import('./symbols.ts').IconStyle }
|
|
394
|
+
/**
|
|
395
|
+
* Bounded preview projection. Recent unfinished work wins the row budget, then the
|
|
396
|
+
* most recent completed history fills the rest. Linear scans only, so a per-second redraw
|
|
397
|
+
* never sorts thousands of Todos. The full view keeps the canonical list order.
|
|
398
|
+
*/
|
|
399
|
+
function previewTasks(tasks: readonly Todo[], limit: number, priorityIds?: ReadonlySet<number>): Todo[] {
|
|
400
|
+
if (limit >= tasks.length) return [...tasks];
|
|
401
|
+
const chosen = new Set<Todo>();
|
|
402
|
+
if (priorityIds) for (let index = tasks.length - 1; index >= 0 && chosen.size < limit; index--) {
|
|
403
|
+
const task = tasks[index]!; if (priorityIds.has(task.id)) chosen.add(task);
|
|
404
|
+
}
|
|
405
|
+
for (let index = tasks.length - 1; index >= 0 && chosen.size < limit; index--) {
|
|
406
|
+
const task = tasks[index]!;
|
|
407
|
+
if (task.status === 'pending' || task.status === 'in_progress') chosen.add(task);
|
|
408
|
+
}
|
|
409
|
+
// Walk backwards: recent completed tasks are the useful context, not the oldest #1.
|
|
410
|
+
for (let index = tasks.length - 1; index >= 0 && chosen.size < limit; index--) {
|
|
411
|
+
const task = tasks[index]!;
|
|
412
|
+
if (task.status !== 'pending' && task.status !== 'in_progress') chosen.add(task);
|
|
413
|
+
}
|
|
414
|
+
// Selection decides the window; rendering keeps the canonical creation order.
|
|
415
|
+
return tasks.filter((task) => chosen.has(task));
|
|
416
|
+
}
|
|
417
|
+
interface RowSelection { readonly selected: readonly Todo[]; readonly more: boolean; readonly rows: TreeRow[] }
|
|
418
|
+
const selectionCache = snapshotCache<RowSelection>(4);
|
|
419
|
+
/** Bounded preview window and its structural rows, reused while the snapshot and width stay put. */
|
|
420
|
+
function selectionFor(tasks: readonly Todo[], style: string, width: number, limit: number, priorityIds?: ReadonlySet<number>): RowSelection {
|
|
421
|
+
return selectionCache(tasks, `${style}\u0000${width}\u0000${limit}\u0000${priorityIds ? [...priorityIds].join(',') : ''}`, () => {
|
|
422
|
+
const selected = previewTasks(tasks, limit, priorityIds);
|
|
423
|
+
const more = tasks.length > selected.length;
|
|
424
|
+
return { selected, more, rows: style === "flat" ? flatRows(selected, more) : pathRows(selected, width, more) };
|
|
425
|
+
});
|
|
426
|
+
}
|
|
427
|
+
const fallbackCache = snapshotCache<RowSelection>(4);
|
|
428
|
+
/** Flat list the DAG uses when its layout degrades; same window rules as the uncached path. */
|
|
429
|
+
function fallbackFor(tasks: readonly Todo[], limit: number): RowSelection {
|
|
430
|
+
return fallbackCache(tasks, `flat\u0000${limit}`, () => {
|
|
431
|
+
const selected = tasks.slice(0, limit === Infinity ? tasks.length : Math.max(1, limit - 2));
|
|
432
|
+
const more = selected.length < tasks.length;
|
|
433
|
+
return { selected, more, rows: flatRows(selected, more) };
|
|
434
|
+
});
|
|
435
|
+
}
|
|
436
|
+
export function renderTaskTree(state: WorkflowState, width: number, options?: TaskViewOptions): { title: string; rows: { taskId?: number; text: string }[] } {
|
|
437
|
+
width = columns(width);
|
|
438
|
+
if (!width) return { title: '', rows: [] };
|
|
439
|
+
const msg = options?.msg ?? chinese, jobs = options?.jobs ?? [], filter = options?.filter ?? state.filter ?? 'full';
|
|
440
|
+
const view = filteredView(state.tasks, filter, jobs), tasks = view.tasks;
|
|
441
|
+
const title = header(state, tasks, width, options?.theme, options?.goalTitle, options?.dagHeader ?? false, msg, filter).join('\n');
|
|
442
|
+
const requested = options?.maxRows ?? 8;
|
|
443
|
+
const limit = requested === Infinity ? tasks.length : Number.isFinite(requested) ? Math.max(0, Math.floor(requested)) : 8;
|
|
444
|
+
const selection = selectionFor(tasks, state.treeStyle === 'flat' ? 'flat' : 'chain', width, limit, options?.priorityIds);
|
|
445
|
+
const lines = paintRows(selection.rows, frameFor(selection.rows, width, jobs, msg, view), width, options?.theme, msg, view);
|
|
446
|
+
const rows: { taskId?: number; text: string }[] = lines.map((text, index) => ({ taskId: selection.rows[index]!.task.id, text }));
|
|
447
|
+
if (selection.more) rows.push({ text: tint(clip(msg`└─ … 隐藏 ${tasks.length - selection.selected.length} 项`, width), 'dim', options?.theme) });
|
|
448
|
+
return { title, rows };
|
|
449
|
+
}
|
|
450
|
+
|
|
451
|
+
/** Default: main dependency paths. Flat mode retains all predecessor references. */
|
|
452
|
+
export function renderTasks(state: WorkflowState, width: number, options?: TaskViewOptions): string[] {
|
|
453
|
+
width = columns(width);
|
|
454
|
+
if (!width) return [];
|
|
455
|
+
const msg = options?.msg ?? chinese;
|
|
456
|
+
const jobs = options?.jobs ?? [];
|
|
457
|
+
const filter = options?.filter ?? state.filter ?? "full";
|
|
458
|
+
const view = filteredView(state.tasks, filter, jobs);
|
|
459
|
+
const tree = renderTaskTree(state, width, options);
|
|
460
|
+
const lines = [...tree.title ? [tree.title] : [], ...tree.rows.map(row => row.text)];
|
|
461
|
+
const requested = options?.maxRows ?? 8;
|
|
462
|
+
// The bounded widget keeps four standalone rows; the complete view carries every job it shows.
|
|
463
|
+
lines.push(...agentSection(jobs, width, options?.theme, msg, view, requested === Infinity ? Infinity : options?.agentRows ?? AGENT_ROWS, options?.modelMax, options?.iconStyle));
|
|
464
|
+
return lines;
|
|
465
|
+
}
|
|
466
|
+
|
|
467
|
+
/** Complete solid-line graph, not a spanning tree. Only fallback lists repeat predecessor ids. */
|
|
468
|
+
export function renderDag(state: WorkflowState, width: number, theme?: Theme, goalTitle?: string, jobs: readonly AgentView[] = [], options?: { maxLines?: number; agentRows?: number; msg?: Translator; filter?: TodoFilter; modelMax?: number; iconStyle?: import('./symbols.ts').IconStyle }): string[] {
|
|
469
|
+
width = columns(width);
|
|
470
|
+
if (!width) return [];
|
|
471
|
+
const msg = options?.msg ?? chinese;
|
|
472
|
+
const filter = options?.filter ?? state.filter ?? "full";
|
|
473
|
+
const view = filteredView(state.tasks, filter, jobs);
|
|
474
|
+
const tasks = view.tasks;
|
|
475
|
+
const lines = header(state, tasks, width, theme, goalTitle, true, msg, filter);
|
|
476
|
+
// The graph keeps its own preview budget; Standalone is appended after it on every path exactly once.
|
|
477
|
+
const tail = agentSection(jobs, width, theme, msg, view, options?.maxLines === undefined ? Infinity : options?.agentRows ?? AGENT_ROWS, options?.modelMax, options?.iconStyle);
|
|
478
|
+
if (!tasks.length) return [...lines, ...tail];
|
|
479
|
+
const limit = options?.maxLines !== undefined && Number.isFinite(options.maxLines) ? Math.max(3, Math.floor(options.maxLines)) : Infinity;
|
|
480
|
+
const result = dagLayout(projection(view.dagTasks), width, msg);
|
|
481
|
+
if (!result.layout) {
|
|
482
|
+
lines.push(tint(clip(msg`图已降级为列表:${result.reason};左编号保留完整前驱`, width), "dim", theme));
|
|
483
|
+
const selection = fallbackFor(tasks, limit);
|
|
484
|
+
const rendered = paintRows(selection.rows, frameFor(selection.rows, width, jobs, msg, view), width, theme, msg, view);
|
|
485
|
+
const rows = selection.rows;
|
|
486
|
+
const selected = selection.selected;
|
|
487
|
+
for (const [index, row] of rows.entries()) {
|
|
488
|
+
if (lines.length >= limit) break;
|
|
489
|
+
lines.push(rendered[index]!);
|
|
490
|
+
const fullRef = reference(row.task, row.dependencies);
|
|
491
|
+
if (row.dependencies.length && !stripTerminalSequences(rendered[index]!).includes(fullRef)) {
|
|
492
|
+
// Never claim a truncated identifier contains all predecessors.
|
|
493
|
+
lines.push(...wrapTextWithAnsi(fullRef, width).map((part) => tint(part, 'dim', theme)));
|
|
494
|
+
}
|
|
495
|
+
}
|
|
496
|
+
if (selected.length < tasks.length || lines.length >= limit) return [...lines.slice(0, limit), tint(clip(msg`图预览 · 共 ${tasks.length} 项 · /dag 查看完整结构`, width), 'dim', theme), ...tail];
|
|
497
|
+
return [...lines, ...tail];
|
|
498
|
+
}
|
|
499
|
+
const layout = result.layout;
|
|
500
|
+
const byId = new Map(tasks.map((task) => [task.id, task]));
|
|
501
|
+
const byTodo = new Map(jobs.filter((job) => job.todoId !== undefined).map((job) => [job.todoId!, job]));
|
|
502
|
+
const bodies = new Map<number, { left: number; width: number; text: string }[]>();
|
|
503
|
+
const overflowRefs: string[] = [];
|
|
504
|
+
for (const box of layout.boxes) {
|
|
505
|
+
if (box.top + 1 >= limit) continue;
|
|
506
|
+
const task = byId.get(box.id)!;
|
|
507
|
+
const job = byTodo.get(box.id);
|
|
508
|
+
// Same override rule as a list row: a job failure or cancellation replaces an unfinished Todo.
|
|
509
|
+
const effective = view.effective.get(task.id);
|
|
510
|
+
const display = effective === "failed" || effective === "cancelled" ? effective : task.status;
|
|
511
|
+
const resumable = view.resumableIds.has(task.id);
|
|
512
|
+
const budget = box.width - 4;
|
|
513
|
+
// A filtered graph cannot draw an edge to a hidden prerequisite, so the box names it as a
|
|
514
|
+
// left-hand reference instead of implying the dependency is met.
|
|
515
|
+
const hidden = view.external.get(task.id) ?? [];
|
|
516
|
+
const idText = `#${task.id}${hidden.length ? `<-${hidden.map((id) => `#${id}`).join(",")}` : ""} `;
|
|
517
|
+
const prefix = `${icons[display]} ${idText}`;
|
|
518
|
+
const title = clip(clean(task.subject), Math.max(0, budget - visibleWidth(prefix)));
|
|
519
|
+
const rawFirst = tint(icons[display], colors[display], theme) + " " + tint(idText, "dim", theme) + tint(title, "accent", theme);
|
|
520
|
+
const first = bounded(rawFirst, budget, theme);
|
|
521
|
+
if (hidden.length && visibleWidth(prefix) > budget) overflowRefs.push(reference(task, hidden));
|
|
522
|
+
const profile = job ? jobProfileLabel(job) : '';
|
|
523
|
+
const owner = job && !job.taskReportStale ? `${clean(job.id)}${profile ? ` · ${profile}` : ''}` : staticsFor(task).owner || msg("主会话");
|
|
524
|
+
const ownerText = job && !job.taskReportStale && visibleWidth(`[${owner}]`) > budget ? `[${clean(job.id)}]` : `[${owner}]`;
|
|
525
|
+
const live = job && display !== "completed" && job.activity && ACTIVE_LIVE.has(job.status) ? job.activity : undefined;
|
|
526
|
+
const label = job ? live ? activityLabel(live, Date.now(), msg) : boundLabel(task, job, msg, resumable) : statusLabel(msg)[display];
|
|
527
|
+
const content = [first, tint(clip(ownerText, budget), "muted", theme), tint(clip(`[${label}]`, budget), job ? boundColor(task, job, resumable) : colors[display], theme)];
|
|
528
|
+
content.forEach((text, index) => {
|
|
529
|
+
const y = box.top + index + 1;
|
|
530
|
+
const group = bodies.get(y) ?? [];
|
|
531
|
+
group.push({ left: box.left + 1, width: box.width - 2, text: " " + text + " ".repeat(Math.max(0, box.width - 3 - visibleWidth(text))) });
|
|
532
|
+
bodies.set(y, group);
|
|
533
|
+
});
|
|
534
|
+
}
|
|
535
|
+
const offset = " ".repeat(Math.max(0, Math.floor((width - layout.width) / 2)));
|
|
536
|
+
for (const [y, raw] of layout.lines.slice(0, Math.max(0, limit - lines.length)).entries()) {
|
|
537
|
+
let x = 0; let row = offset;
|
|
538
|
+
for (const part of (bodies.get(y) ?? []).sort((a, b) => a.left - b.left)) {
|
|
539
|
+
row += tint(raw.slice(x, part.left), "dim", theme) + part.text;
|
|
540
|
+
x = part.left + part.width;
|
|
541
|
+
}
|
|
542
|
+
row += tint(raw.slice(x), "dim", theme);
|
|
543
|
+
lines.push(bounded(row, width, theme));
|
|
544
|
+
}
|
|
545
|
+
if (lines.length - 1 < layout.lines.length) lines.push(tint(clip(msg`图预览 ${lines.length}/${layout.lines.length + 1} 行 · /dag 滚动查看完整结构`, width), 'dim', theme));
|
|
546
|
+
else if (layout.crossings) lines.push(tint(clip(msg("╳ 仅交叉、不汇合;依赖从上向下读取"), width), "dim", theme));
|
|
547
|
+
if (overflowRefs.length) {
|
|
548
|
+
if (limit === Infinity) for (const ref of overflowRefs) lines.push(...wrapTextWithAnsi(ref, width).map((part) => tint(part, 'dim', theme)));
|
|
549
|
+
else lines.push(tint(clip(msg('前置引用已截断;/dag 查看完整结构'), width), 'dim', theme));
|
|
550
|
+
}
|
|
551
|
+
return [...lines, ...tail];
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** One bounded budget for the whole widget; complete detail screens have no such limit. */
|
|
555
|
+
export function widgetHeightBudget(rows: number): number {
|
|
556
|
+
return Math.max(1, Math.min(12, Math.floor((Number.isFinite(rows) ? rows : 36) / 3)));
|
|
557
|
+
}
|
|
558
|
+
const urgentIds = weakMemo((tasks: readonly Todo[]) => [
|
|
559
|
+
...tasks.filter((task) => task.status === 'in_progress').slice(-8),
|
|
560
|
+
...tasks.filter((task) => task.status === 'failed' || task.status === 'cancelled').slice(-4),
|
|
561
|
+
].map((task) => task.id));
|
|
562
|
+
|
|
563
|
+
/** A degraded widget must not claim clipped predecessor ids are complete. */
|
|
564
|
+
function widgetReferences(lines: string[], rows: readonly TreeRow[], start: number, budget: number, width: number, theme: Theme | undefined, msg: Translator, noticeAt?: number): string[] {
|
|
565
|
+
const missing = rows.flatMap((row, index) => {
|
|
566
|
+
const ref = reference(row.task, row.dependencies);
|
|
567
|
+
return row.dependencies.length && !stripTerminalSequences(lines[start + index] ?? '').includes(ref) ? [{ ref, index }] : [];
|
|
568
|
+
});
|
|
569
|
+
if (!missing.length) return lines;
|
|
570
|
+
const extra = missing.flatMap(({ ref }) => wrapTextWithAnsi(ref, width).map((part) => tint(part, 'dim', theme)));
|
|
571
|
+
if (lines.length + extra.length <= budget) lines.splice(start + rows.length, 0, ...extra);
|
|
572
|
+
else {
|
|
573
|
+
const warning = msg('前置引用已截断;/dag 查看完整结构');
|
|
574
|
+
const clause = warning.split(/[;;]/)[0]!;
|
|
575
|
+
// On a narrow terminal keep the verdict ("shortened"), not just the start of its subject.
|
|
576
|
+
const compact = visibleWidth(clause) <= width ? clause : '…' + sliceByColumn(clause, visibleWidth(clause) - width + 1, width - 1);
|
|
577
|
+
lines[noticeAt ?? start + missing[0]!.index] = tint(visibleWidth(warning) <= width ? warning : compact, 'dim', theme);
|
|
578
|
+
}
|
|
579
|
+
return lines;
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
export function renderWidget(state: WorkflowState, width: number, maxLines: number, options: TaskViewOptions = {}): string[] {
|
|
583
|
+
width = columns(width);
|
|
584
|
+
const budget = Math.max(0, Math.floor(maxLines));
|
|
585
|
+
if (!width || !budget) return [];
|
|
586
|
+
const msg = options.msg ?? chinese; const jobs = options.jobs ?? [];
|
|
587
|
+
const filter = options.filter ?? state.filter ?? 'full';
|
|
588
|
+
const view = filteredView(state.tasks, filter, jobs);
|
|
589
|
+
const tasks = view.tasks;
|
|
590
|
+
const unbound = jobs.filter((job) => view.standaloneIds.has(job.id)).sort((a, b) => agentRank(a) - agentRank(b));
|
|
591
|
+
const heading = header(state, tasks, width, options.theme, options.goalTitle, state.view === 'dag', msg, filter);
|
|
592
|
+
const agentCost = (rows: number) => unbound.length ? 1 + rows + (rows < unbound.length ? 1 : 0) : 0;
|
|
593
|
+
const priorityIds = new Set(urgentIds(tasks));
|
|
594
|
+
for (const [id, job] of latestBoundJobs(jobs)) {
|
|
595
|
+
const effective = view.effective.get(id);
|
|
596
|
+
if (effective !== undefined && effective !== 'completed' && (effective === 'failed' || effective === 'cancelled' || ACTIVE_LIVE.has(job.status) || job.status === 'waiting' || job.status === 'completed')) priorityIds.add(id);
|
|
597
|
+
}
|
|
598
|
+
const taskAttention = [...priorityIds].filter((id) => view.effective.has(id)).length;
|
|
599
|
+
const agentAttention = unbound.filter((job) => agentRank(job) < 2).length;
|
|
600
|
+
let notice: string | undefined;
|
|
601
|
+
if (state.view === 'dag' && tasks.length && options.maxRows !== 0) {
|
|
602
|
+
const graph = dagLayout(projection(view.dagTasks), width, msg);
|
|
603
|
+
// Keep the graph only when the normal Standalone preview fits too; a larger budget must
|
|
604
|
+
// not trade visible questions, activity or handoffs for boxes plus a hidden-count row.
|
|
605
|
+
const rows = Math.min(AGENT_ROWS, unbound.length);
|
|
606
|
+
if (graph.layout && graph.layout.lines.length + heading.length + (graph.layout.crossings ? 1 : 0) + agentCost(rows) <= budget) {
|
|
607
|
+
const lines = renderDag(state, width, options.theme, options.goalTitle, jobs, { maxLines: budget - agentCost(rows), agentRows: rows, msg, filter, ...(options.modelMax === undefined ? {} : { modelMax: options.modelMax }) });
|
|
608
|
+
if (lines.length <= budget) return lines;
|
|
609
|
+
}
|
|
610
|
+
notice = msg`图已降级为列表:${graph.reason ?? msg('终端高度不足')};左编号保留完整前驱`;
|
|
611
|
+
}
|
|
612
|
+
let choice: { todos: number; agents: number; score: number } | undefined;
|
|
613
|
+
for (let todos = 0; todos <= Math.min(options.maxRows ?? 8, tasks.length); todos++) for (let agents = 0; agents <= Math.min(4, unbound.length); agents++) {
|
|
614
|
+
const cost = heading.length + (notice ? 1 : 0) + todos + (todos < tasks.length ? 1 : 0) + agentCost(agents);
|
|
615
|
+
if (cost > budget) continue;
|
|
616
|
+
const score = 1000 * (Math.min(todos, taskAttention) + Math.min(agents, agentAttention)) + (todos ? 20 : 0) + (agents ? 20 : 0) + todos * 2 + agents;
|
|
617
|
+
if (!choice || score > choice.score) choice = { todos, agents, score };
|
|
618
|
+
}
|
|
619
|
+
if (choice && (choice.todos || choice.agents || !taskAttention && !agentAttention)) {
|
|
620
|
+
const lines = renderTasks(state, width, { ...options, msg, filter, maxRows: choice.todos, agentRows: choice.agents, priorityIds, dagHeader: state.view === 'dag' });
|
|
621
|
+
if (notice) {
|
|
622
|
+
lines.splice(heading.length, 0, tint(clip(notice, width), 'dim', options.theme));
|
|
623
|
+
const selection = selectionFor(tasks, state.treeStyle === 'flat' ? 'flat' : 'chain', width, choice.todos, priorityIds);
|
|
624
|
+
return widgetReferences(lines, selection.rows, heading.length + 1, budget, width, options.theme, msg, heading.length);
|
|
625
|
+
}
|
|
626
|
+
return lines;
|
|
627
|
+
}
|
|
628
|
+
// In a tiny terminal, retain the title and one urgent row before a compact count summary.
|
|
629
|
+
const lines = [...heading];
|
|
630
|
+
if (notice && budget >= heading.length + 2) lines.push(tint(clip(notice, width), 'dim', options.theme));
|
|
631
|
+
const selection = selectionFor(tasks, state.treeStyle === 'flat' ? 'flat' : 'chain', width, 1, priorityIds);
|
|
632
|
+
const taskRow = paintRows(selection.rows, frameFor(selection.rows, width, jobs, msg, view), width, options.theme, msg, view)[0];
|
|
633
|
+
const standalone = agentSection(jobs, width, options.theme, msg, view, 1, options.modelMax, options.iconStyle);
|
|
634
|
+
if (!lines.length && standalone.length) lines.push(standalone[0]!);
|
|
635
|
+
const row = unbound[0]?.status === 'waiting' || options.maxRows === 0 ? standalone[1] : taskAttention ? taskRow : standalone[1] ?? taskRow;
|
|
636
|
+
const rowAt = lines.length;
|
|
637
|
+
if (row && lines.length < budget) lines.push(row);
|
|
638
|
+
if (lines.length < budget) lines.push(tint(clip(msg`预览 · Todos ${tasks.length} · Standalone ${unbound.length} · /todos /agents`, width), 'dim', options.theme));
|
|
639
|
+
if (notice && row === taskRow && rowAt < budget) widgetReferences(lines, selection.rows, rowAt, budget, width, options.theme, msg, rowAt > heading.length ? heading.length : undefined);
|
|
640
|
+
return lines.slice(0, budget);
|
|
641
|
+
}
|