@crouton-kit/crouter 0.3.70 → 0.3.78
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 +1 -1
- package/dist/builtin-memory/00-runtime-base.md +3 -10
- package/dist/builtin-memory/04-base-worker.md +18 -0
- package/dist/builtin-memory/04-orchestration-kernel.md +1 -1
- package/dist/builtin-pi-packages/pi-crtr-extensions/README.md +13 -34
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/__tests__/provider-rotation.test.ts +622 -724
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/provider-rotation.ts +231 -68
- package/dist/builtin-pi-packages/pi-crtr-extensions/extensions/statusline.ts +2 -9
- package/dist/builtin-pi-packages/pi-crtr-extensions/lib/subscription-state.ts +361 -90
- package/dist/builtin-pi-packages/pi-crtr-extensions/package-lock.json +2 -2
- package/dist/builtin-pi-packages/pi-mode-switch/README.md +11 -39
- package/dist/builtin-pi-packages/pi-mode-switch/extensions/index.ts +20 -15
- package/dist/builtin-pi-packages/pi-mode-switch/package.json +1 -1
- package/dist/builtin-views/canvas/tui.mjs +8 -9
- package/dist/builtin-views/chat/tui.mjs +12 -12
- package/dist/builtin-views/git-pr/tui.mjs +7 -8
- package/dist/builtin-views/inbox/tui.mjs +27 -41
- package/dist/builtin-views/linkedin/tui.mjs +23 -37
- package/dist/builtin-views/prompt-review/tui.mjs +11 -11
- package/dist/builtin-views/settings/tui.mjs +11 -11
- package/dist/builtin-views/workspace-sidebar/tui.mjs +8 -9
- package/dist/clients/attach/__tests__/attach-chrome-remote.test.js +3 -3
- package/dist/clients/attach/__tests__/crtr-output-render.test.js +1 -1
- package/dist/clients/attach/__tests__/mermaid-render.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/mermaid-render.test.js +20 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.d.ts +1 -0
- package/dist/clients/attach/__tests__/oauth-dialog-lifecycle.test.js +110 -0
- package/dist/clients/attach/attach-cmd.d.ts +1 -1
- package/dist/clients/attach/attach-cmd.js +738 -735
- package/dist/clients/attach/canvas-panels.js +2 -3
- package/dist/clients/attach/chat-view.d.ts +7 -8
- package/dist/clients/attach/chat-view.js +119 -79
- package/dist/clients/attach/edit-diff-render.d.ts +6 -0
- package/dist/clients/attach/edit-diff-render.js +263 -0
- package/dist/clients/attach/input-controller.d.ts +28 -2
- package/dist/clients/attach/input-controller.js +38 -5
- package/dist/clients/attach/mermaid-render.js +5 -2
- package/dist/clients/attach/pickers.d.ts +8 -7
- package/dist/clients/attach/pickers.js +11 -17
- package/dist/clients/attach/slash-commands.d.ts +9 -0
- package/dist/clients/attach/slash-commands.js +127 -7
- package/dist/clients/attach/titled-editor.d.ts +12 -1
- package/dist/clients/attach/titled-editor.js +103 -8
- package/dist/commands/canvas-browse.js +2 -2
- package/dist/commands/memory/lint.js +39 -5
- package/dist/commands/memory/write.js +1 -0
- package/dist/commands/node.js +9 -2
- package/dist/commands/surface-tmux-spread.js +1 -3
- package/dist/commands/sys/__tests__/config-keybindings.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-keybindings.test.js +55 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.d.ts +1 -0
- package/dist/commands/sys/__tests__/config-model-ladders.test.js +121 -0
- package/dist/commands/sys/config.js +18 -21
- package/dist/commands/sys/setup-core.d.ts +12 -1
- package/dist/commands/sys/setup-core.js +23 -3
- package/dist/commands/sys/setup.js +42 -12
- package/dist/core/__tests__/base-worker-prompt.test.d.ts +1 -0
- package/dist/core/__tests__/base-worker-prompt.test.js +24 -0
- package/dist/core/__tests__/canvas-inbox-watcher-hold.test.js +232 -1
- package/dist/core/__tests__/fault-classifier.test.js +15 -0
- package/dist/core/__tests__/full/broker-dialogs.test.js +7 -2
- package/dist/core/__tests__/host-teardown-process-group.test.js +15 -4
- package/dist/core/__tests__/stream-watchdog.test.d.ts +1 -0
- package/dist/core/__tests__/stream-watchdog.test.js +70 -0
- package/dist/core/__tests__/tmux-surface.test.js +72 -0
- package/dist/core/canvas/browse/__tests__/model.test.js +23 -5
- package/dist/core/canvas/browse/__tests__/render.test.js +1 -0
- package/dist/core/canvas/browse/app.js +164 -5
- package/dist/core/canvas/browse/model.d.ts +10 -6
- package/dist/core/canvas/browse/model.js +20 -12
- package/dist/core/canvas/browse/pins.d.ts +4 -0
- package/dist/core/canvas/browse/pins.js +29 -0
- package/dist/core/canvas/browse/render.d.ts +13 -1
- package/dist/core/canvas/browse/render.js +72 -15
- package/dist/core/canvas/nav-model.js +5 -11
- package/dist/core/canvas/paths.d.ts +5 -0
- package/dist/core/canvas/paths.js +7 -0
- package/dist/core/config.d.ts +53 -2
- package/dist/core/config.js +228 -110
- package/dist/core/fault-classifier.js +1 -1
- package/dist/core/fs-utils.d.ts +1 -0
- package/dist/core/fs-utils.js +15 -1
- package/dist/core/keybindings/__tests__/resolve.test.d.ts +1 -0
- package/dist/core/keybindings/__tests__/resolve.test.js +224 -0
- package/dist/core/keybindings/catalog.d.ts +14 -0
- package/dist/core/keybindings/catalog.js +256 -0
- package/dist/core/keybindings/index.d.ts +5 -0
- package/dist/core/keybindings/index.js +4 -0
- package/dist/core/keybindings/match.d.ts +29 -0
- package/dist/core/keybindings/match.js +99 -0
- package/dist/core/keybindings/persistence.d.ts +14 -0
- package/dist/core/keybindings/persistence.js +31 -0
- package/dist/core/keybindings/resolve.d.ts +14 -0
- package/dist/core/keybindings/resolve.js +305 -0
- package/dist/core/keybindings/types.d.ts +37 -0
- package/dist/core/keybindings/types.js +1 -0
- package/dist/core/memory-resolver.d.ts +1 -1
- package/dist/core/memory-resolver.js +10 -3
- package/dist/core/predicate.d.ts +5 -3
- package/dist/core/predicate.js +5 -3
- package/dist/core/runtime/broker-protocol.d.ts +33 -5
- package/dist/core/runtime/broker.js +163 -14
- package/dist/core/runtime/launch.d.ts +39 -6
- package/dist/core/runtime/launch.js +78 -20
- package/dist/core/runtime/naming.js +3 -3
- package/dist/core/runtime/pi-cli.d.ts +6 -0
- package/dist/core/runtime/pi-cli.js +16 -2
- package/dist/core/runtime/placement.d.ts +2 -2
- package/dist/core/runtime/placement.js +4 -1
- package/dist/core/runtime/promote.js +4 -0
- package/dist/core/runtime/recap.d.ts +3 -3
- package/dist/core/runtime/recap.js +47 -44
- package/dist/core/runtime/recycle.js +6 -1
- package/dist/core/runtime/reset.js +5 -0
- package/dist/core/runtime/session-list-cache.d.ts +23 -0
- package/dist/core/runtime/session-list-cache.js +270 -0
- package/dist/core/runtime/spawn.js +51 -32
- package/dist/core/runtime/stream-watchdog.d.ts +26 -0
- package/dist/core/runtime/stream-watchdog.js +75 -0
- package/dist/core/runtime/tmux-chrome.d.ts +1 -1
- package/dist/core/runtime/tmux-chrome.js +2 -2
- package/dist/core/runtime/tmux.d.ts +18 -15
- package/dist/core/runtime/tmux.js +216 -110
- package/dist/core/tui/__tests__/host-keybindings.test.d.ts +1 -0
- package/dist/core/tui/__tests__/host-keybindings.test.js +112 -0
- package/dist/core/tui/host.d.ts +30 -1
- package/dist/core/tui/host.js +148 -34
- package/dist/core/view/contract.d.ts +17 -3
- package/dist/pi-extensions/__tests__/canvas-stophook-agentend.test.js +41 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.d.ts +1 -0
- package/dist/pi-extensions/__tests__/canvas-stophook-context-nudge.test.js +121 -0
- package/dist/pi-extensions/canvas-inbox-watcher.d.ts +1 -1
- package/dist/pi-extensions/canvas-inbox-watcher.js +203 -25
- package/dist/pi-extensions/canvas-recap.d.ts +4 -0
- package/dist/pi-extensions/canvas-recap.js +38 -37
- package/dist/pi-extensions/canvas-stophook.d.ts +11 -0
- package/dist/pi-extensions/canvas-stophook.js +33 -4
- package/dist/types.d.ts +3 -13
- package/dist/types.js +2 -26
- package/dist/web-client/assets/{index-DiFuLcp6.js → index--md2ylfi.js} +1 -1
- package/dist/web-client/index.html +1 -1
- package/dist/web-client/sw.js +1 -1
- package/package.json +3 -3
- package/dist/pi-extensions/canvas-nav.d.ts +0 -43
- package/dist/pi-extensions/canvas-nav.js +0 -640
- package/dist/pi-extensions/widget-order-bus.d.ts +0 -6
- package/dist/pi-extensions/widget-order-bus.js +0 -34
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
import { test, before, after } from 'node:test';
|
|
63
63
|
import assert from 'node:assert/strict';
|
|
64
64
|
import { spawn } from 'node:child_process';
|
|
65
|
-
import {
|
|
65
|
+
import { mkdtempSync, readFileSync, rmSync } from 'node:fs';
|
|
66
66
|
import { tmpdir } from 'node:os';
|
|
67
67
|
import { join } from 'node:path';
|
|
68
68
|
import { createHarness } from './helpers/harness.js';
|
|
@@ -180,9 +180,20 @@ async function spawnBrokerWithSharedGroupDescendant(tag, descendantScript = 'sle
|
|
|
180
180
|
if (leader.pid == null)
|
|
181
181
|
throw new Error('failed to spawn fake broker leader');
|
|
182
182
|
trackPid(leader.pid);
|
|
183
|
-
await h.waitFor(() =>
|
|
184
|
-
|
|
185
|
-
|
|
183
|
+
const descendantPid = await h.waitFor(() => {
|
|
184
|
+
try {
|
|
185
|
+
const record = readFileSync(pidFile, 'utf8');
|
|
186
|
+
if (!/^\d+\n$/.test(record))
|
|
187
|
+
return false;
|
|
188
|
+
const pid = Number(record.slice(0, -1));
|
|
189
|
+
return Number.isSafeInteger(pid) && pid > 0 ? pid : false;
|
|
190
|
+
}
|
|
191
|
+
catch (error) {
|
|
192
|
+
if (error.code === 'ENOENT')
|
|
193
|
+
return false;
|
|
194
|
+
throw error;
|
|
195
|
+
}
|
|
196
|
+
}, { label: `${tag}: valid descendant pid written` });
|
|
186
197
|
trackPid(descendantPid);
|
|
187
198
|
await h.waitFor(() => isPidAlive(descendantPid), { label: `${tag}: descendant alive before teardown` });
|
|
188
199
|
return { leaderPid: leader.pid, descendantPid };
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
// Run with: node --import tsx/esm --test src/core/__tests__/stream-watchdog.test.ts
|
|
2
|
+
//
|
|
3
|
+
// Unit-level contract test for StreamWatchdog (see stream-watchdog.ts): a
|
|
4
|
+
// single-timer dead-man's switch the broker pokes while a turn awaits the
|
|
5
|
+
// provider (disarmed during tool execution) and clears at agent_end. This
|
|
6
|
+
// locks the class's arm/clear/single-fire/token contract directly — no broker
|
|
7
|
+
// or fake-engine harness needed (that integration surface is deliberately out
|
|
8
|
+
// of scope; see
|
|
9
|
+
// the roadmap's Fix 3 test-scope note).
|
|
10
|
+
import { test } from 'node:test';
|
|
11
|
+
import assert from 'node:assert/strict';
|
|
12
|
+
import { StreamWatchdog } from '../runtime/stream-watchdog.js';
|
|
13
|
+
const MS = 30;
|
|
14
|
+
const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms));
|
|
15
|
+
test('poke() arms the watchdog, firing once after ms with no further poke', async () => {
|
|
16
|
+
let calls = 0;
|
|
17
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
18
|
+
wd.poke(0);
|
|
19
|
+
assert.equal(calls, 0, 'must not fire before ms elapses');
|
|
20
|
+
await sleep(MS * 2);
|
|
21
|
+
assert.equal(calls, 1, 'must fire exactly once after ms with no further poke');
|
|
22
|
+
});
|
|
23
|
+
test('repeated pokes rearm the deadline from the LAST poke, not the first', async () => {
|
|
24
|
+
// A cancel-without-reschedule bug (clear the old timer but never arm a new
|
|
25
|
+
// one) would also "never fire while poked", so proving no-fire mid-stream is
|
|
26
|
+
// not enough. Prove the deadline actually REARMS: no fire around the original
|
|
27
|
+
// deadline, then exactly one fire measured from the FINAL poke.
|
|
28
|
+
let calls = 0;
|
|
29
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
30
|
+
wd.poke(0);
|
|
31
|
+
// Re-poke well inside the window, several times, always resetting the clock.
|
|
32
|
+
// The last poke lands at ~t=2.5*MS, so the original t=MS deadline must pass
|
|
33
|
+
// with no fire.
|
|
34
|
+
for (let i = 0; i < 5; i++) {
|
|
35
|
+
await sleep(MS / 2);
|
|
36
|
+
wd.poke(0);
|
|
37
|
+
}
|
|
38
|
+
assert.equal(calls, 0, 'no fire around the original deadline — each poke rearmed it');
|
|
39
|
+
// From the final poke the timer must still be armed and fire exactly once.
|
|
40
|
+
await sleep(MS * 2);
|
|
41
|
+
assert.equal(calls, 1, 'exactly one fire, measured from the final poke — proves rearming');
|
|
42
|
+
});
|
|
43
|
+
test('clear() after poke prevents firing', async () => {
|
|
44
|
+
let calls = 0;
|
|
45
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
46
|
+
wd.poke(0);
|
|
47
|
+
wd.clear();
|
|
48
|
+
await sleep(MS * 2);
|
|
49
|
+
assert.equal(calls, 0, 'a cleared watchdog must never fire');
|
|
50
|
+
});
|
|
51
|
+
test('fires exactly once even if left armed well past ms', async () => {
|
|
52
|
+
let calls = 0;
|
|
53
|
+
const wd = new StreamWatchdog(() => calls++, MS);
|
|
54
|
+
wd.poke(0);
|
|
55
|
+
await sleep(MS * 5);
|
|
56
|
+
assert.equal(calls, 1, 'must fire only once, not repeatedly, past the deadline');
|
|
57
|
+
});
|
|
58
|
+
test('onStall receives the token of the arm that fired (generation binding)', async () => {
|
|
59
|
+
// The broker stamps each poke with the current session generation and no-ops
|
|
60
|
+
// a stall whose token is stale. This pins that the token travels with the
|
|
61
|
+
// arm: a re-poke supersedes the prior token, so a fire after a rebind carries
|
|
62
|
+
// the NEW generation — and an old timer that somehow survived would deliver
|
|
63
|
+
// the OLD one, which the broker guard rejects.
|
|
64
|
+
const tokens = [];
|
|
65
|
+
const wd = new StreamWatchdog((token) => tokens.push(token), MS);
|
|
66
|
+
wd.poke(1);
|
|
67
|
+
wd.poke(2); // supersedes generation 1 before it can fire
|
|
68
|
+
await sleep(MS * 2);
|
|
69
|
+
assert.deepEqual(tokens, [2], 'exactly one fire, carrying the most-recent arm\'s token');
|
|
70
|
+
});
|
|
@@ -18,8 +18,11 @@
|
|
|
18
18
|
import { test } from 'node:test';
|
|
19
19
|
import assert from 'node:assert/strict';
|
|
20
20
|
import { readFileSync, readdirSync, statSync } from 'node:fs';
|
|
21
|
+
import { spawnSync } from 'node:child_process';
|
|
21
22
|
import { fileURLToPath } from 'node:url';
|
|
22
23
|
import { dirname, join, basename } from 'node:path';
|
|
24
|
+
import { installTmuxBindings } from '../runtime/tmux-chrome.js';
|
|
25
|
+
import { resolveKeybindings } from '../keybindings/index.js';
|
|
23
26
|
const __dirname = dirname(fileURLToPath(import.meta.url));
|
|
24
27
|
const SRC_ROOT = join(__dirname, '..', '..'); // .../src
|
|
25
28
|
const TMUX_TS = join(SRC_ROOT, 'core', 'runtime', 'tmux.ts');
|
|
@@ -113,3 +116,72 @@ test('§5.1 lint: only placement.ts / tmux-chrome.ts import the tmux driver', ()
|
|
|
113
116
|
.map((f) => f.slice(SRC_ROOT.length + 1));
|
|
114
117
|
assert.deepEqual(offenders, [], `modules importing tmux.ts directly: ${offenders.join(', ')}`);
|
|
115
118
|
});
|
|
119
|
+
function isolatedTmux(socket, args) {
|
|
120
|
+
const result = spawnSync('tmux', ['-S', socket, ...args], { encoding: 'utf8' });
|
|
121
|
+
return { status: result.status, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
|
|
122
|
+
}
|
|
123
|
+
function tmuxBinding(socket, table, key) {
|
|
124
|
+
const result = isolatedTmux(socket, ['list-keys', '-T', table]);
|
|
125
|
+
if (result.status !== 0)
|
|
126
|
+
return null;
|
|
127
|
+
return result.stdout.split('\n').find((line) => {
|
|
128
|
+
const match = line.match(/^bind-key\s+(?:-r\s+)?-T\s+\S+\s+(\S+)\s+/);
|
|
129
|
+
return match?.[1] === key;
|
|
130
|
+
}) ?? null;
|
|
131
|
+
}
|
|
132
|
+
test('tmux installer remaps owned pairs and preserves occupied user bindings', (t) => {
|
|
133
|
+
if (spawnSync('tmux', ['-V']).status !== 0) {
|
|
134
|
+
t.skip('tmux is unavailable');
|
|
135
|
+
return;
|
|
136
|
+
}
|
|
137
|
+
const name = `crtr-keybindings-${process.pid}-${Date.now()}`;
|
|
138
|
+
const boot = spawnSync('tmux', ['-L', name, 'new-session', '-d', '-s', 'test'], { encoding: 'utf8' });
|
|
139
|
+
assert.equal(boot.status, 0, boot.stderr);
|
|
140
|
+
const socketResult = spawnSync('tmux', ['-L', name, 'display-message', '-p', '#{socket_path}'], { encoding: 'utf8' });
|
|
141
|
+
const socket = socketResult.stdout.trim();
|
|
142
|
+
assert.notEqual(socket, '');
|
|
143
|
+
const priorTmux = process.env['TMUX'];
|
|
144
|
+
process.env['TMUX'] = `${socket},1,0`;
|
|
145
|
+
try {
|
|
146
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-]', 'run-shell', `crtr node cycle --dir next --pane '#{pane_id}' >/dev/null 2>&1`]);
|
|
147
|
+
const first = installTmuxBindings(resolveKeybindings());
|
|
148
|
+
assert.equal(first.ok, true, JSON.stringify(first.diagnostics));
|
|
149
|
+
assert.ok(first.installed.some((pair) => pair.table === 'root' && pair.key === 'M-]'));
|
|
150
|
+
assert.match(tmuxBinding(socket, 'root', 'M-c') ?? '', /display-menu/);
|
|
151
|
+
assert.match(tmuxBinding(socket, 'root', 'M-v') ?? '', /switch-client/);
|
|
152
|
+
const remapped = installTmuxBindings(resolveKeybindings({
|
|
153
|
+
'crtr.tmux.menu.open': ['alt+shift+z'],
|
|
154
|
+
'crtr.tmux.menu.graph': ['shift+z'],
|
|
155
|
+
'crtr.tmux.node.next': [],
|
|
156
|
+
'crtr.tmux.view.next': ['alt+z > n'],
|
|
157
|
+
'crtr.tmux.view.previous': ['alt+z > p'],
|
|
158
|
+
}));
|
|
159
|
+
assert.equal(remapped.ok, true, JSON.stringify(remapped.diagnostics));
|
|
160
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-c'), null);
|
|
161
|
+
assert.equal(tmuxBinding(socket, 'root', 'M-]'), null);
|
|
162
|
+
const menu = tmuxBinding(socket, 'root', 'M-Z') ?? '';
|
|
163
|
+
assert.match(menu, /graph view/);
|
|
164
|
+
assert.match(menu, /Z/);
|
|
165
|
+
const prefix = tmuxBinding(socket, 'root', 'M-z') ?? '';
|
|
166
|
+
assert.match(prefix, /switch-client/);
|
|
167
|
+
const again = installTmuxBindings(resolveKeybindings({
|
|
168
|
+
'crtr.tmux.menu.open': ['alt+shift+y'],
|
|
169
|
+
'crtr.tmux.node.next': [],
|
|
170
|
+
'crtr.tmux.view.next': ['alt+z > n'],
|
|
171
|
+
'crtr.tmux.view.previous': ['alt+z > p'],
|
|
172
|
+
}));
|
|
173
|
+
assert.equal(again.ok, true, JSON.stringify(again.diagnostics));
|
|
174
|
+
isolatedTmux(socket, ['bind-key', '-T', 'root', 'M-Z', 'display-message', 'user-owned']);
|
|
175
|
+
const occupied = installTmuxBindings(resolveKeybindings({ 'crtr.tmux.menu.open': ['alt+shift+z'] }));
|
|
176
|
+
assert.equal(occupied.ok, true);
|
|
177
|
+
assert.ok(occupied.diagnostics.some((diagnostic) => diagnostic.code === 'occupied' && diagnostic.bindingId === 'crtr.tmux.menu.open'));
|
|
178
|
+
assert.match(tmuxBinding(socket, 'root', 'M-Z') ?? '', /user-owned/);
|
|
179
|
+
}
|
|
180
|
+
finally {
|
|
181
|
+
if (priorTmux === undefined)
|
|
182
|
+
delete process.env['TMUX'];
|
|
183
|
+
else
|
|
184
|
+
process.env['TMUX'] = priorTmux;
|
|
185
|
+
isolatedTmux(socket, ['kill-server']);
|
|
186
|
+
}
|
|
187
|
+
});
|
|
@@ -108,20 +108,38 @@ test('pruneNode: absent id is a no-op', () => {
|
|
|
108
108
|
assert.equal(t.nodes.size, before);
|
|
109
109
|
});
|
|
110
110
|
// ── tab predicates ───────────────────────────────────────────────────────────
|
|
111
|
-
test('tabPredicate: All / Live / Dormant /
|
|
112
|
-
assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', '
|
|
111
|
+
test('tabPredicate: All / Live / Dormant / Attention / Pinned', () => {
|
|
112
|
+
assert.deepEqual([...TABS], ['All', 'Live', 'Dormant', 'Attention', 'Pinned']);
|
|
113
113
|
const active = ROWS[0]; // root1 active
|
|
114
114
|
const idle = ROWS[1]; // child-a idle
|
|
115
115
|
const done = ROWS[2]; // grand-x done
|
|
116
|
-
const
|
|
116
|
+
const attention = ROWS[3]; // child-b active asks 2
|
|
117
117
|
assert.equal(tabPredicate('All', done), true);
|
|
118
118
|
assert.equal(tabPredicate('Live', active), true);
|
|
119
119
|
assert.equal(tabPredicate('Live', idle), true);
|
|
120
120
|
assert.equal(tabPredicate('Live', done), false);
|
|
121
121
|
assert.equal(tabPredicate('Dormant', done), true);
|
|
122
122
|
assert.equal(tabPredicate('Dormant', active), false);
|
|
123
|
-
|
|
124
|
-
assert.equal(tabPredicate('
|
|
123
|
+
// Attention = pending asks OR a fault.
|
|
124
|
+
assert.equal(tabPredicate('Attention', attention), true);
|
|
125
|
+
assert.equal(tabPredicate('Attention', active), false);
|
|
126
|
+
assert.equal(tabPredicate('Attention', { ...active, asks: 0, hanging: { kind: 'provider' } }), true);
|
|
127
|
+
// Pinned reads the isPinned arg, not the row.
|
|
128
|
+
assert.equal(tabPredicate('Pinned', active), false);
|
|
129
|
+
assert.equal(tabPredicate('Pinned', active, true), true);
|
|
130
|
+
});
|
|
131
|
+
// ── flatten: pins ────────────────────────────────────────────────────────────
|
|
132
|
+
test('flatten: Pinned tab shows only pinned rows', () => {
|
|
133
|
+
const pinned = new Set(['child-c']);
|
|
134
|
+
const v = flatten(tree(), { collapsed: new Set(), tab: 'Pinned', query: '', pinned });
|
|
135
|
+
// child-c is directly matched; root2 rides along as its ancestor (context).
|
|
136
|
+
const matched = v.filter((r) => r.matched).map((r) => r.id);
|
|
137
|
+
assert.deepEqual(matched, ['child-c']);
|
|
138
|
+
});
|
|
139
|
+
test('flatten: pinned rows float to the top in a flat sort', () => {
|
|
140
|
+
const pinned = new Set(['lonely']); // an idle straggler that would otherwise sort late
|
|
141
|
+
const v = flatten(tree(), { collapsed: new Set(), tab: 'All', query: '', sort: 'recency', pinned });
|
|
142
|
+
assert.equal(v[0].id, 'lonely');
|
|
125
143
|
});
|
|
126
144
|
// ── flatten: collapse / expand ───────────────────────────────────────────────
|
|
127
145
|
const allCollapsed = () => new Set(['root1', 'child-a', 'root2']); // every node with children
|
|
@@ -15,6 +15,7 @@ import { RemoteCanvasSource } from '../remote-canvas-source.js';
|
|
|
15
15
|
import { closeNode } from '../../runtime/close.js';
|
|
16
16
|
import { setupTerminal, restoreTerminal, getTerminalSize, parseKeypress, } from '../../tui/terminal.js';
|
|
17
17
|
import { buildTree, flatten, pruneNode, TABS } from './model.js';
|
|
18
|
+
import { loadPins, savePins } from './pins.js';
|
|
18
19
|
import { createCoalescedRebuilder } from './rebuild-coalescer.js';
|
|
19
20
|
// Sort cycle for the `s` key. Starts on the default `attention` ordering; one `s`
|
|
20
21
|
// press restores the structural `tree` view, then relevance/recency, then back.
|
|
@@ -23,10 +24,10 @@ import { renderFrame, detectColorCaps, headerHeight, PREVIEW_HEIGHT } from './re
|
|
|
23
24
|
/** Viewport (body) height = total rows minus the header renderFrame draws (see
|
|
24
25
|
* render.ts headerHeight), the footer, and the preview panel when shown. Kept
|
|
25
26
|
* in lockstep with render.ts via the shared headerHeight/PREVIEW_HEIGHT. */
|
|
26
|
-
function viewportHeight(rowsTotal,
|
|
27
|
+
function viewportHeight(rowsTotal, inputLine, previewOn) {
|
|
27
28
|
const rows = Math.max(8, rowsTotal);
|
|
28
29
|
const previewH = previewOn ? PREVIEW_HEIGHT : 0;
|
|
29
|
-
return Math.max(1, rows - headerHeight(
|
|
30
|
+
return Math.max(1, rows - headerHeight(inputLine) - 1 /* footer */ - previewH);
|
|
30
31
|
}
|
|
31
32
|
/** Wires a RemoteEventStream's `nodes`/`inbox` events through the coalescer
|
|
32
33
|
* (`rebuild-coalescer.ts`) so a burst of SSE events collapses to ONE rebuild
|
|
@@ -103,6 +104,14 @@ export async function runBrowse(opts = {}) {
|
|
|
103
104
|
preview: true, // default ON (decision)
|
|
104
105
|
residentsOnly: true, // default ON: hide one-shot workers (decision)
|
|
105
106
|
pendingClose: null,
|
|
107
|
+
pinned: loadPins(),
|
|
108
|
+
help: false,
|
|
109
|
+
rename: false,
|
|
110
|
+
renameBuf: '',
|
|
111
|
+
renameTarget: null,
|
|
112
|
+
message: false,
|
|
113
|
+
messageBuf: '',
|
|
114
|
+
messageTarget: null,
|
|
106
115
|
};
|
|
107
116
|
let visible = [];
|
|
108
117
|
// The DashboardRow ref the tree holds for a visible id. Enrichment mutates this
|
|
@@ -193,6 +202,7 @@ export async function runBrowse(opts = {}) {
|
|
|
193
202
|
cwdScope: state.cwdScope,
|
|
194
203
|
residentsOnly: state.residentsOnly,
|
|
195
204
|
sort: state.sort,
|
|
205
|
+
pinned: state.pinned,
|
|
196
206
|
});
|
|
197
207
|
if (keepId !== undefined) {
|
|
198
208
|
const idx = visible.findIndex((v) => v.id === keepId);
|
|
@@ -207,7 +217,8 @@ export async function runBrowse(opts = {}) {
|
|
|
207
217
|
const flush = async () => {
|
|
208
218
|
const size = getTerminalSize();
|
|
209
219
|
const previewOn = state.preview && visible.length > 0;
|
|
210
|
-
const
|
|
220
|
+
const inputMode = state.search || state.rename || state.message;
|
|
221
|
+
const viewport = viewportHeight(size.rows, inputMode, previewOn);
|
|
211
222
|
// Keep the cursor inside the viewport window.
|
|
212
223
|
if (state.cursor < state.scrollOffset)
|
|
213
224
|
state.scrollOffset = state.cursor;
|
|
@@ -238,6 +249,9 @@ export async function runBrowse(opts = {}) {
|
|
|
238
249
|
query: state.query, search: state.search, totalNodes,
|
|
239
250
|
cwdScope: state.cwdScope, sort: state.sort, preview: state.preview,
|
|
240
251
|
residentsOnly: state.residentsOnly, pendingClose: state.pendingClose,
|
|
252
|
+
pinned: state.pinned, help: state.help,
|
|
253
|
+
rename: state.rename, renameBuf: state.renameBuf,
|
|
254
|
+
message: state.message, messageBuf: state.messageBuf,
|
|
241
255
|
readOnly: isRemote,
|
|
242
256
|
}, size, caps);
|
|
243
257
|
process.stdout.write(frame);
|
|
@@ -419,6 +433,102 @@ export async function runBrowse(opts = {}) {
|
|
|
419
433
|
scheduleSearchRepaint();
|
|
420
434
|
}
|
|
421
435
|
};
|
|
436
|
+
// Recenter the cursor on the launching node (CRTR_NODE_ID), expanding its
|
|
437
|
+
// ancestors so it is actually visible. No-op outside a node or when it's filtered
|
|
438
|
+
// out of the current tab/scope. Same move the boot performs, on demand (`.`).
|
|
439
|
+
const recenterOnSelf = () => {
|
|
440
|
+
const selfId = process.env['CRTR_NODE_ID'];
|
|
441
|
+
if (selfId === undefined || selfId === '' || !tree.nodes.has(selfId))
|
|
442
|
+
return;
|
|
443
|
+
for (let p = tree.nodes.get(selfId)?.parentId ?? null; p !== null; p = tree.nodes.get(p)?.parentId ?? null) {
|
|
444
|
+
state.collapsed.delete(p);
|
|
445
|
+
}
|
|
446
|
+
recompute(selfId);
|
|
447
|
+
};
|
|
448
|
+
// Toggle the pin on a node: mutate the in-memory set, persist best-effort, and
|
|
449
|
+
// recompute so it floats (flat sorts) / (dis)appears from the Pinned tab.
|
|
450
|
+
const togglePin = (id) => {
|
|
451
|
+
if (state.pinned.has(id))
|
|
452
|
+
state.pinned.delete(id);
|
|
453
|
+
else
|
|
454
|
+
state.pinned.add(id);
|
|
455
|
+
savePins(state.pinned);
|
|
456
|
+
recompute(id);
|
|
457
|
+
};
|
|
458
|
+
// Commit an inline rename: `crtr node config <id> --name <name>` (also renames a
|
|
459
|
+
// live viewer window), then reflect the new name into the snapshot in place.
|
|
460
|
+
const commitRename = () => {
|
|
461
|
+
const id = state.renameTarget;
|
|
462
|
+
const name = state.renameBuf.trim();
|
|
463
|
+
state.rename = false;
|
|
464
|
+
if (id !== null && name !== '') {
|
|
465
|
+
try {
|
|
466
|
+
execFileSync('crtr', ['node', 'config', id, '--name', name], { stdio: 'ignore' });
|
|
467
|
+
}
|
|
468
|
+
catch {
|
|
469
|
+
/* best-effort — a rename failure never wedges the picker */
|
|
470
|
+
}
|
|
471
|
+
const n = tree.nodes.get(id);
|
|
472
|
+
if (n !== undefined)
|
|
473
|
+
n.row.name = name;
|
|
474
|
+
}
|
|
475
|
+
recompute(state.renameTarget ?? undefined);
|
|
476
|
+
scheduleFlush();
|
|
477
|
+
};
|
|
478
|
+
// Commit an inline message: `crtr node msg --to <id> <body>` (immediate inbox
|
|
479
|
+
// delivery, best-effort revive if dormant). Fire-and-forget — browse stays put.
|
|
480
|
+
const commitMessage = () => {
|
|
481
|
+
const id = state.messageTarget;
|
|
482
|
+
const body = state.messageBuf.trim();
|
|
483
|
+
state.message = false;
|
|
484
|
+
if (id !== null && body !== '') {
|
|
485
|
+
try {
|
|
486
|
+
execFileSync('crtr', ['node', 'msg', '--to', id, body], { stdio: 'ignore' });
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
/* best-effort — delivery failure never wedges the picker */
|
|
490
|
+
}
|
|
491
|
+
}
|
|
492
|
+
scheduleFlush();
|
|
493
|
+
};
|
|
494
|
+
// Shared text-input keystroke handling for the rename/message sub-modes: Esc
|
|
495
|
+
// cancels, Enter commits, Backspace deletes, Ctrl+C quits, printable chars append.
|
|
496
|
+
const onKeyText = (input, key, get, set, commit, cancel) => {
|
|
497
|
+
if (key.escape) {
|
|
498
|
+
cancel();
|
|
499
|
+
return;
|
|
500
|
+
}
|
|
501
|
+
if (key.return) {
|
|
502
|
+
commit();
|
|
503
|
+
return;
|
|
504
|
+
}
|
|
505
|
+
if (key.backspace) {
|
|
506
|
+
set(get().slice(0, -1));
|
|
507
|
+
scheduleFlush();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
if (key.ctrl) {
|
|
511
|
+
if (input === 'c')
|
|
512
|
+
quit();
|
|
513
|
+
return;
|
|
514
|
+
}
|
|
515
|
+
const printable = [...input].filter((c) => c >= ' ').join('');
|
|
516
|
+
if (printable.length > 0) {
|
|
517
|
+
set(get() + printable);
|
|
518
|
+
scheduleFlush();
|
|
519
|
+
}
|
|
520
|
+
};
|
|
521
|
+
const onKeyRename = (input, key) => onKeyText(input, key, () => state.renameBuf, (s) => { state.renameBuf = s; }, commitRename, () => { state.rename = false; scheduleFlush(); });
|
|
522
|
+
const onKeyMessage = (input, key) => onKeyText(input, key, () => state.messageBuf, (s) => { state.messageBuf = s; }, commitMessage, () => { state.message = false; scheduleFlush(); });
|
|
523
|
+
// Help overlay: any key dismisses (Ctrl+C still quits outright).
|
|
524
|
+
const onKeyHelp = (input, key) => {
|
|
525
|
+
if (key.ctrl && input === 'c') {
|
|
526
|
+
quit();
|
|
527
|
+
return;
|
|
528
|
+
}
|
|
529
|
+
state.help = false;
|
|
530
|
+
scheduleFlush();
|
|
531
|
+
};
|
|
422
532
|
const onKeyNav = (input, key) => {
|
|
423
533
|
// Ctrl-combos first: only Ctrl+C is meaningful (quit); swallow the rest so
|
|
424
534
|
// Ctrl+L / Ctrl+J / Ctrl+Q etc. don't masquerade as l/j/q commands.
|
|
@@ -519,7 +629,7 @@ export async function runBrowse(opts = {}) {
|
|
|
519
629
|
scheduleFlush();
|
|
520
630
|
return;
|
|
521
631
|
}
|
|
522
|
-
if (input >= '1' && input <= '
|
|
632
|
+
if (input >= '1' && input <= '5') {
|
|
523
633
|
const idx = Number(input) - 1;
|
|
524
634
|
if (idx < TABS.length) {
|
|
525
635
|
state.tab = TABS[idx];
|
|
@@ -569,6 +679,49 @@ export async function runBrowse(opts = {}) {
|
|
|
569
679
|
scheduleFlush();
|
|
570
680
|
return;
|
|
571
681
|
}
|
|
682
|
+
// Help overlay.
|
|
683
|
+
if (input === '?') {
|
|
684
|
+
state.help = true;
|
|
685
|
+
scheduleFlush();
|
|
686
|
+
return;
|
|
687
|
+
}
|
|
688
|
+
// Pin / unpin (local, view-only — works against a remote canvas too).
|
|
689
|
+
if (input === 'f') {
|
|
690
|
+
if (row !== undefined)
|
|
691
|
+
togglePin(row.id);
|
|
692
|
+
scheduleFlush();
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
// Recenter on the launching node.
|
|
696
|
+
if (input === '.') {
|
|
697
|
+
recenterOnSelf();
|
|
698
|
+
scheduleFlush();
|
|
699
|
+
return;
|
|
700
|
+
}
|
|
701
|
+
// Rename the node inline (mutation — no-op on a remote canvas).
|
|
702
|
+
if (input === 'R') {
|
|
703
|
+
if (isRemote)
|
|
704
|
+
return;
|
|
705
|
+
if (row !== undefined) {
|
|
706
|
+
state.rename = true;
|
|
707
|
+
state.renameTarget = row.id;
|
|
708
|
+
state.renameBuf = rowOf(row.id)?.name ?? '';
|
|
709
|
+
}
|
|
710
|
+
scheduleFlush();
|
|
711
|
+
return;
|
|
712
|
+
}
|
|
713
|
+
// Message the node without resuming it (mutation — no-op on a remote canvas).
|
|
714
|
+
if (input === 'm') {
|
|
715
|
+
if (isRemote)
|
|
716
|
+
return;
|
|
717
|
+
if (row !== undefined) {
|
|
718
|
+
state.message = true;
|
|
719
|
+
state.messageTarget = row.id;
|
|
720
|
+
state.messageBuf = '';
|
|
721
|
+
}
|
|
722
|
+
scheduleFlush();
|
|
723
|
+
return;
|
|
724
|
+
}
|
|
572
725
|
// Search. Starting a search ranks by relevance (decision) so the best prompt/
|
|
573
726
|
// name match floats to the top as you type.
|
|
574
727
|
if (input === '/') {
|
|
@@ -664,8 +817,14 @@ export async function runBrowse(opts = {}) {
|
|
|
664
817
|
const onData = (data) => {
|
|
665
818
|
try {
|
|
666
819
|
const { input, key } = parseKeypress(data);
|
|
667
|
-
if (state.
|
|
820
|
+
if (state.help)
|
|
821
|
+
onKeyHelp(input, key);
|
|
822
|
+
else if (state.search)
|
|
668
823
|
onKeySearch(input, key);
|
|
824
|
+
else if (state.rename)
|
|
825
|
+
onKeyRename(input, key);
|
|
826
|
+
else if (state.message)
|
|
827
|
+
onKeyMessage(input, key);
|
|
669
828
|
else
|
|
670
829
|
onKeyNav(input, key);
|
|
671
830
|
}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import type { DashboardRow } from '../render.js';
|
|
2
2
|
import type { NodeStatus } from '../types.js';
|
|
3
|
-
export type Tab = 'All' | 'Live' | 'Dormant' | '
|
|
3
|
+
export type Tab = 'All' | 'Live' | 'Dormant' | 'Attention' | 'Pinned';
|
|
4
4
|
export declare const TABS: readonly Tab[];
|
|
5
5
|
/** How the visible rows are ordered.
|
|
6
6
|
* tree — spanning-tree order, ancestors shown for context (the default).
|
|
@@ -11,11 +11,12 @@ export declare const TABS: readonly Tab[];
|
|
|
11
11
|
export type SortMode = 'tree' | 'attention' | 'relevance' | 'recency';
|
|
12
12
|
export declare const SORTS: readonly SortMode[];
|
|
13
13
|
/** Does a node belong to this tab's slice?
|
|
14
|
-
* All
|
|
15
|
-
* Live
|
|
16
|
-
* Dormant
|
|
17
|
-
*
|
|
18
|
-
|
|
14
|
+
* All — every node.
|
|
15
|
+
* Live — active | idle.
|
|
16
|
+
* Dormant — done | dead | canceled.
|
|
17
|
+
* Attention — needs a human: has pending asks OR is parked on a fault.
|
|
18
|
+
* Pinned — user-starred (a local, view-only preference; `isPinned`). */
|
|
19
|
+
export declare function tabPredicate(tab: Tab, row: DashboardRow, isPinned?: boolean): boolean;
|
|
19
20
|
export interface TreeNode {
|
|
20
21
|
row: DashboardRow;
|
|
21
22
|
depth: number;
|
|
@@ -138,6 +139,9 @@ export interface FlattenOpts {
|
|
|
138
139
|
/** Ordering. `tree` keeps the spanning tree + ancestor context; `relevance` /
|
|
139
140
|
* `recency` produce a FLAT ranked list of directly-matched rows. */
|
|
140
141
|
sort?: SortMode;
|
|
142
|
+
/** User-starred node ids (local, view-only). Gates the Pinned tab, and floats
|
|
143
|
+
* pinned rows to the top of every FLAT sort mode. undefined = none pinned. */
|
|
144
|
+
pinned?: Set<string>;
|
|
141
145
|
}
|
|
142
146
|
/** Is this row inside the active cwd scope? No scope (null/undefined) = All dirs. */
|
|
143
147
|
export declare function cwdMatch(scope: string | null | undefined, row: DashboardRow): boolean;
|
|
@@ -4,19 +4,21 @@
|
|
|
4
4
|
// it is exhaustively unit-testable. The app layer (app.ts) wires the canvas data
|
|
5
5
|
// access (dashboardRowsAll / listNodes / subscriptionsOf) into buildTree, then
|
|
6
6
|
// drives flatten() on each keystroke.
|
|
7
|
-
export const TABS = ['All', 'Live', 'Dormant', '
|
|
7
|
+
export const TABS = ['All', 'Live', 'Dormant', 'Attention', 'Pinned'];
|
|
8
8
|
export const SORTS = ['tree', 'attention', 'relevance', 'recency'];
|
|
9
9
|
/** Does a node belong to this tab's slice?
|
|
10
|
-
* All
|
|
11
|
-
* Live
|
|
12
|
-
* Dormant
|
|
13
|
-
*
|
|
14
|
-
|
|
10
|
+
* All — every node.
|
|
11
|
+
* Live — active | idle.
|
|
12
|
+
* Dormant — done | dead | canceled.
|
|
13
|
+
* Attention — needs a human: has pending asks OR is parked on a fault.
|
|
14
|
+
* Pinned — user-starred (a local, view-only preference; `isPinned`). */
|
|
15
|
+
export function tabPredicate(tab, row, isPinned = false) {
|
|
15
16
|
switch (tab) {
|
|
16
17
|
case 'All': return true;
|
|
17
18
|
case 'Live': return row.status === 'active' || row.status === 'idle';
|
|
18
19
|
case 'Dormant': return row.status === 'done' || row.status === 'dead' || row.status === 'canceled';
|
|
19
|
-
case '
|
|
20
|
+
case 'Attention': return row.asks > 0 || row.hanging != null;
|
|
21
|
+
case 'Pinned': return isPinned;
|
|
20
22
|
}
|
|
21
23
|
}
|
|
22
24
|
/** Sort rank for roots/stragglers — live first (active, then idle), dormant
|
|
@@ -383,7 +385,8 @@ export function lifecycleMatch(residentsOnly, row) {
|
|
|
383
385
|
* always reachable.
|
|
384
386
|
*/
|
|
385
387
|
export function flatten(tree, opts) {
|
|
386
|
-
const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree' } = opts;
|
|
388
|
+
const { collapsed, tab, query, cwdScope, residentsOnly, sort = 'tree', pinned } = opts;
|
|
389
|
+
const isPinned = (id) => pinned?.has(id) === true;
|
|
387
390
|
// 1. Directly-matched nodes: tab predicate AND cwd scope AND lifecycle AND query.
|
|
388
391
|
// `matched` honors the residents-only filter (drives top-level rows, ancestor
|
|
389
392
|
// context, force-expand, and the flat search results below). `matchedAll` is
|
|
@@ -394,7 +397,7 @@ export function flatten(tree, opts) {
|
|
|
394
397
|
const matched = new Set();
|
|
395
398
|
const matchedAll = new Set();
|
|
396
399
|
for (const [id, node] of tree.nodes) {
|
|
397
|
-
if (tabPredicate(tab, node.row) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
|
|
400
|
+
if (tabPredicate(tab, node.row, isPinned(id)) && cwdMatch(cwdScope, node.row) && queryMatch(query, node.row)) {
|
|
398
401
|
matchedAll.add(id);
|
|
399
402
|
if (lifecycleMatch(residentsOnly, node.row))
|
|
400
403
|
matched.add(id);
|
|
@@ -407,21 +410,26 @@ export function flatten(tree, opts) {
|
|
|
407
410
|
const ids = [...matched];
|
|
408
411
|
const createdOf = (id) => tree.nodes.get(id)?.row.created ?? '';
|
|
409
412
|
const byRecency = (a, b) => createdOf(b).localeCompare(createdOf(a));
|
|
413
|
+
// Pinned rows float above everything in every flat mode — "keep this at the
|
|
414
|
+
// top of my list" is the whole point of a pin. Ordering WITHIN each pin tier
|
|
415
|
+
// still follows the active sort.
|
|
416
|
+
const pinRank = (id) => (isPinned(id) ? 0 : 1);
|
|
410
417
|
if (sort === 'attention') {
|
|
411
418
|
// Tiered (attached/streaming/live first), newest-message-first within tier.
|
|
412
419
|
const rowOf = (id) => tree.nodes.get(id).row;
|
|
413
|
-
ids.sort((a, b) => (
|
|
420
|
+
ids.sort((a, b) => (pinRank(a) - pinRank(b)) ||
|
|
421
|
+
(attentionTier(rowOf(a)) - attentionTier(rowOf(b))) ||
|
|
414
422
|
(attentionMtime(rowOf(b)) - attentionMtime(rowOf(a))) ||
|
|
415
423
|
byRecency(a, b));
|
|
416
424
|
}
|
|
417
425
|
else if (sort === 'recency' || query === '') {
|
|
418
|
-
ids.sort(byRecency);
|
|
426
|
+
ids.sort((a, b) => (pinRank(a) - pinRank(b)) || byRecency(a, b));
|
|
419
427
|
}
|
|
420
428
|
else {
|
|
421
429
|
const score = new Map();
|
|
422
430
|
for (const id of ids)
|
|
423
431
|
score.set(id, scoreRow(query, tree.nodes.get(id).row));
|
|
424
|
-
ids.sort((a, b) => (score.get(b) - score.get(a)) || byRecency(a, b));
|
|
432
|
+
ids.sort((a, b) => (pinRank(a) - pinRank(b)) || (score.get(b) - score.get(a)) || byRecency(a, b));
|
|
425
433
|
}
|
|
426
434
|
return ids.map((id) => ({ id, depth: 0, hasChildren: false, collapsed: false, matched: true }));
|
|
427
435
|
}
|
|
@@ -0,0 +1,4 @@
|
|
|
1
|
+
/** Load the pinned-id set. Missing/garbage file → empty set (never throws). */
|
|
2
|
+
export declare function loadPins(): Set<string>;
|
|
3
|
+
/** Persist the pinned-id set. Best-effort — a write failure never wedges the TUI. */
|
|
4
|
+
export declare function savePins(pins: Set<string>): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
// pins.ts — the durable per-user "pinned" set for the canvas browser.
|
|
2
|
+
//
|
|
3
|
+
// A pin is a LOCAL, view-only preference, NOT a canvas property: starring a node
|
|
4
|
+
// keeps it at the top of YOUR navigator across sessions and machines, and it works
|
|
5
|
+
// against a remote canvas too (you are pinning in your own view, mutating nothing
|
|
6
|
+
// on the canvas). Stored as a flat id list at ~/.crouter/browse-pins.json so every
|
|
7
|
+
// `canvas browse` invocation shares it regardless of cwd/scope. Read once at boot,
|
|
8
|
+
// mutated in memory, written best-effort on each toggle.
|
|
9
|
+
import { join } from 'node:path';
|
|
10
|
+
import { userScopeRoot } from '../../scope.js';
|
|
11
|
+
import { readJsonIfExists, atomicWriteJson } from '../../fs-utils.js';
|
|
12
|
+
const PINS_FILE = 'browse-pins.json';
|
|
13
|
+
function pinsPath() {
|
|
14
|
+
return join(userScopeRoot(), PINS_FILE);
|
|
15
|
+
}
|
|
16
|
+
/** Load the pinned-id set. Missing/garbage file → empty set (never throws). */
|
|
17
|
+
export function loadPins() {
|
|
18
|
+
const raw = readJsonIfExists(pinsPath());
|
|
19
|
+
return new Set(Array.isArray(raw) ? raw.filter((x) => typeof x === 'string') : []);
|
|
20
|
+
}
|
|
21
|
+
/** Persist the pinned-id set. Best-effort — a write failure never wedges the TUI. */
|
|
22
|
+
export function savePins(pins) {
|
|
23
|
+
try {
|
|
24
|
+
atomicWriteJson(pinsPath(), [...pins]);
|
|
25
|
+
}
|
|
26
|
+
catch {
|
|
27
|
+
/* best-effort: a pin that doesn't survive the session is a minor loss */
|
|
28
|
+
}
|
|
29
|
+
}
|