pi-cockpit 0.1.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/README.md +87 -0
- package/package.json +70 -0
- package/src/agents-store.ts +270 -0
- package/src/ambient.ts +72 -0
- package/src/bash-bg-overlay.ts +362 -0
- package/src/bash-bg-store.ts +90 -0
- package/src/bash-bg-widget.ts +70 -0
- package/src/config.ts +86 -0
- package/src/extension/index.ts +1 -0
- package/src/extension-status.ts +25 -0
- package/src/footer.ts +354 -0
- package/src/icons.ts +166 -0
- package/src/index.ts +595 -0
- package/src/layout.ts +129 -0
- package/src/render.ts +441 -0
- package/src/settings-view.ts +129 -0
- package/src/stack-widget.ts +121 -0
- package/src/theme-picker.ts +255 -0
- package/src/todo-store.ts +123 -0
- package/src/types.ts +112 -0
- package/src/viewport.ts +62 -0
- package/themes/cockpit-amber.json +24 -0
- package/themes/cockpit-minimal-amber.json +23 -0
- package/themes/cockpit-minimal-cyan.json +23 -0
- package/themes/cockpit-minimal-green.json +23 -0
- package/themes/cockpit-minimal-purple.json +23 -0
- package/themes/cockpit-minimal-rose.json +23 -0
- package/themes/cockpit-minimal.json +23 -0
- package/themes/cockpit-notion.json +24 -0
- package/themes/cockpit-ocean.json +24 -0
package/src/layout.ts
ADDED
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
// Shared line-fitting primitives.
|
|
2
|
+
//
|
|
3
|
+
// Every cockpit surface renders "a list of segments on one terminal line". The
|
|
4
|
+
// footer already degraded segment-by-segment while the widgets blind-clipped the
|
|
5
|
+
// whole joined line, which meant the trailing segments (duration, the Alt+J hint,
|
|
6
|
+
// the next-task label) were always the first casualties — exactly the tokens the
|
|
7
|
+
// non-color-accessibility spec says must survive. This module is the single
|
|
8
|
+
// implementation both sides use.
|
|
9
|
+
|
|
10
|
+
// Width helpers are injected so these stay pure and hermetic in unit tests; the
|
|
11
|
+
// real widgets pass pi-tui's visibleWidth / truncateToWidth so east-asian widths
|
|
12
|
+
// are correct on a terminal.
|
|
13
|
+
export interface WidthUtils {
|
|
14
|
+
measure: (text: string) => number;
|
|
15
|
+
clip: (text: string, width: number, ellipsis: string) => string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export interface PrioritizedSegment {
|
|
19
|
+
text: string;
|
|
20
|
+
/** Higher survives longer. The lowest-priority segment is sacrificed first. */
|
|
21
|
+
priority: number;
|
|
22
|
+
/**
|
|
23
|
+
* When false the segment is dropped outright rather than ellipsized, so a
|
|
24
|
+
* segment can never degrade into a bare "…" that occupies space and says
|
|
25
|
+
* nothing. Defaults to true.
|
|
26
|
+
*/
|
|
27
|
+
clippable?: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* Narrowest width at which this segment still carries meaning. Below it the
|
|
30
|
+
* segment is dropped instead of clipped. Segments that carry a glyph prefix
|
|
31
|
+
* need this: clipping "{icon} ~/work/project" to two columns leaves the icon
|
|
32
|
+
* and an ellipsis — decoration with the content eaten out of it.
|
|
33
|
+
*/
|
|
34
|
+
minWidth?: number;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Drop and clip segments by ascending priority until the joined line fits.
|
|
39
|
+
*
|
|
40
|
+
* A segment is only ellipsized when the remaining room still leaves visible
|
|
41
|
+
* characters beyond the ellipsis itself; otherwise it is dropped, because a
|
|
42
|
+
* lone "…" costs a column and carries no information.
|
|
43
|
+
*/
|
|
44
|
+
export function fitSegmentsByPriority(
|
|
45
|
+
segs: readonly PrioritizedSegment[],
|
|
46
|
+
maxW: number,
|
|
47
|
+
measure: WidthUtils["measure"],
|
|
48
|
+
clip: WidthUtils["clip"],
|
|
49
|
+
ellipsis = "...",
|
|
50
|
+
separatorWidth = 1,
|
|
51
|
+
): string[] {
|
|
52
|
+
const ellipsisW = measure(ellipsis);
|
|
53
|
+
const items = segs.map((s) => ({
|
|
54
|
+
text: s.text,
|
|
55
|
+
priority: s.priority,
|
|
56
|
+
clippable: s.clippable !== false,
|
|
57
|
+
minWidth: Math.max(s.minWidth ?? 0, ellipsisW + 1),
|
|
58
|
+
w: measure(s.text),
|
|
59
|
+
}));
|
|
60
|
+
const totalW = (): number => {
|
|
61
|
+
const active = items.filter((it) => it.text !== "");
|
|
62
|
+
return active.reduce((a, it) => a + it.w, 0) + Math.max(0, active.length - 1) * separatorWidth;
|
|
63
|
+
};
|
|
64
|
+
while (totalW() > maxW) {
|
|
65
|
+
let target = -1;
|
|
66
|
+
for (let i = 0; i < items.length; i++) {
|
|
67
|
+
if (items[i].text !== "" && (target === -1 || items[i].priority < items[target].priority)) {
|
|
68
|
+
target = i;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
if (target === -1) break;
|
|
72
|
+
const others = items.filter((_, i) => i !== target && items[i].text !== "");
|
|
73
|
+
const otherW = others.reduce((a, it) => a + it.w, 0) + Math.max(0, others.length - 1) * separatorWidth;
|
|
74
|
+
const avail = maxW - otherW - (others.length > 0 ? separatorWidth : 0);
|
|
75
|
+
// Ellipsizing down to decoration-plus-ellipsis wastes columns and says
|
|
76
|
+
// nothing, so a segment must clear its minWidth to survive the clip.
|
|
77
|
+
const drop = (): void => {
|
|
78
|
+
items[target].text = "";
|
|
79
|
+
items[target].w = 0;
|
|
80
|
+
};
|
|
81
|
+
if (!items[target].clippable || avail < items[target].minWidth) {
|
|
82
|
+
drop();
|
|
83
|
+
} else if (avail < items[target].w) {
|
|
84
|
+
const clipped = clip(items[target].text, avail, ellipsis);
|
|
85
|
+
const clippedW = measure(clipped);
|
|
86
|
+
if (clippedW < items[target].minWidth) drop();
|
|
87
|
+
else {
|
|
88
|
+
items[target].text = clipped;
|
|
89
|
+
items[target].w = clippedW;
|
|
90
|
+
}
|
|
91
|
+
} else {
|
|
92
|
+
break;
|
|
93
|
+
}
|
|
94
|
+
}
|
|
95
|
+
return items.filter((it) => it.text !== "").map((it) => it.text);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/**
|
|
99
|
+
* Join prioritized segments into a single line that fits `width`.
|
|
100
|
+
*
|
|
101
|
+
* The common case at every call site: build segments, fit them, join with the
|
|
102
|
+
* separator. Returns "" when nothing survives.
|
|
103
|
+
*/
|
|
104
|
+
export function fitLineByPriority(
|
|
105
|
+
segs: readonly PrioritizedSegment[],
|
|
106
|
+
width: number,
|
|
107
|
+
utils: WidthUtils,
|
|
108
|
+
separator: string,
|
|
109
|
+
ellipsis: string,
|
|
110
|
+
): string {
|
|
111
|
+
if (width <= 0) return "";
|
|
112
|
+
const fitted = fitSegmentsByPriority(
|
|
113
|
+
segs,
|
|
114
|
+
width,
|
|
115
|
+
utils.measure,
|
|
116
|
+
utils.clip,
|
|
117
|
+
ellipsis,
|
|
118
|
+
utils.measure(separator),
|
|
119
|
+
);
|
|
120
|
+
return fitted.join(separator);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* First visible index of a scrolling window that keeps `selected` centred where
|
|
125
|
+
* it can, and flush against either end where it cannot.
|
|
126
|
+
*/
|
|
127
|
+
export function visibleStart(selected: number, length: number, size: number): number {
|
|
128
|
+
return Math.max(0, Math.min(selected - Math.floor(size / 2), Math.max(0, length - size)));
|
|
129
|
+
}
|
package/src/render.ts
ADDED
|
@@ -0,0 +1,441 @@
|
|
|
1
|
+
import type { Theme, ThemeColor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import type { AgentRow, TodoItem, ViewMode } from "./types.ts";
|
|
3
|
+
import type { IconGlyphs } from "./icons.ts";
|
|
4
|
+
import { fitLineByPriority, type PrioritizedSegment, type WidthUtils } from "./layout.ts";
|
|
5
|
+
import { fitRows } from "./viewport.ts";
|
|
6
|
+
|
|
7
|
+
// Re-exported for existing importers; the shared implementation lives in layout.ts.
|
|
8
|
+
export type { WidthUtils };
|
|
9
|
+
|
|
10
|
+
// Below this the layout must degrade to an action-first single column
|
|
11
|
+
// (terminal control-center spec).
|
|
12
|
+
export const NARROW_WIDTH = 40;
|
|
13
|
+
|
|
14
|
+
// Dependencies are result-flow edges, never hierarchy, so they stay in their own
|
|
15
|
+
// segment. Long chains collapse to a count instead of pushing the task off the row.
|
|
16
|
+
const MAX_LISTED_DEPENDENCIES = 3;
|
|
17
|
+
|
|
18
|
+
function formatDependencies(dependencies: readonly number[], glyphs: IconGlyphs): string {
|
|
19
|
+
const listed = dependencies.slice(0, MAX_LISTED_DEPENDENCIES).map((d) => `#${d + 1}`).join(",");
|
|
20
|
+
const rest = dependencies.length - MAX_LISTED_DEPENDENCIES;
|
|
21
|
+
return `${glyphs.depArrow} ${listed}${rest > 0 ? `,+${rest}` : ""}`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// Weight and strikethrough are channels the hue palette cannot carry, so they are
|
|
25
|
+
// part of the paint surface. They stay optional because the theme is injected: the
|
|
26
|
+
// unit tests pass a bare `fg` stub, and a missing helper must degrade to plain text
|
|
27
|
+
// rather than throw.
|
|
28
|
+
export type PaintTheme = Pick<Theme, "fg"> & Partial<Pick<Theme, "bold" | "strikethrough">>;
|
|
29
|
+
|
|
30
|
+
function bold(theme: PaintTheme, text: string): string {
|
|
31
|
+
return theme.bold ? theme.bold(text) : text;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function struck(theme: PaintTheme, text: string): string {
|
|
35
|
+
return theme.strikethrough ? theme.strikethrough(text) : text;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export interface RenderOpts {
|
|
39
|
+
glyphs: IconGlyphs;
|
|
40
|
+
spin?: string;
|
|
41
|
+
now?: number;
|
|
42
|
+
expanded?: boolean;
|
|
43
|
+
/**
|
|
44
|
+
* How the user actually toggles the todo panel. `alt+t` is registered by
|
|
45
|
+
* pi-maestro-flow, which forwards to cockpit through COCKPIT_TODO_TOGGLE_EVENT,
|
|
46
|
+
* so it is accurate in the shipped pairing. A standalone install without that
|
|
47
|
+
* owner overrides this with the always-available `/cockpit todo`.
|
|
48
|
+
*/
|
|
49
|
+
toggleHint?: string;
|
|
50
|
+
/**
|
|
51
|
+
* Compact agent mode prints its own roster count. When the widget already
|
|
52
|
+
* renders an "Agents N running" header, that would be the same sentence twice
|
|
53
|
+
* on adjacent rows — a wasted row out of a very small vertical budget.
|
|
54
|
+
*/
|
|
55
|
+
withHead?: boolean;
|
|
56
|
+
/**
|
|
57
|
+
* Total rows this call may return, including its own summary and overflow
|
|
58
|
+
* marker. Undefined means the terminal height is unknown, so the historical
|
|
59
|
+
* fixed caps apply.
|
|
60
|
+
*/
|
|
61
|
+
maxRows?: number;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export const DEFAULT_TOGGLE_HINT = "Alt+T";
|
|
65
|
+
|
|
66
|
+
// One cell per task stops scaling once the plan outgrows the terminal, so beyond
|
|
67
|
+
// that the bar switches to a proportional summary instead of eating the whole line.
|
|
68
|
+
function renderTodoBar(
|
|
69
|
+
items: readonly TodoItem[],
|
|
70
|
+
width: number,
|
|
71
|
+
theme: PaintTheme,
|
|
72
|
+
g: IconGlyphs,
|
|
73
|
+
): string {
|
|
74
|
+
const cell = (st: TodoItem["status"]): string => {
|
|
75
|
+
if (st === "completed") return theme.fg("success", g.barDone);
|
|
76
|
+
if (st === "in_progress") return theme.fg("warning", g.barActive);
|
|
77
|
+
if (st === "blocked") return theme.fg("error", g.blocked);
|
|
78
|
+
return theme.fg("dim", g.barPending);
|
|
79
|
+
};
|
|
80
|
+
const budget = Math.max(4, Math.floor(width * 0.3));
|
|
81
|
+
if (items.length <= budget) return items.map((i) => cell(i.status)).join("");
|
|
82
|
+
|
|
83
|
+
const order: TodoItem["status"][] = ["completed", "in_progress", "blocked", "pending"];
|
|
84
|
+
const counts = order.map((st) => items.filter((i) => i.status === st).length);
|
|
85
|
+
// Largest-remainder allocation so a non-empty bucket never rounds away to zero.
|
|
86
|
+
const exact = counts.map((n) => (n / items.length) * budget);
|
|
87
|
+
const cells = exact.map(Math.floor);
|
|
88
|
+
for (let i = 0; i < cells.length; i++) if (counts[i] > 0 && cells[i] === 0) cells[i] = 1;
|
|
89
|
+
let overflow = cells.reduce((a, b) => a + b, 0) - budget;
|
|
90
|
+
for (let i = cells.length - 1; i >= 0 && overflow > 0; i--) {
|
|
91
|
+
const spare = Math.min(overflow, Math.max(0, cells[i] - 1));
|
|
92
|
+
cells[i] -= spare;
|
|
93
|
+
overflow -= spare;
|
|
94
|
+
}
|
|
95
|
+
return order.map((st, i) => cell(st).repeat(cells[i])).join("");
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatDuration(ms: number): string {
|
|
99
|
+
const totalSeconds = Math.max(0, Math.floor(ms / 1000));
|
|
100
|
+
if (totalSeconds < 60) return `${totalSeconds}s`;
|
|
101
|
+
const s = totalSeconds % 60;
|
|
102
|
+
const totalMinutes = Math.floor(totalSeconds / 60);
|
|
103
|
+
if (totalMinutes < 60) return `${totalMinutes}m ${s}s`;
|
|
104
|
+
const m = totalMinutes % 60;
|
|
105
|
+
const h = Math.floor(totalMinutes / 60);
|
|
106
|
+
return `${h}h ${m}m ${s}s`;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
// Role is identity, status is state — they must not share a palette. Reusing
|
|
110
|
+
// success/warning/error for roles meant a green row could mean "executor" or
|
|
111
|
+
// "done", and every theme inherits that ambiguity. Roles therefore draw from the
|
|
112
|
+
// theme's identity colors, which carry no success/failure meaning anywhere.
|
|
113
|
+
const ROLE_PALETTE: readonly ThemeColor[] = [
|
|
114
|
+
"syntaxFunction",
|
|
115
|
+
"syntaxType",
|
|
116
|
+
"syntaxString",
|
|
117
|
+
"syntaxVariable",
|
|
118
|
+
"syntaxKeyword",
|
|
119
|
+
"mdLink",
|
|
120
|
+
];
|
|
121
|
+
|
|
122
|
+
// Stable per-name assignment so an unknown role still gets a consistent colour
|
|
123
|
+
// instead of collapsing into undifferentiated body text.
|
|
124
|
+
function roleColor(role: string): ThemeColor {
|
|
125
|
+
let hash = 0;
|
|
126
|
+
for (let i = 0; i < role.length; i++) hash = (hash * 31 + role.charCodeAt(i)) >>> 0;
|
|
127
|
+
return ROLE_PALETTE[hash % ROLE_PALETTE.length];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
interface AgentTreeEntry {
|
|
131
|
+
row: AgentRow;
|
|
132
|
+
prefix: string;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
function buildAgentTree(rows: readonly AgentRow[], glyphs: IconGlyphs): AgentTreeEntry[] {
|
|
136
|
+
const activityOrder = (a: AgentRow, b: AgentRow): number =>
|
|
137
|
+
b.lastActivityAt - a.lastActivityAt || a.correlationId.localeCompare(b.correlationId);
|
|
138
|
+
const byId = new Map(rows.map((row) => [row.correlationId, row]));
|
|
139
|
+
const children = new Map<string, AgentRow[]>();
|
|
140
|
+
const roots: AgentRow[] = [];
|
|
141
|
+
for (const row of rows) {
|
|
142
|
+
const parent = row.parentCorrelationId;
|
|
143
|
+
if (!parent || parent === row.correlationId || !byId.has(parent)) {
|
|
144
|
+
roots.push(row);
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
const siblings = children.get(parent) ?? [];
|
|
148
|
+
siblings.push(row);
|
|
149
|
+
children.set(parent, siblings);
|
|
150
|
+
}
|
|
151
|
+
roots.sort(activityOrder);
|
|
152
|
+
for (const siblings of children.values()) siblings.sort(activityOrder);
|
|
153
|
+
|
|
154
|
+
const entries: AgentTreeEntry[] = [];
|
|
155
|
+
const visited = new Set<string>();
|
|
156
|
+
const append = (row: AgentRow, ancestors: string, isLast: boolean): void => {
|
|
157
|
+
if (visited.has(row.correlationId)) return;
|
|
158
|
+
visited.add(row.correlationId);
|
|
159
|
+
entries.push({ row, prefix: `${ancestors}${isLast ? glyphs.treeLast : glyphs.treeBranch}` });
|
|
160
|
+
const descendants = children.get(row.correlationId) ?? [];
|
|
161
|
+
const nextAncestors = `${ancestors}${isLast ? glyphs.treeSpace : glyphs.treeVertical}`;
|
|
162
|
+
descendants.forEach((child, index) => append(child, nextAncestors, index === descendants.length - 1));
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
roots.forEach((root, index) => append(root, "", index === roots.length - 1));
|
|
166
|
+
// Broken or cyclic parent references must never hide rows or recurse forever.
|
|
167
|
+
for (const row of [...rows].sort(activityOrder)) {
|
|
168
|
+
if (!visited.has(row.correlationId)) append(row, "", true);
|
|
169
|
+
}
|
|
170
|
+
return entries;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function agentGlyph(
|
|
174
|
+
status: AgentRow["status"],
|
|
175
|
+
glyphs: IconGlyphs,
|
|
176
|
+
spin: string,
|
|
177
|
+
): { glyph: string; color: ThemeColor; label?: string } {
|
|
178
|
+
if (status === "failed") return { glyph: glyphs.cross, color: "error" };
|
|
179
|
+
if (status === "done") return { glyph: glyphs.check, color: "success" };
|
|
180
|
+
if (status === "pending") return { glyph: glyphs.pending, color: "dim", label: "pending" };
|
|
181
|
+
if (status === "sleeping") return { glyph: glyphs.dotIdle, color: "warning", label: "sleeping" };
|
|
182
|
+
if (status === "retrying") return { glyph: spin, color: "warning", label: "retrying" };
|
|
183
|
+
return { glyph: spin, color: "accent" };
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
// Empty roster => no lines (the bare-pi guard: nothing to draw, widget stays hidden).
|
|
187
|
+
export function renderAgents(
|
|
188
|
+
rows: readonly AgentRow[],
|
|
189
|
+
mode: ViewMode,
|
|
190
|
+
width: number,
|
|
191
|
+
theme: PaintTheme,
|
|
192
|
+
utils: WidthUtils,
|
|
193
|
+
opts: RenderOpts,
|
|
194
|
+
): string[] {
|
|
195
|
+
if (rows.length === 0) return [];
|
|
196
|
+
const g = opts.glyphs;
|
|
197
|
+
const ell = theme.fg("dim", g.ellipsis);
|
|
198
|
+
if (mode === "compact") {
|
|
199
|
+
const active = rows.filter((row) => row.status === "running" || row.status === "retrying").length;
|
|
200
|
+
const head = `${theme.fg("text", String(rows.length))} ${theme.fg("muted", "agents")}${active ? theme.fg("dim", ` · ${active} active`) : ""}`;
|
|
201
|
+
const tails = rows
|
|
202
|
+
.filter((r) => r.tail)
|
|
203
|
+
.map((r) => `${theme.fg(roleColor(r.role), r.role)}${theme.fg("dim", ":")} ${theme.fg("dim", r.tail)}`);
|
|
204
|
+
const parts = opts.withHead === false ? tails : [head, ...tails];
|
|
205
|
+
if (parts.length === 0) return [];
|
|
206
|
+
return [utils.clip(parts.join(theme.fg("dim", g.separator)), width, ell)];
|
|
207
|
+
}
|
|
208
|
+
const spin = opts.spin ?? "~";
|
|
209
|
+
const now = opts.now ?? Date.now();
|
|
210
|
+
const tree = buildAgentTree(rows, g);
|
|
211
|
+
// Width decides how much a row can say; height decides how many rows exist.
|
|
212
|
+
// The width cap counts roster rows and lets the overflow marker sit on top of
|
|
213
|
+
// it; maxRows is a hard ceiling on everything this call emits, marker included.
|
|
214
|
+
const widthCap = width < NARROW_WIDTH ? 3 : 6;
|
|
215
|
+
const budget = Math.min(widthCap + 1, opts.maxRows ?? Number.POSITIVE_INFINITY);
|
|
216
|
+
const { visible: visibleCount, hidden } = fitRows(tree.length, budget);
|
|
217
|
+
const visible = tree.slice(0, visibleCount);
|
|
218
|
+
|
|
219
|
+
// Priorities, not concatenation order, decide what survives a narrow terminal.
|
|
220
|
+
// Identity (tree position, state, role, task) outranks telemetry (tool, tail,
|
|
221
|
+
// counts) so the row still answers "who is doing what" at 40 columns.
|
|
222
|
+
const lines = visible.map(({ row: r, prefix }) => {
|
|
223
|
+
const status = agentGlyph(r.status, g, spin);
|
|
224
|
+
const rc = roleColor(r.role);
|
|
225
|
+
const segs: PrioritizedSegment[] = [
|
|
226
|
+
{ text: theme.fg("dim", prefix), priority: 100, clippable: false },
|
|
227
|
+
{ text: theme.fg(status.color, status.glyph), priority: 99, clippable: false },
|
|
228
|
+
];
|
|
229
|
+
if (status.label) segs.push({ text: theme.fg(status.color, status.label), priority: 95, clippable: false });
|
|
230
|
+
segs.push({ text: theme.fg(rc, r.role), priority: 90 });
|
|
231
|
+
if (r.task) segs.push({ text: r.task, priority: 80, minWidth: 6 });
|
|
232
|
+
segs.push({ text: theme.fg("muted", formatDuration(now - r.startedAt)), priority: 70, clippable: false });
|
|
233
|
+
if (r.dependencies?.length) {
|
|
234
|
+
segs.push({ text: theme.fg("dim", formatDependencies(r.dependencies, g)), priority: 60, clippable: false });
|
|
235
|
+
}
|
|
236
|
+
if (r.activeTool) segs.push({ text: theme.fg("accent", `tool ${r.activeTool}`), priority: 50, minWidth: 8 });
|
|
237
|
+
if (r.tail) segs.push({ text: theme.fg("dim", r.tail), priority: 40, minWidth: 8 });
|
|
238
|
+
if (r.toolCount !== undefined) segs.push({ text: theme.fg("muted", `${r.toolCount} tools`), priority: 20, clippable: false });
|
|
239
|
+
if (r.tokens !== undefined) segs.push({ text: theme.fg("muted", `${r.tokens} tok`), priority: 10, clippable: false });
|
|
240
|
+
return fitLineByPriority(segs, width, utils, " ", g.ellipsis);
|
|
241
|
+
});
|
|
242
|
+
if (hidden > 0) {
|
|
243
|
+
lines.push(utils.clip(theme.fg("dim", `${g.treeLast} ${g.ellipsis} ${hidden} more`), width, ell));
|
|
244
|
+
}
|
|
245
|
+
return lines;
|
|
246
|
+
}
|
|
247
|
+
|
|
248
|
+
const TODO_MAX_VISIBLE = 8;
|
|
249
|
+
const RECENT_COMPLETED_MS = 30_000;
|
|
250
|
+
|
|
251
|
+
function todoDisplayRank(item: TodoItem, now: number): number {
|
|
252
|
+
if (item.status === "completed") {
|
|
253
|
+
const recent = item.updatedAt !== undefined && now - item.updatedAt < RECENT_COMPLETED_MS;
|
|
254
|
+
return recent ? 0 : 4;
|
|
255
|
+
}
|
|
256
|
+
if (item.status === "in_progress") return 1;
|
|
257
|
+
if (item.status === "blocked") return 2;
|
|
258
|
+
return 3;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
function findNextTodo(items: readonly TodoItem[]): TodoItem | undefined {
|
|
262
|
+
return items.find((i) => i.status === "in_progress")
|
|
263
|
+
?? items.find((i) => i.status === "pending" && i.blockedBy.length === 0)
|
|
264
|
+
?? items.find((i) => i.status === "blocked" || i.status === "pending");
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
function todoActorLabel(actor: NonNullable<TodoItem["assignee"]>, items: readonly TodoItem[]): string {
|
|
268
|
+
const collidingIds = new Set(items
|
|
269
|
+
.flatMap((item) => [item.createdBy, item.assignee])
|
|
270
|
+
.filter((candidate): candidate is NonNullable<TodoItem["assignee"]> =>
|
|
271
|
+
Boolean(candidate && candidate.label === actor.label))
|
|
272
|
+
.map((candidate) => candidate.id));
|
|
273
|
+
if (collidingIds.size < 2) return actor.label;
|
|
274
|
+
for (let length = Math.min(4, actor.id.length); length < actor.id.length; length++) {
|
|
275
|
+
const prefix = actor.id.slice(0, length);
|
|
276
|
+
if ([...collidingIds].every((candidate) => candidate === actor.id || !candidate.startsWith(prefix))) {
|
|
277
|
+
return `${actor.label}#${prefix}`;
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return `${actor.label}#${actor.id}`;
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
function todoActor(item: TodoItem, items: readonly TodoItem[]): string {
|
|
284
|
+
if (!item.assignee) return "";
|
|
285
|
+
const assigned = `@${todoActorLabel(item.assignee, items)}`;
|
|
286
|
+
if (!item.createdBy || item.createdBy.id === item.assignee.id) return assigned;
|
|
287
|
+
return `@${todoActorLabel(item.createdBy, items)}→${assigned}`;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
// One table for the whole todo surface: the summary's next-task label and the
|
|
291
|
+
// expanded rows must not drift apart, and every status has to differ from its
|
|
292
|
+
// neighbours by glyph *and* hue *and* weight — hue alone is not a channel a
|
|
293
|
+
// colour-blind or monochrome terminal can read.
|
|
294
|
+
interface TodoPaint {
|
|
295
|
+
glyph: string;
|
|
296
|
+
glyphColor: ThemeColor;
|
|
297
|
+
subjectColor: ThemeColor;
|
|
298
|
+
bold?: boolean;
|
|
299
|
+
struck?: boolean;
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
function todoPaint(status: TodoItem["status"], glyphs: IconGlyphs, spin: string): TodoPaint {
|
|
303
|
+
if (status === "completed") {
|
|
304
|
+
return { glyph: glyphs.check, glyphColor: "success", subjectColor: "dim", struck: true };
|
|
305
|
+
}
|
|
306
|
+
if (status === "in_progress") {
|
|
307
|
+
return { glyph: spin, glyphColor: "warning", subjectColor: "text", bold: true };
|
|
308
|
+
}
|
|
309
|
+
if (status === "blocked") {
|
|
310
|
+
return { glyph: glyphs.blocked, glyphColor: "error", subjectColor: "error" };
|
|
311
|
+
}
|
|
312
|
+
return { glyph: glyphs.pending, glyphColor: "accent", subjectColor: "text" };
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
function todoSubject(paint: TodoPaint, subject: string, theme: PaintTheme): string {
|
|
316
|
+
const text = theme.fg(paint.subjectColor, subject);
|
|
317
|
+
if (paint.struck) return struck(theme, text);
|
|
318
|
+
return paint.bold ? bold(theme, text) : text;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
function todoNextLabel(
|
|
322
|
+
item: TodoItem,
|
|
323
|
+
items: readonly TodoItem[],
|
|
324
|
+
glyphs: IconGlyphs,
|
|
325
|
+
spin: string,
|
|
326
|
+
theme: PaintTheme,
|
|
327
|
+
): string {
|
|
328
|
+
const actor = todoActor(item, items);
|
|
329
|
+
const paint = todoPaint(item.status, glyphs, spin);
|
|
330
|
+
return [
|
|
331
|
+
theme.fg(paint.glyphColor, paint.glyph),
|
|
332
|
+
actor ? theme.fg("mdLink", actor) : "",
|
|
333
|
+
todoSubject(paint, item.subject, theme),
|
|
334
|
+
].filter(Boolean).join(" ");
|
|
335
|
+
}
|
|
336
|
+
|
|
337
|
+
export function renderTodos(
|
|
338
|
+
items: readonly TodoItem[],
|
|
339
|
+
mode: ViewMode,
|
|
340
|
+
width: number,
|
|
341
|
+
theme: PaintTheme,
|
|
342
|
+
utils: WidthUtils,
|
|
343
|
+
opts: RenderOpts,
|
|
344
|
+
): string[] {
|
|
345
|
+
if (items.length === 0) return [];
|
|
346
|
+
const g = opts.glyphs;
|
|
347
|
+
const ell = theme.fg("dim", g.ellipsis);
|
|
348
|
+
const spin = opts.spin ?? "~";
|
|
349
|
+
const now = opts.now ?? Date.now();
|
|
350
|
+
const expanded = opts.expanded !== false;
|
|
351
|
+
const toggleHint = opts.toggleHint ?? DEFAULT_TOGGLE_HINT;
|
|
352
|
+
|
|
353
|
+
if (mode === "compact") {
|
|
354
|
+
const total = items.length;
|
|
355
|
+
const done = items.filter((i) => i.status === "completed").length;
|
|
356
|
+
const blocked = items.filter((i) => i.status === "blocked").length;
|
|
357
|
+
const pct = total ? Math.round((done / total) * 100) : 0;
|
|
358
|
+
const nxt = findNextTodo(items);
|
|
359
|
+
const label = nxt
|
|
360
|
+
? `${theme.fg("dim", g.arrow)} ${todoNextLabel(nxt, items, g, spin, theme)}`
|
|
361
|
+
: done === total ? theme.fg("success", `${g.check} all done`) : "";
|
|
362
|
+
const segs: PrioritizedSegment[] = [
|
|
363
|
+
{ text: renderTodoBar(items, width, theme, g), priority: 60, clippable: false },
|
|
364
|
+
{ text: theme.fg("muted", `${pct}%`), priority: 90, clippable: false },
|
|
365
|
+
];
|
|
366
|
+
// A glyph run is opaque to a screen reader, so the blocked count is spelled out.
|
|
367
|
+
if (blocked > 0) segs.push({ text: theme.fg("error", `${blocked} blocked`), priority: 95, clippable: false });
|
|
368
|
+
if (label) segs.push({ text: label, priority: 100, minWidth: 8 });
|
|
369
|
+
segs.push({ text: theme.fg("dim", `(${toggleHint} expand)`), priority: 10, clippable: false });
|
|
370
|
+
return [fitLineByPriority(segs, width, utils, " ", g.ellipsis)];
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// summary line (always rendered, width-adaptive: full → compact → minimal)
|
|
374
|
+
const total = items.length;
|
|
375
|
+
const done = items.filter((i) => i.status === "completed").length;
|
|
376
|
+
const running = items.filter((i) => i.status === "in_progress").length;
|
|
377
|
+
const blocked = items.filter((i) => i.status === "blocked").length;
|
|
378
|
+
const members = new Set(items.map((item) => item.assignee?.id).filter(Boolean)).size;
|
|
379
|
+
const sep = theme.fg("dim", g.separator.trim());
|
|
380
|
+
const nxt = findNextTodo(items);
|
|
381
|
+
const nextText = nxt
|
|
382
|
+
? `${theme.fg(nxt.status === "blocked" ? "error" : "dim", g.arrow)} ${todoNextLabel(nxt, items, g, spin, theme)}`
|
|
383
|
+
: theme.fg("success", `${g.check} all done`);
|
|
384
|
+
|
|
385
|
+
// The next actionable task outranks the counts: it is the one token that tells
|
|
386
|
+
// the user what to do, so it must survive when the terminal is narrow.
|
|
387
|
+
const summarySegs: PrioritizedSegment[] = [
|
|
388
|
+
{ text: theme.fg("syntaxFunction", "Todo"), priority: 40, clippable: false },
|
|
389
|
+
{ text: theme.fg("dim", `${done}/${total}`), priority: 80, clippable: false },
|
|
390
|
+
{ text: nextText, priority: 100, minWidth: 8 },
|
|
391
|
+
];
|
|
392
|
+
if (blocked > 0) {
|
|
393
|
+
summarySegs.push({ text: theme.fg("error", `${blocked} blocked`), priority: 90, clippable: false });
|
|
394
|
+
}
|
|
395
|
+
if (running > 0) {
|
|
396
|
+
summarySegs.push({ text: theme.fg("dim", `${running} running`), priority: 50, clippable: false });
|
|
397
|
+
}
|
|
398
|
+
if (members > 0) {
|
|
399
|
+
summarySegs.push({ text: theme.fg("dim", `${members} members`), priority: 30, clippable: false });
|
|
400
|
+
}
|
|
401
|
+
summarySegs.push({
|
|
402
|
+
text: theme.fg("dim", `(${toggleHint} ${expanded ? "collapse" : "expand"})`),
|
|
403
|
+
priority: 10,
|
|
404
|
+
clippable: false,
|
|
405
|
+
});
|
|
406
|
+
const summaryLine = fitLineByPriority(summarySegs, width, utils, ` ${sep} `, g.ellipsis);
|
|
407
|
+
|
|
408
|
+
if (!expanded) return [summaryLine];
|
|
409
|
+
|
|
410
|
+
// sorted + capped task rows
|
|
411
|
+
const ordered = [...items].sort((a, b) => todoDisplayRank(a, now) - todoDisplayRank(b, now));
|
|
412
|
+
// The summary line is already spent, so the task rows compete for what is left.
|
|
413
|
+
const budget = Math.min(
|
|
414
|
+
TODO_MAX_VISIBLE + 1,
|
|
415
|
+
opts.maxRows !== undefined ? opts.maxRows - 1 : Number.POSITIVE_INFINITY,
|
|
416
|
+
);
|
|
417
|
+
const { visible: visibleCount, hidden } = fitRows(ordered.length, budget);
|
|
418
|
+
const visible = ordered.slice(0, visibleCount);
|
|
419
|
+
const rows: string[] = [summaryLine];
|
|
420
|
+
for (const it of visible) {
|
|
421
|
+
const paint = todoPaint(it.status, g, spin);
|
|
422
|
+
const actor = todoActor(it, items);
|
|
423
|
+
const segments = [` ${theme.fg(paint.glyphColor, paint.glyph)}`];
|
|
424
|
+
if (actor) segments.push(theme.fg("mdLink", actor));
|
|
425
|
+
segments.push(todoSubject(paint, it.subject, theme));
|
|
426
|
+
if (it.skills.length > 0) {
|
|
427
|
+
const primary = it.skills.find((skill) => skill.role === "primary") ?? it.skills[0];
|
|
428
|
+
segments.push(theme.fg("dim", `/${primary.name}${it.skills.length > 1 ? ` +${it.skills.length - 1}` : ""}`));
|
|
429
|
+
}
|
|
430
|
+
if (it.status === "blocked" && it.blockedBy.length > 0) {
|
|
431
|
+
for (const dependencyId of it.blockedBy) {
|
|
432
|
+
const dependency = items.find((candidate) => candidate.id === dependencyId);
|
|
433
|
+
const depGlyph = dependency ? todoPaint(dependency.status, g, spin).glyph : "?";
|
|
434
|
+
segments.push(theme.fg("dim", `← ${depGlyph} ${dependency?.subject ?? "?"}`));
|
|
435
|
+
}
|
|
436
|
+
}
|
|
437
|
+
rows.push(utils.clip(segments.join(" "), width, ell));
|
|
438
|
+
}
|
|
439
|
+
if (hidden > 0) rows.push(utils.clip(theme.fg("dim", ` ${g.ellipsis} ${hidden} more`), width, ell));
|
|
440
|
+
return rows;
|
|
441
|
+
}
|