@crouton-kit/crouter 0.3.15 → 0.3.17
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/dist/builtin-personas/developer/orchestrator.md +1 -1
- package/dist/builtin-personas/orchestration-kernel.md +6 -6
- package/dist/builtin-personas/plan/base.md +1 -1
- package/dist/builtin-personas/plan/orchestrator.md +1 -1
- package/dist/builtin-personas/spec/base.md +1 -1
- package/dist/commands/canvas-browse.d.ts +2 -0
- package/dist/commands/canvas-browse.js +45 -0
- package/dist/commands/canvas-prune.js +11 -2
- package/dist/commands/canvas.js +3 -2
- package/dist/commands/chord.js +1 -1
- package/dist/commands/human/shared.js +1 -1
- package/dist/commands/node.js +14 -2
- package/dist/commands/skill/author.js +2 -2
- package/dist/commands/tmux-spread.js +2 -3
- package/dist/core/__tests__/cascade-close.test.js +199 -0
- package/dist/core/__tests__/close.test.js +2 -2
- package/dist/core/__tests__/daemon-boot.test.js +7 -0
- package/dist/core/__tests__/daemon-liveness.test.js +59 -4
- package/dist/core/__tests__/dead-pane-regression.test.js +151 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.d.ts +2 -0
- package/dist/core/__tests__/fixtures/fake-pi-host.js +301 -0
- package/dist/core/__tests__/flagship-lifecycle.test.js +273 -0
- package/dist/core/__tests__/focuses.test.js +5 -68
- package/dist/core/__tests__/grace-clock.test.d.ts +1 -0
- package/dist/core/__tests__/grace-clock.test.js +115 -0
- package/dist/core/__tests__/helpers/harness.d.ts +78 -0
- package/dist/core/__tests__/helpers/harness.js +406 -0
- package/dist/core/__tests__/home-session.test.js +1 -1
- package/dist/core/__tests__/lifecycle.test.js +6 -13
- package/dist/core/__tests__/live-mutation.test.d.ts +1 -0
- package/dist/core/__tests__/live-mutation.test.js +341 -0
- package/dist/core/__tests__/placement-focus.test.js +106 -46
- package/dist/core/__tests__/placement-teardown.test.js +4 -9
- package/dist/core/__tests__/relaunch.test.js +22 -16
- package/dist/core/__tests__/reset.test.js +11 -6
- package/dist/core/__tests__/spike-harness.test.d.ts +1 -0
- package/dist/core/__tests__/spike-harness.test.js +241 -0
- package/dist/core/__tests__/subscription-delivery.test.d.ts +1 -0
- package/dist/core/__tests__/subscription-delivery.test.js +233 -0
- package/dist/core/__tests__/tmux-surface.test.js +8 -9
- package/dist/core/canvas/browse/__tests__/model.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +142 -0
- package/dist/core/canvas/browse/__tests__/render.test.d.ts +1 -0
- package/dist/core/canvas/browse/__tests__/render.test.js +102 -0
- package/dist/core/canvas/browse/app.d.ts +4 -0
- package/dist/core/canvas/browse/app.js +349 -0
- package/dist/core/canvas/browse/model.d.ts +97 -0
- package/dist/core/canvas/browse/model.js +258 -0
- package/dist/core/canvas/browse/render.d.ts +41 -0
- package/dist/core/canvas/browse/render.js +387 -0
- package/dist/core/canvas/browse/terminal.d.ts +23 -0
- package/dist/core/canvas/browse/terminal.js +100 -0
- package/dist/core/canvas/canvas.d.ts +9 -2
- package/dist/core/canvas/canvas.js +41 -3
- package/dist/core/canvas/db.js +2 -3
- package/dist/core/canvas/focuses.d.ts +2 -2
- package/dist/core/canvas/focuses.js +4 -3
- package/dist/core/canvas/render.d.ts +10 -0
- package/dist/core/canvas/render.js +25 -1
- package/dist/core/canvas/types.d.ts +1 -1
- package/dist/core/feed/inbox.d.ts +0 -3
- package/dist/core/feed/inbox.js +1 -5
- package/dist/core/runtime/busy.d.ts +8 -0
- package/dist/core/runtime/busy.js +46 -0
- package/dist/core/runtime/close.js +2 -2
- package/dist/core/runtime/demote.js +2 -7
- package/dist/core/runtime/launch.d.ts +3 -1
- package/dist/core/runtime/launch.js +4 -1
- package/dist/core/runtime/lifecycle.d.ts +1 -1
- package/dist/core/runtime/lifecycle.js +12 -4
- package/dist/core/runtime/naming.d.ts +3 -3
- package/dist/core/runtime/naming.js +6 -6
- package/dist/core/runtime/nodes.d.ts +7 -0
- package/dist/core/runtime/nodes.js +10 -1
- package/dist/core/runtime/placement.d.ts +39 -10
- package/dist/core/runtime/placement.js +100 -44
- package/dist/core/runtime/reset.d.ts +11 -8
- package/dist/core/runtime/reset.js +36 -31
- package/dist/core/runtime/revive.d.ts +1 -1
- package/dist/core/runtime/revive.js +2 -2
- package/dist/core/runtime/spawn.js +3 -3
- package/dist/core/runtime/tmux-chrome.d.ts +1 -0
- package/dist/core/runtime/tmux-chrome.js +4 -0
- package/dist/core/runtime/tmux.d.ts +13 -6
- package/dist/core/runtime/tmux.js +21 -12
- package/dist/daemon/crtrd.js +43 -21
- package/dist/pi-extensions/canvas-nav.js +40 -28
- package/dist/pi-extensions/canvas-resume.d.ts +21 -0
- package/dist/pi-extensions/canvas-resume.js +82 -0
- package/dist/pi-extensions/canvas-stophook.d.ts +1 -1
- package/dist/pi-extensions/canvas-stophook.js +21 -9
- package/dist/prompts/skill.js +6 -1
- package/package.json +2 -2
- package/dist/commands/__tests__/skill.test.js +0 -290
- package/dist/core/__tests__/pkg.test.js +0 -218
- package/dist/core/__tests__/sys.test.js +0 -208
- package/dist/core/runtime/presence.d.ts +0 -30
- package/dist/core/runtime/presence.js +0 -178
- /package/dist/{commands/__tests__/skill.test.d.ts → core/__tests__/cascade-close.test.d.ts} +0 -0
- /package/dist/core/__tests__/{pkg.test.d.ts → dead-pane-regression.test.d.ts} +0 -0
- /package/dist/core/__tests__/{sys.test.d.ts → flagship-lifecycle.test.d.ts} +0 -0
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// model.ts — pure, TTY-free logic for the canvas browser.
|
|
2
|
+
//
|
|
3
|
+
// Everything here is a pure function of its inputs (no db, no stdin, no ANSI) so
|
|
4
|
+
// it is exhaustively unit-testable. The app layer (app.ts) wires the canvas data
|
|
5
|
+
// access (dashboardRowsAll / listNodes / subscriptionsOf) into buildTree, then
|
|
6
|
+
// drives flatten() on each keystroke.
|
|
7
|
+
export const TABS = ['All', 'Live', 'Dormant', 'Flagged'];
|
|
8
|
+
export const SORTS = ['tree', 'relevance', 'recency'];
|
|
9
|
+
/** Does a node belong to this tab's slice?
|
|
10
|
+
* All — every node.
|
|
11
|
+
* Live — active | idle.
|
|
12
|
+
* Dormant — done | dead | canceled.
|
|
13
|
+
* Flagged — has > 0 pending human asks. */
|
|
14
|
+
export function tabPredicate(tab, row) {
|
|
15
|
+
switch (tab) {
|
|
16
|
+
case 'All': return true;
|
|
17
|
+
case 'Live': return row.status === 'active' || row.status === 'idle';
|
|
18
|
+
case 'Dormant': return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
|
|
19
|
+
case 'Flagged': return row.asks > 0;
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
/** Sort rank for roots/stragglers — live first (active, then idle), dormant
|
|
23
|
+
* after. Mirrors render.ts / canvas-resume.ts statusRank. */
|
|
24
|
+
export function statusRank(status) {
|
|
25
|
+
switch (status) {
|
|
26
|
+
case 'active': return 0;
|
|
27
|
+
case 'idle': return 1;
|
|
28
|
+
case 'done': return 2;
|
|
29
|
+
case 'canceled': return 3;
|
|
30
|
+
case 'dead': return 4;
|
|
31
|
+
default: return 5;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Build a spanning tree of the whole canvas.
|
|
36
|
+
* - `rows` — one DashboardRow per node (display text + status/asks).
|
|
37
|
+
* - `rootIds` — node ids whose `parent === null` (raw, unsorted).
|
|
38
|
+
* - `childIdsOf` — a node's children = the nodes it subscribes to (its
|
|
39
|
+
* reports), in edge order. (= subscriptionsOf(id) node ids.)
|
|
40
|
+
*
|
|
41
|
+
* Roots are sorted live-first. The graph is walked DFS-preorder; the FIRST
|
|
42
|
+
* parent to reach a node owns it (cycle-/multi-parent-safe via `visited`). Any
|
|
43
|
+
* node never reached from a root (orphaned by a missing subscription edge) is
|
|
44
|
+
* appended as a depth-0 straggler so "All" is genuinely the whole canvas.
|
|
45
|
+
*/
|
|
46
|
+
export function buildTree(rows, rootIds, childIdsOf) {
|
|
47
|
+
const rowMap = new Map(rows.map((r) => [r.node_id, r]));
|
|
48
|
+
const nodes = new Map();
|
|
49
|
+
const orderedRoots = [];
|
|
50
|
+
const visited = new Set();
|
|
51
|
+
const rankOf = (id) => {
|
|
52
|
+
const r = rowMap.get(id);
|
|
53
|
+
return r !== undefined ? statusRank(r.status) : 99;
|
|
54
|
+
};
|
|
55
|
+
const walk = (id, depth, parentId) => {
|
|
56
|
+
if (visited.has(id))
|
|
57
|
+
return; // cycle, or already claimed by an earlier parent
|
|
58
|
+
const row = rowMap.get(id);
|
|
59
|
+
if (row === undefined)
|
|
60
|
+
return; // id in the graph but no row (missing meta)
|
|
61
|
+
visited.add(id);
|
|
62
|
+
const childIds = childIdsOf(id).filter((c) => rowMap.has(c) && !visited.has(c));
|
|
63
|
+
nodes.set(id, { row, depth, parentId, childIds });
|
|
64
|
+
for (const c of childIds)
|
|
65
|
+
walk(c, depth + 1, id);
|
|
66
|
+
};
|
|
67
|
+
const sortedRoots = [...rootIds].filter((id) => rowMap.has(id)).sort((a, b) => rankOf(a) - rankOf(b));
|
|
68
|
+
for (const r of sortedRoots) {
|
|
69
|
+
if (visited.has(r))
|
|
70
|
+
continue;
|
|
71
|
+
orderedRoots.push(r);
|
|
72
|
+
walk(r, 0, null);
|
|
73
|
+
}
|
|
74
|
+
// Stragglers: any row never reached from a declared root. Attach live-first as
|
|
75
|
+
// depth-0 pseudo-roots so the whole canvas stays reachable.
|
|
76
|
+
const stragglers = rows
|
|
77
|
+
.map((r) => r.node_id)
|
|
78
|
+
.filter((id) => !visited.has(id))
|
|
79
|
+
.sort((a, b) => rankOf(a) - rankOf(b));
|
|
80
|
+
for (const s of stragglers) {
|
|
81
|
+
if (visited.has(s))
|
|
82
|
+
continue;
|
|
83
|
+
orderedRoots.push(s);
|
|
84
|
+
walk(s, 0, null);
|
|
85
|
+
}
|
|
86
|
+
return { roots: orderedRoots, nodes };
|
|
87
|
+
}
|
|
88
|
+
// ---------------------------------------------------------------------------
|
|
89
|
+
// Fuzzy match
|
|
90
|
+
// ---------------------------------------------------------------------------
|
|
91
|
+
/** Case-insensitive subsequence match: every char of `query` appears in `text`
|
|
92
|
+
* in order (gaps allowed). Empty query matches everything. Substrings are a
|
|
93
|
+
* subsequence, so this subsumes substring matching too. */
|
|
94
|
+
export function fuzzyMatch(query, text) {
|
|
95
|
+
if (query === '')
|
|
96
|
+
return true;
|
|
97
|
+
const q = query.toLowerCase();
|
|
98
|
+
const t = text.toLowerCase();
|
|
99
|
+
let qi = 0;
|
|
100
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
101
|
+
if (t[ti] === q[qi])
|
|
102
|
+
qi++;
|
|
103
|
+
}
|
|
104
|
+
return qi === q.length;
|
|
105
|
+
}
|
|
106
|
+
/** Indices in `text` consumed by a greedy left-to-right subsequence match of
|
|
107
|
+
* `query` — the same walk as `fuzzyMatch`, but returning WHICH chars matched so
|
|
108
|
+
* the renderer can highlight them. Empty set when `query` is empty OR does not
|
|
109
|
+
* fully match (no partial highlights). */
|
|
110
|
+
export function matchIndices(query, text) {
|
|
111
|
+
const out = new Set();
|
|
112
|
+
if (query === '')
|
|
113
|
+
return out;
|
|
114
|
+
const q = query.toLowerCase();
|
|
115
|
+
const t = text.toLowerCase();
|
|
116
|
+
let qi = 0;
|
|
117
|
+
for (let ti = 0; ti < t.length && qi < q.length; ti++) {
|
|
118
|
+
if (t[ti] === q[qi]) {
|
|
119
|
+
out.add(ti);
|
|
120
|
+
qi++;
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
if (qi < q.length)
|
|
124
|
+
out.clear(); // no full match → no highlight
|
|
125
|
+
return out;
|
|
126
|
+
}
|
|
127
|
+
function shortId(id) {
|
|
128
|
+
return id.slice(0, 8);
|
|
129
|
+
}
|
|
130
|
+
/** Does this row match the live query? Super-search spans name (which already
|
|
131
|
+
* folds in the pi-generated description), kind, short-id, AND the spawn prompt
|
|
132
|
+
* (`row.goal`). Empty query matches everything. */
|
|
133
|
+
export function queryMatch(query, row) {
|
|
134
|
+
if (query === '')
|
|
135
|
+
return true;
|
|
136
|
+
return (fuzzyMatch(query, row.name) ||
|
|
137
|
+
fuzzyMatch(query, row.kind) ||
|
|
138
|
+
fuzzyMatch(query, shortId(row.node_id)) ||
|
|
139
|
+
(row.goal !== undefined && fuzzyMatch(query, row.goal)));
|
|
140
|
+
}
|
|
141
|
+
// ---------------------------------------------------------------------------
|
|
142
|
+
// Relevance scoring (super-search)
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
/** Score how well `query` matches one field, 0 (no match) → 1 (exact). Tiers:
|
|
145
|
+
* exact > prefix > word-boundary substring > interior substring > subsequence.
|
|
146
|
+
* An interior match decays slightly the later it starts so leading matches win. */
|
|
147
|
+
export function fieldScore(query, text) {
|
|
148
|
+
if (query === '' || text === '')
|
|
149
|
+
return 0;
|
|
150
|
+
const q = query.toLowerCase();
|
|
151
|
+
const t = text.toLowerCase();
|
|
152
|
+
const idx = t.indexOf(q);
|
|
153
|
+
if (idx === 0)
|
|
154
|
+
return t.length === q.length ? 1 : 0.85; // exact | prefix
|
|
155
|
+
if (idx > 0) {
|
|
156
|
+
const prev = t[idx - 1] ?? '';
|
|
157
|
+
const boundary = /[\s_\-/.:]/.test(prev) ? 0.1 : 0; // word-boundary bonus
|
|
158
|
+
return 0.55 + boundary - Math.min(0.2, idx / 400); // interior, decays late
|
|
159
|
+
}
|
|
160
|
+
return fuzzyMatch(q, t) ? 0.2 : 0; // scattered subsequence
|
|
161
|
+
}
|
|
162
|
+
/** Per-field weights — name (handle + description) dominates, the spawn prompt
|
|
163
|
+
* is the long-tail super-search field. */
|
|
164
|
+
const FIELD_WEIGHTS = { name: 4, kind: 2, id: 1, goal: 1.5 };
|
|
165
|
+
/** Weighted relevance of a row to the query across all searched fields. 0 means
|
|
166
|
+
* no field matched (excluded from relevance results, same as `queryMatch`). */
|
|
167
|
+
export function scoreRow(query, row) {
|
|
168
|
+
if (query === '')
|
|
169
|
+
return 0;
|
|
170
|
+
return (FIELD_WEIGHTS.name * fieldScore(query, row.name) +
|
|
171
|
+
FIELD_WEIGHTS.kind * fieldScore(query, row.kind) +
|
|
172
|
+
FIELD_WEIGHTS.id * fieldScore(query, shortId(row.node_id)) +
|
|
173
|
+
FIELD_WEIGHTS.goal * (row.goal !== undefined ? fieldScore(query, row.goal) : 0));
|
|
174
|
+
}
|
|
175
|
+
/** Is this row inside the active cwd scope? No scope (null/undefined) = All dirs. */
|
|
176
|
+
export function cwdMatch(scope, row) {
|
|
177
|
+
return scope === null || scope === undefined || row.cwd === scope;
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Flatten the tree to the ordered list of currently-visible rows.
|
|
181
|
+
*
|
|
182
|
+
* Inclusion: a node is shown when it directly matches (tab predicate AND query)
|
|
183
|
+
* — flagged `matched:true` — OR it is an ANCESTOR of a directly-matched node
|
|
184
|
+
* (shown for tree context, `matched:false`, dimmed by the renderer).
|
|
185
|
+
*
|
|
186
|
+
* Collapse: children are emitted only under an EXPANDED node. A node is expanded
|
|
187
|
+
* when it is not in `collapsed` — except under a non-empty query, where every
|
|
188
|
+
* ancestor-of-a-match is force-expanded regardless of `collapsed` so matches are
|
|
189
|
+
* always reachable.
|
|
190
|
+
*/
|
|
191
|
+
export function flatten(tree, opts) {
|
|
192
|
+
const { collapsed, tab, query, cwdScope, sort = 'tree' } = opts;
|
|
193
|
+
// 1. Directly-matched nodes: tab predicate AND cwd scope AND query.
|
|
194
|
+
const matched = new Set();
|
|
195
|
+
for (const [id, node] of tree.nodes) {
|
|
196
|
+
if (tabPredicate(tab, node.row) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
|
|
197
|
+
matched.add(id);
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
// FLAT ranked modes (relevance / recency): no tree, no ancestors — just the
|
|
201
|
+
// directly-matched rows in ranked order. Relevance falls back to recency when
|
|
202
|
+
// the query is empty (every score would be 0).
|
|
203
|
+
if (sort !== 'tree') {
|
|
204
|
+
const ids = [...matched];
|
|
205
|
+
const createdOf = (id) => tree.nodes.get(id)?.row.created ?? '';
|
|
206
|
+
const byRecency = (a, b) => createdOf(b).localeCompare(createdOf(a));
|
|
207
|
+
if (sort === 'recency' || query === '') {
|
|
208
|
+
ids.sort(byRecency);
|
|
209
|
+
}
|
|
210
|
+
else {
|
|
211
|
+
const score = new Map();
|
|
212
|
+
for (const id of ids)
|
|
213
|
+
score.set(id, scoreRow(query, tree.nodes.get(id).row));
|
|
214
|
+
ids.sort((a, b) => (score.get(b) - score.get(a)) || byRecency(a, b));
|
|
215
|
+
}
|
|
216
|
+
return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
|
|
217
|
+
}
|
|
218
|
+
// 2. Ancestors of matches (for tree context + force-expand under query).
|
|
219
|
+
const ancestors = new Set();
|
|
220
|
+
for (const id of matched) {
|
|
221
|
+
let p = tree.nodes.get(id)?.parentId ?? null;
|
|
222
|
+
while (p !== null && !ancestors.has(p)) {
|
|
223
|
+
ancestors.add(p);
|
|
224
|
+
p = tree.nodes.get(p)?.parentId ?? null;
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
const included = (id) => matched.has(id) || ancestors.has(id);
|
|
228
|
+
const isExpanded = (id) => {
|
|
229
|
+
if (query !== '' && ancestors.has(id))
|
|
230
|
+
return true; // force-expand to reveal matches
|
|
231
|
+
return !collapsed.has(id);
|
|
232
|
+
};
|
|
233
|
+
// 3. Walk the tree in order, emitting included nodes and descending only into
|
|
234
|
+
// expanded ones.
|
|
235
|
+
const out = [];
|
|
236
|
+
const walk = (id) => {
|
|
237
|
+
const node = tree.nodes.get(id);
|
|
238
|
+
if (node === undefined)
|
|
239
|
+
return;
|
|
240
|
+
if (included(id)) {
|
|
241
|
+
const hasChildren = node.childIds.length > 0;
|
|
242
|
+
out.push({
|
|
243
|
+
id,
|
|
244
|
+
depth: node.depth,
|
|
245
|
+
hasChildren,
|
|
246
|
+
collapsed: hasChildren && !isExpanded(id),
|
|
247
|
+
matched: matched.has(id),
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
if (isExpanded(id)) {
|
|
251
|
+
for (const c of node.childIds)
|
|
252
|
+
walk(c);
|
|
253
|
+
}
|
|
254
|
+
};
|
|
255
|
+
for (const r of tree.roots)
|
|
256
|
+
walk(r);
|
|
257
|
+
return out;
|
|
258
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import type { Tab, Tree, VisibleRow, SortMode } from './model.js';
|
|
2
|
+
export declare const PREVIEW_BODY = 5;
|
|
3
|
+
export declare const PREVIEW_HEIGHT: number;
|
|
4
|
+
export declare function headerHeight(search: boolean): number;
|
|
5
|
+
export interface ColorCaps {
|
|
6
|
+
/** Any hue (fg/bg color) allowed. */
|
|
7
|
+
color: boolean;
|
|
8
|
+
/** 256-color bg allowed — drives the subtle cursor-row background. */
|
|
9
|
+
color256: boolean;
|
|
10
|
+
}
|
|
11
|
+
/** Detect color capability. Honors `NO_COLOR` and `TERM=dumb`, and only emits
|
|
12
|
+
* hue when stdout is a TTY. `color256` additionally requires a 256/truecolor
|
|
13
|
+
* terminal (for the cursor-row background; otherwise we fall back to reverse). */
|
|
14
|
+
export declare function detectColorCaps(stream?: {
|
|
15
|
+
isTTY?: boolean;
|
|
16
|
+
}, env?: NodeJS.ProcessEnv): ColorCaps;
|
|
17
|
+
export interface RenderState {
|
|
18
|
+
tree: Tree;
|
|
19
|
+
visible: VisibleRow[];
|
|
20
|
+
tab: Tab;
|
|
21
|
+
cursor: number;
|
|
22
|
+
scrollOffset: number;
|
|
23
|
+
query: string;
|
|
24
|
+
search: boolean;
|
|
25
|
+
totalNodes: number;
|
|
26
|
+
/** Active cwd-scope filter; null = All dirs. Shown on the status line. */
|
|
27
|
+
cwdScope: string | null;
|
|
28
|
+
/** Active ordering — status line + (flat modes) row presentation. */
|
|
29
|
+
sort: SortMode;
|
|
30
|
+
/** Whether the bottom preview panel is drawn. */
|
|
31
|
+
preview: boolean;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Render the whole frame. Returns a single string that, written as-is, repaints
|
|
35
|
+
* the screen in place. `caps` gates all hue (defaults to a no-color frame so
|
|
36
|
+
* existing callers / non-TTY paths stay color-free).
|
|
37
|
+
*/
|
|
38
|
+
export declare function renderFrame(state: RenderState, size: {
|
|
39
|
+
cols: number;
|
|
40
|
+
rows: number;
|
|
41
|
+
}, caps?: ColorCaps): string;
|
|
@@ -0,0 +1,387 @@
|
|
|
1
|
+
// render.ts — pure frame rendering for the canvas browser.
|
|
2
|
+
//
|
|
3
|
+
// renderFrame(state, size, caps) → a full-screen string. The app writes it
|
|
4
|
+
// verbatim on every keystroke. Redraw is flicker-free: home the cursor (\x1b[H),
|
|
5
|
+
// clear each line to EOL (\x1b[K), and clear below the last line (\x1b[J) so a
|
|
6
|
+
// shrunk frame leaves no stale rows. A full frame per keypress is fine for a
|
|
7
|
+
// picker.
|
|
8
|
+
//
|
|
9
|
+
// COLOR is browse-only and *reinforces* the status glyphs (`● ○ ✓ ✗ ⊘`) which
|
|
10
|
+
// stay the primary, color-free encoding (colorblind / light-bg / NO_COLOR safe).
|
|
11
|
+
// Every hue (fg / bg color) is gated on `caps.color`; structural SGR (bold/dim/
|
|
12
|
+
// reverse) is allowed always. See detectColorCaps() for the gate, and the
|
|
13
|
+
// canvas-browse color spec for the rationale + palette.
|
|
14
|
+
import { TABS, matchIndices } from './model.js';
|
|
15
|
+
// Fixed chrome heights, shared with app.ts so its viewport math never drifts
|
|
16
|
+
// from what renderFrame actually draws.
|
|
17
|
+
// header = title + tab bar + status line + separator (+ search input when searching)
|
|
18
|
+
// preview = separator + meta line + PREVIEW_BODY prompt lines
|
|
19
|
+
export const PREVIEW_BODY = 5;
|
|
20
|
+
export const PREVIEW_HEIGHT = PREVIEW_BODY + 2;
|
|
21
|
+
export function headerHeight(search) {
|
|
22
|
+
return 4 + (search ? 1 : 0);
|
|
23
|
+
}
|
|
24
|
+
// ── ANSI ────────────────────────────────────────────────────────────────────
|
|
25
|
+
const ESC = '\x1b[';
|
|
26
|
+
const RESET = `${ESC}0m`;
|
|
27
|
+
const REVERSE = `${ESC}7m`;
|
|
28
|
+
const DIM = `${ESC}2m`;
|
|
29
|
+
const BOLD = `${ESC}1m`;
|
|
30
|
+
const CURSOR_BG = `${ESC}48;5;236m`; // subtle dark-gray cursor-row bg (256-color)
|
|
31
|
+
// Basic-16 ANSI fg codes used by the palette.
|
|
32
|
+
const FG_GREEN = '32';
|
|
33
|
+
const FG_YELLOW = '33';
|
|
34
|
+
const FG_RED = '31';
|
|
35
|
+
const FG_CYAN = '36';
|
|
36
|
+
const FG_GRAY = '90'; // bright-black
|
|
37
|
+
const FG_BRIGHT_YELLOW = '93';
|
|
38
|
+
const FG_BRIGHT_CYAN = '96'; // query-match highlight (ties to the cyan search accent)
|
|
39
|
+
const STATUS_GLYPH = {
|
|
40
|
+
active: '●',
|
|
41
|
+
idle: '○',
|
|
42
|
+
done: '✓',
|
|
43
|
+
dead: '✗',
|
|
44
|
+
canceled: '⊘',
|
|
45
|
+
};
|
|
46
|
+
/** The load-bearing color: glyph hue per status. Single source of truth, mirrors
|
|
47
|
+
* STATUS_GLYPH. Reinforces the glyph everywhere it appears (rows + summary). */
|
|
48
|
+
const STATUS_COLOR = {
|
|
49
|
+
active: FG_GREEN,
|
|
50
|
+
idle: FG_YELLOW,
|
|
51
|
+
done: FG_CYAN,
|
|
52
|
+
dead: FG_RED,
|
|
53
|
+
canceled: FG_GRAY,
|
|
54
|
+
};
|
|
55
|
+
/** Detect color capability. Honors `NO_COLOR` and `TERM=dumb`, and only emits
|
|
56
|
+
* hue when stdout is a TTY. `color256` additionally requires a 256/truecolor
|
|
57
|
+
* terminal (for the cursor-row background; otherwise we fall back to reverse). */
|
|
58
|
+
export function detectColorCaps(stream = process.stdout, env = process.env) {
|
|
59
|
+
const term = env['TERM'] ?? '';
|
|
60
|
+
const color = stream.isTTY === true && !env['NO_COLOR'] && term !== 'dumb';
|
|
61
|
+
const colorTerm = env['COLORTERM'] ?? '';
|
|
62
|
+
const color256 = color && (/256|direct/i.test(term) || /truecolor|24bit/i.test(colorTerm));
|
|
63
|
+
return { color, color256 };
|
|
64
|
+
}
|
|
65
|
+
function fmtCtx(tokens) {
|
|
66
|
+
if (tokens <= 0)
|
|
67
|
+
return '0k';
|
|
68
|
+
return `${Math.floor(tokens / 1000)}k`;
|
|
69
|
+
}
|
|
70
|
+
/** Tiered ctx-budget hue: dim under 50k, yellow 50–100k, red ≥100k. `undefined`
|
|
71
|
+
* → dim (structural), so low budgets recede. */
|
|
72
|
+
function ctxColorCode(tokens) {
|
|
73
|
+
if (tokens >= 100_000)
|
|
74
|
+
return FG_RED;
|
|
75
|
+
if (tokens >= 50_000)
|
|
76
|
+
return FG_YELLOW;
|
|
77
|
+
return undefined;
|
|
78
|
+
}
|
|
79
|
+
/** Truncate to `max` visible cols (plain text, no ANSI). */
|
|
80
|
+
function clip(text, max) {
|
|
81
|
+
if (max <= 0)
|
|
82
|
+
return '';
|
|
83
|
+
return text.length <= max ? text : text.slice(0, Math.max(0, max - 1)) + '…';
|
|
84
|
+
}
|
|
85
|
+
/** Compact relative age, e.g. `45s` `12m` `3h` `5d` `2w` `4mo`. Empty on a bad
|
|
86
|
+
* timestamp. Drives the per-row recency cue + the preview meta line. */
|
|
87
|
+
function relAge(created, now) {
|
|
88
|
+
const t = Date.parse(created);
|
|
89
|
+
if (Number.isNaN(t))
|
|
90
|
+
return '';
|
|
91
|
+
const s = Math.max(0, Math.floor((now - t) / 1000));
|
|
92
|
+
if (s < 60)
|
|
93
|
+
return `${s}s`;
|
|
94
|
+
const m = Math.floor(s / 60);
|
|
95
|
+
if (m < 60)
|
|
96
|
+
return `${m}m`;
|
|
97
|
+
const h = Math.floor(m / 60);
|
|
98
|
+
if (h < 24)
|
|
99
|
+
return `${h}h`;
|
|
100
|
+
const d = Math.floor(h / 24);
|
|
101
|
+
if (d < 7)
|
|
102
|
+
return `${d}d`;
|
|
103
|
+
const w = Math.floor(d / 7);
|
|
104
|
+
if (w < 5)
|
|
105
|
+
return `${w}w`;
|
|
106
|
+
return `${Math.floor(d / 30)}mo`;
|
|
107
|
+
}
|
|
108
|
+
/** Last path segment of a cwd — the project name shown as the All-dirs cue. */
|
|
109
|
+
function baseDir(cwd) {
|
|
110
|
+
const parts = cwd.replace(/\/+$/, '').split('/');
|
|
111
|
+
return parts[parts.length - 1] || cwd;
|
|
112
|
+
}
|
|
113
|
+
/** Greedy word-wrap to `width` cols, capped at `maxLines` (last line clipped). */
|
|
114
|
+
function wrap(text, width, maxLines) {
|
|
115
|
+
if (width <= 0 || maxLines <= 0)
|
|
116
|
+
return [];
|
|
117
|
+
const words = text.replace(/\s+/g, ' ').trim().split(' ');
|
|
118
|
+
const out = [];
|
|
119
|
+
let cur = '';
|
|
120
|
+
for (const w of words) {
|
|
121
|
+
const next = cur === '' ? w : `${cur} ${w}`;
|
|
122
|
+
if (next.length <= width) {
|
|
123
|
+
cur = next;
|
|
124
|
+
continue;
|
|
125
|
+
}
|
|
126
|
+
if (cur !== '')
|
|
127
|
+
out.push(cur);
|
|
128
|
+
cur = w;
|
|
129
|
+
if (out.length >= maxLines)
|
|
130
|
+
break;
|
|
131
|
+
}
|
|
132
|
+
if (out.length < maxLines && cur !== '')
|
|
133
|
+
out.push(cur);
|
|
134
|
+
return out.slice(0, maxLines).map((l) => clip(l, width));
|
|
135
|
+
}
|
|
136
|
+
/** Style one span. Hue is gated on `color`; bold/dim are not. After the span we
|
|
137
|
+
* return to `lineBase` (not a bare reset) so a row-level background/dim persists
|
|
138
|
+
* across the span instead of bleeding or being cleared. */
|
|
139
|
+
function styleSpan(text, span, color, lineBase) {
|
|
140
|
+
if (text === '')
|
|
141
|
+
return '';
|
|
142
|
+
let pre = '';
|
|
143
|
+
if (span.dim)
|
|
144
|
+
pre += DIM;
|
|
145
|
+
if (span.bold)
|
|
146
|
+
pre += BOLD;
|
|
147
|
+
if (color && span.fg)
|
|
148
|
+
pre += `${ESC}${span.fg}m`;
|
|
149
|
+
if (pre === '')
|
|
150
|
+
return text; // inherits lineBase / default
|
|
151
|
+
return `${pre}${text}${RESET}${lineBase}`;
|
|
152
|
+
}
|
|
153
|
+
/** Assemble styled spans into one line clipped to `width` visible cols. When
|
|
154
|
+
* `fill`, pad the remainder with spaces (under `lineBase`) so a cursor-row
|
|
155
|
+
* background spans the full width. Always RESET-terminated so no color bleeds
|
|
156
|
+
* into the next line. */
|
|
157
|
+
function assemble(spans, width, color, lineBase, fill) {
|
|
158
|
+
let used = 0;
|
|
159
|
+
let body = '';
|
|
160
|
+
for (const span of spans) {
|
|
161
|
+
if (used >= width)
|
|
162
|
+
break;
|
|
163
|
+
if (span.text === '')
|
|
164
|
+
continue;
|
|
165
|
+
let t = span.text;
|
|
166
|
+
const remaining = width - used;
|
|
167
|
+
let cut = false;
|
|
168
|
+
if (t.length > remaining) {
|
|
169
|
+
t = t.slice(0, Math.max(0, remaining - 1)) + '…';
|
|
170
|
+
cut = true;
|
|
171
|
+
}
|
|
172
|
+
body += styleSpan(t, span, color, lineBase);
|
|
173
|
+
used += t.length;
|
|
174
|
+
if (cut)
|
|
175
|
+
break;
|
|
176
|
+
}
|
|
177
|
+
if (fill && used < width)
|
|
178
|
+
body += ' '.repeat(width - used);
|
|
179
|
+
return lineBase === '' ? body : `${lineBase}${body}${RESET}`;
|
|
180
|
+
}
|
|
181
|
+
/** Status tallies across the whole canvas, for the right-aligned header. Memoized
|
|
182
|
+
* per tree — the snapshot is immutable for a browse session, so counting is done
|
|
183
|
+
* once, not O(N) on every keystroke (the "massive canvas" target). */
|
|
184
|
+
const summaryCache = new WeakMap();
|
|
185
|
+
function statusCounts(tree) {
|
|
186
|
+
const cached = summaryCache.get(tree);
|
|
187
|
+
if (cached !== undefined)
|
|
188
|
+
return cached;
|
|
189
|
+
const counts = { active: 0, idle: 0, done: 0, dead: 0, canceled: 0 };
|
|
190
|
+
for (const node of tree.nodes.values())
|
|
191
|
+
counts[node.row.status]++;
|
|
192
|
+
const parts = [];
|
|
193
|
+
for (const s of ['active', 'idle', 'done', 'dead', 'canceled']) {
|
|
194
|
+
if (counts[s] > 0)
|
|
195
|
+
parts.push({ status: s, count: counts[s] });
|
|
196
|
+
}
|
|
197
|
+
summaryCache.set(tree, parts);
|
|
198
|
+
return parts;
|
|
199
|
+
}
|
|
200
|
+
function tabBar(active, color) {
|
|
201
|
+
return TABS.map((t) => {
|
|
202
|
+
if (t === active) {
|
|
203
|
+
// Keep the [ ] brackets in BOTH paths so the active tab reads without color.
|
|
204
|
+
return color ? `${BOLD}${ESC}${FG_CYAN}m[ ${t} ]${RESET}` : `${REVERSE}[ ${t} ]${RESET}`;
|
|
205
|
+
}
|
|
206
|
+
return color ? `${DIM} ${t} ${RESET}` : ` ${t} `;
|
|
207
|
+
}).join('');
|
|
208
|
+
}
|
|
209
|
+
const EMPTY_HI = new Set();
|
|
210
|
+
/** Name → spans, splitting out the query-matched chars (bold + bright-cyan) so
|
|
211
|
+
* matches are scannable. Non-matched chars carry the row's name style (dim for
|
|
212
|
+
* terminal status, bold on the cursor row). */
|
|
213
|
+
function nameSpans(name, query, style) {
|
|
214
|
+
const hi = query === '' ? EMPTY_HI : matchIndices(query, name);
|
|
215
|
+
if (hi.size === 0)
|
|
216
|
+
return [{ text: name, dim: style.dim, bold: style.bold }];
|
|
217
|
+
const out = [];
|
|
218
|
+
let buf = '';
|
|
219
|
+
let bufHi = false;
|
|
220
|
+
const flush = () => {
|
|
221
|
+
if (buf === '')
|
|
222
|
+
return;
|
|
223
|
+
if (bufHi)
|
|
224
|
+
out.push({ text: buf, fg: FG_BRIGHT_CYAN, bold: true });
|
|
225
|
+
else
|
|
226
|
+
out.push({ text: buf, dim: style.dim, bold: style.bold });
|
|
227
|
+
buf = '';
|
|
228
|
+
};
|
|
229
|
+
for (let i = 0; i < name.length; i++) {
|
|
230
|
+
const h = hi.has(i);
|
|
231
|
+
if (h !== bufHi) {
|
|
232
|
+
flush();
|
|
233
|
+
bufHi = h;
|
|
234
|
+
}
|
|
235
|
+
buf += name[i];
|
|
236
|
+
}
|
|
237
|
+
flush();
|
|
238
|
+
return out;
|
|
239
|
+
}
|
|
240
|
+
/** One row line: `<indent><collapse> <glyph> <name> [kind/mode] ctx Nk age [~dir] [⚑n]`.
|
|
241
|
+
* `showCwd` adds the project-name cue (All-dirs view); `now` drives the age. */
|
|
242
|
+
function rowLine(row, tree, width, isCursor, query, caps, showCwd, now) {
|
|
243
|
+
const node = tree.nodes.get(row.id);
|
|
244
|
+
if (node === undefined)
|
|
245
|
+
return '';
|
|
246
|
+
const r = node.row;
|
|
247
|
+
const indent = ' '.repeat(row.depth);
|
|
248
|
+
const collapse = !row.hasChildren ? ' ' : row.collapsed ? '▸' : '▾';
|
|
249
|
+
const glyph = STATUS_GLYPH[r.status] ?? '?';
|
|
250
|
+
const terminal = r.status === 'done' || r.status === 'dead' || r.status === 'canceled';
|
|
251
|
+
const ctxStr = fmtCtx(r.ctx_tokens);
|
|
252
|
+
const ctxFg = ctxColorCode(r.ctx_tokens);
|
|
253
|
+
const age = relAge(r.created, now);
|
|
254
|
+
// Name: bold on the cursor row; dim for terminal status (live names stay default).
|
|
255
|
+
const nameStyle = { dim: !isCursor && terminal, bold: isCursor };
|
|
256
|
+
const spans = [
|
|
257
|
+
{ text: `${indent}${collapse} ` },
|
|
258
|
+
{ text: glyph, fg: STATUS_COLOR[r.status] }, // load-bearing status hue
|
|
259
|
+
{ text: ' ' },
|
|
260
|
+
...nameSpans(r.name, query, nameStyle),
|
|
261
|
+
{ text: ` [${r.kind}/${r.mode}]`, fg: FG_GRAY }, // recedes
|
|
262
|
+
{ text: ' ctx ', dim: true },
|
|
263
|
+
{ text: ctxStr, fg: ctxFg, dim: ctxFg === undefined }, // tiered budget cue
|
|
264
|
+
];
|
|
265
|
+
if (age !== '')
|
|
266
|
+
spans.push({ text: ` ${age}`, dim: true }); // recency cue
|
|
267
|
+
if (showCwd)
|
|
268
|
+
spans.push({ text: ` ~${baseDir(r.cwd)}`, fg: FG_GRAY }); // project cue (All dirs)
|
|
269
|
+
if (r.asks > 0)
|
|
270
|
+
spans.push({ text: ` ⚑${r.asks}`, fg: FG_BRIGHT_YELLOW, bold: true }); // attention
|
|
271
|
+
// Row base: cursor → subtle bg (256) or reverse fallback (also covers !color);
|
|
272
|
+
// non-matched ancestor → whole-row dim for tree context (keep prior behavior).
|
|
273
|
+
let lineBase = '';
|
|
274
|
+
let fill = false;
|
|
275
|
+
if (isCursor) {
|
|
276
|
+
lineBase = caps.color256 ? CURSOR_BG : REVERSE;
|
|
277
|
+
fill = true;
|
|
278
|
+
}
|
|
279
|
+
else if (!row.matched) {
|
|
280
|
+
lineBase = DIM;
|
|
281
|
+
}
|
|
282
|
+
return assemble(spans, width, caps.color, lineBase, fill);
|
|
283
|
+
}
|
|
284
|
+
/** The status line (always present): active cwd scope · sort mode · committed
|
|
285
|
+
* filter. The committed-filter indicator lives here now (not its own line) so a
|
|
286
|
+
* filtered view always advertises its query without consuming a header row. */
|
|
287
|
+
function statusLine(state) {
|
|
288
|
+
const dim = (s) => `${DIM}${s}${RESET}`;
|
|
289
|
+
const scope = state.cwdScope === null ? 'all dirs' : baseDir(state.cwdScope);
|
|
290
|
+
const segs = [`${dim('scope')} ${scope}`, `${dim('sort')} ${state.sort}`];
|
|
291
|
+
if (!state.search && state.query !== '')
|
|
292
|
+
segs.push(`${dim('filter')} ${state.query}`);
|
|
293
|
+
return segs.join(dim(' · '));
|
|
294
|
+
}
|
|
295
|
+
/** The bottom preview panel — exactly PREVIEW_HEIGHT lines: a separator, a meta
|
|
296
|
+
* line (status · kind/mode · project · age · ctx · asks), then the selected
|
|
297
|
+
* node's spawn prompt wrapped to PREVIEW_BODY lines. The "which one was this?"
|
|
298
|
+
* answer — paired with super-search. Always full height so viewport math holds. */
|
|
299
|
+
function previewPanel(r, width, caps, now) {
|
|
300
|
+
const out = [`${DIM}${'─'.repeat(width)}${RESET}`];
|
|
301
|
+
if (r === undefined) {
|
|
302
|
+
while (out.length < PREVIEW_HEIGHT)
|
|
303
|
+
out.push('');
|
|
304
|
+
return out;
|
|
305
|
+
}
|
|
306
|
+
const glyph = caps.color
|
|
307
|
+
? `${ESC}${STATUS_COLOR[r.status]}m${STATUS_GLYPH[r.status]}${RESET}`
|
|
308
|
+
: (STATUS_GLYPH[r.status] ?? '?');
|
|
309
|
+
const metaPieces = [`${r.status} ${r.kind}/${r.mode}`, baseDir(r.cwd), relAge(r.created, now), `ctx ${fmtCtx(r.ctx_tokens)}`];
|
|
310
|
+
if (r.asks > 0)
|
|
311
|
+
metaPieces.push(`⚑${r.asks}`);
|
|
312
|
+
const metaText = clip(metaPieces.filter((p) => p !== '').join(' · '), Math.max(0, width - 2));
|
|
313
|
+
out.push(caps.color ? `${glyph} ${DIM}${metaText}${RESET}` : `${glyph} ${metaText}`);
|
|
314
|
+
const goalText = (r.goal ?? '').trim();
|
|
315
|
+
const body = goalText === '' ? [`${DIM}(no spawn prompt)${RESET}`] : wrap(goalText, width, PREVIEW_BODY);
|
|
316
|
+
for (let i = 0; i < PREVIEW_BODY; i++)
|
|
317
|
+
out.push(body[i] ?? '');
|
|
318
|
+
return out;
|
|
319
|
+
}
|
|
320
|
+
/**
|
|
321
|
+
* Render the whole frame. Returns a single string that, written as-is, repaints
|
|
322
|
+
* the screen in place. `caps` gates all hue (defaults to a no-color frame so
|
|
323
|
+
* existing callers / non-TTY paths stay color-free).
|
|
324
|
+
*/
|
|
325
|
+
export function renderFrame(state, size, caps = { color: false, color256: false }) {
|
|
326
|
+
const cols = Math.max(20, size.cols);
|
|
327
|
+
const rows = Math.max(8, size.rows);
|
|
328
|
+
const width = cols - 1; // leave the last column for \x1b[K
|
|
329
|
+
const now = Date.now();
|
|
330
|
+
const lines = [];
|
|
331
|
+
// line 1 — title + right-aligned status summary (each glyph in its status hue).
|
|
332
|
+
const title = `${BOLD}Canvas${RESET} — ${state.totalNodes} nodes`;
|
|
333
|
+
const titlePlainLen = `Canvas — ${state.totalNodes} nodes`.length;
|
|
334
|
+
const parts = statusCounts(state.tree);
|
|
335
|
+
const summaryPlain = parts.map((p) => `${STATUS_GLYPH[p.status]}${p.count}`).join(' ');
|
|
336
|
+
const summaryColored = caps.color
|
|
337
|
+
? parts.map((p) => `${ESC}${STATUS_COLOR[p.status]}m${STATUS_GLYPH[p.status]}${RESET}${DIM}${p.count}${RESET}`).join(' ')
|
|
338
|
+
: `${DIM}${summaryPlain}${RESET}`;
|
|
339
|
+
const gap = width - titlePlainLen - summaryPlain.length;
|
|
340
|
+
lines.push(gap > 1 ? `${title}${' '.repeat(gap)}${summaryColored}` : title);
|
|
341
|
+
// line 2 — tab bar.
|
|
342
|
+
lines.push(tabBar(state.tab, caps.color));
|
|
343
|
+
// line 3 — status line (scope · sort · filter).
|
|
344
|
+
lines.push(statusLine(state));
|
|
345
|
+
// line 4 — search input, only while actively typing a search.
|
|
346
|
+
if (state.search) {
|
|
347
|
+
const slash = caps.color ? `${ESC}${FG_CYAN}m/${RESET}` : '/';
|
|
348
|
+
lines.push(`${slash} ${state.query}▎`);
|
|
349
|
+
}
|
|
350
|
+
// separator.
|
|
351
|
+
lines.push(`${DIM}${'─'.repeat(width)}${RESET}`);
|
|
352
|
+
// body — windowed visible rows, leaving room for the (optional) preview panel.
|
|
353
|
+
const head = lines.length; // === headerHeight(state.search)
|
|
354
|
+
const showCwd = state.cwdScope === null; // project cue only matters across dirs
|
|
355
|
+
const previewOn = state.preview && state.visible.length > 0;
|
|
356
|
+
const previewH = previewOn ? PREVIEW_HEIGHT : 0;
|
|
357
|
+
const viewport = Math.max(1, rows - head - 1 /* footer */ - previewH);
|
|
358
|
+
const start = state.scrollOffset;
|
|
359
|
+
const end = Math.min(state.visible.length, start + viewport);
|
|
360
|
+
if (state.visible.length === 0) {
|
|
361
|
+
lines.push(`${DIM} (no nodes match this view)${RESET}`);
|
|
362
|
+
for (let i = 1; i < viewport; i++)
|
|
363
|
+
lines.push('');
|
|
364
|
+
}
|
|
365
|
+
else {
|
|
366
|
+
for (let i = start; i < end; i++) {
|
|
367
|
+
lines.push(rowLine(state.visible[i], state.tree, width, i === state.cursor, state.query, caps, showCwd, now));
|
|
368
|
+
}
|
|
369
|
+
for (let i = end - start; i < viewport; i++)
|
|
370
|
+
lines.push('');
|
|
371
|
+
}
|
|
372
|
+
// preview panel — the selected row's prompt + meta.
|
|
373
|
+
if (previewOn) {
|
|
374
|
+
const sel = state.visible[state.cursor];
|
|
375
|
+
const r = sel !== undefined ? state.tree.nodes.get(sel.id)?.row : undefined;
|
|
376
|
+
for (const l of previewPanel(r, width, caps, now))
|
|
377
|
+
lines.push(l);
|
|
378
|
+
}
|
|
379
|
+
// footer.
|
|
380
|
+
const footer = state.search
|
|
381
|
+
? '⏎ commit Esc cancel ⌫ delete'
|
|
382
|
+
: '↑↓ move →/← tree ⏎ resume Tab tabs / search s sort c cwd p preview q quit';
|
|
383
|
+
lines.push(`${DIM}${clip(footer, width)}${RESET}`);
|
|
384
|
+
// Assemble: home, each line cleared to EOL, then clear below.
|
|
385
|
+
const body = lines.map((l) => `${l}${ESC}K`).join('\r\n');
|
|
386
|
+
return `${ESC}H${body}${ESC}J`;
|
|
387
|
+
}
|