@edeng23/pines 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/LICENSE +202 -0
- package/README.md +37 -0
- package/dist/cli.js +83 -0
- package/dist/cli.js.map +1 -0
- package/dist/client/ansi.js +37 -0
- package/dist/client/ansi.js.map +1 -0
- package/dist/client/app.js +1868 -0
- package/dist/client/app.js.map +1 -0
- package/dist/client/boot.js +221 -0
- package/dist/client/boot.js.map +1 -0
- package/dist/client/commands.js +133 -0
- package/dist/client/commands.js.map +1 -0
- package/dist/client/daemon-client.js +99 -0
- package/dist/client/daemon-client.js.map +1 -0
- package/dist/client/forest/camera.js +88 -0
- package/dist/client/forest/camera.js.map +1 -0
- package/dist/client/forest/canvas.js +175 -0
- package/dist/client/forest/canvas.js.map +1 -0
- package/dist/client/forest/sidebar.js +182 -0
- package/dist/client/forest/sidebar.js.map +1 -0
- package/dist/client/forest/status.js +39 -0
- package/dist/client/forest/status.js.map +1 -0
- package/dist/client/forest/style.js +70 -0
- package/dist/client/forest/style.js.map +1 -0
- package/dist/client/forest/styles.js +429 -0
- package/dist/client/forest/styles.js.map +1 -0
- package/dist/client/forest/view.js +181 -0
- package/dist/client/forest/view.js.map +1 -0
- package/dist/client/forest/wordmark.js +26 -0
- package/dist/client/forest/wordmark.js.map +1 -0
- package/dist/client/input.js +89 -0
- package/dist/client/input.js.map +1 -0
- package/dist/client/theme.js +27 -0
- package/dist/client/theme.js.map +1 -0
- package/dist/client/tree/asciitree.js +277 -0
- package/dist/client/tree/asciitree.js.map +1 -0
- package/dist/client/tree/convmap.js +260 -0
- package/dist/client/tree/convmap.js.map +1 -0
- package/dist/client/tree/lanegraph.js +260 -0
- package/dist/client/tree/lanegraph.js.map +1 -0
- package/dist/client/tree/merge.js +161 -0
- package/dist/client/tree/merge.js.map +1 -0
- package/dist/client/tree/sessionview.js +163 -0
- package/dist/client/tree/sessionview.js.map +1 -0
- package/dist/client/tree/view.js +238 -0
- package/dist/client/tree/view.js.map +1 -0
- package/dist/client/uistate.js +45 -0
- package/dist/client/uistate.js.map +1 -0
- package/dist/daemon/agents.js +102 -0
- package/dist/daemon/agents.js.map +1 -0
- package/dist/daemon/branch.js +149 -0
- package/dist/daemon/branch.js.map +1 -0
- package/dist/daemon/embed-worker.js +74 -0
- package/dist/daemon/embed-worker.js.map +1 -0
- package/dist/daemon/essence.js +194 -0
- package/dist/daemon/essence.js.map +1 -0
- package/dist/daemon/extbridge.js +138 -0
- package/dist/daemon/extbridge.js.map +1 -0
- package/dist/daemon/lifecycle.js +134 -0
- package/dist/daemon/lifecycle.js.map +1 -0
- package/dist/daemon/pty-compat.js +53 -0
- package/dist/daemon/pty-compat.js.map +1 -0
- package/dist/daemon/semantic.js +337 -0
- package/dist/daemon/semantic.js.map +1 -0
- package/dist/daemon/server.js +808 -0
- package/dist/daemon/server.js.map +1 -0
- package/dist/daemon/supervisor.js +311 -0
- package/dist/daemon/supervisor.js.map +1 -0
- package/dist/extension/pines-extension.js +191 -0
- package/dist/extension/pines-extension.js.map +1 -0
- package/dist/layout/lexical.js +104 -0
- package/dist/layout/lexical.js.map +1 -0
- package/dist/layout/pca.js +91 -0
- package/dist/layout/pca.js.map +1 -0
- package/dist/layout/relax.js +47 -0
- package/dist/layout/relax.js.map +1 -0
- package/dist/session/ingest.js +95 -0
- package/dist/session/ingest.js.map +1 -0
- package/dist/session/parser.js +254 -0
- package/dist/session/parser.js.map +1 -0
- package/dist/shared/config.js +30 -0
- package/dist/shared/config.js.map +1 -0
- package/dist/shared/paths.js +47 -0
- package/dist/shared/paths.js.map +1 -0
- package/dist/shared/protocol.js +9 -0
- package/dist/shared/protocol.js.map +1 -0
- package/dist/shared/types.js +3 -0
- package/dist/shared/types.js.map +1 -0
- package/dist/shared/wire.js +51 -0
- package/dist/shared/wire.js.map +1 -0
- package/dist/store/db.js +203 -0
- package/dist/store/db.js.map +1 -0
- package/dist/store/watcher.js +69 -0
- package/dist/store/watcher.js.map +1 -0
- package/package.json +80 -0
- package/scripts/fix-node-pty.mjs +34 -0
- package/src/extension/pines-extension.ts +192 -0
- package/src/store/schema.sql +60 -0
|
@@ -0,0 +1,1868 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Interactive client: Forest ⇄ Tree ⇄ Node view stack.
|
|
3
|
+
*
|
|
4
|
+
* Forest is the main view — a zoomable/pannable canvas where each tree shows
|
|
5
|
+
* color-coded status; wheel zooms about the cursor, drag pans, click selects.
|
|
6
|
+
* Enter descends (forest → tree → attached pi); Esc ascends. Attaching to a
|
|
7
|
+
* node hands the terminal to the real pi TUI (raw passthrough) with a Ctrl+t
|
|
8
|
+
* prefix for getting back out; the agent keeps running either way.
|
|
9
|
+
*/
|
|
10
|
+
import { createRequire } from "node:module";
|
|
11
|
+
import { homedir } from "node:os";
|
|
12
|
+
import { DaemonClient } from "./daemon-client.js";
|
|
13
|
+
import { pinesHome } from "../shared/paths.js";
|
|
14
|
+
import { startBoot } from "./boot.js";
|
|
15
|
+
import { InputRouter, MOUSE_ENABLE, MOUSE_DISABLE, GUEST_INPUT_ENABLE, GUEST_INPUT_DISABLE, ALT_SCREEN_ENTER, ALT_SCREEN_LEAVE, SYNC_BEGIN, SYNC_END, } from "./input.js";
|
|
16
|
+
import { Canvas } from "./forest/canvas.js";
|
|
17
|
+
import { ensureVisible, fitCamera, worldToCell, zoomAt, clampZoom, OPEN_TREE_ZOOM, } from "./forest/camera.js";
|
|
18
|
+
import { renderForest, statusGlyph, statusSgr, SPINNER_FRAMES } from "./forest/view.js";
|
|
19
|
+
import { FOREST_STYLES, forestStyle, nextStyleId } from "./forest/styles.js";
|
|
20
|
+
import { humanAge, renderSidebar, sidebarHeaderH, sidebarOrder, sidebarRows, sidebarScrollTo, treeTitle, } from "./forest/sidebar.js";
|
|
21
|
+
import { loadUiState, saveUiState } from "./uistate.js";
|
|
22
|
+
import { decodeKittyPrintable, isKeyRelease, Key, matchesKey, parseKey, } from "@earendil-works/pi-tui";
|
|
23
|
+
import { loadConfig, prefixByte } from "../shared/config.js";
|
|
24
|
+
import { clipAnsi, visibleLength } from "./ansi.js";
|
|
25
|
+
import { FAINT, MUTED } from "./theme.js";
|
|
26
|
+
import { aContainerOf, branchHeadOf, buildConvView, cRowIndexOf, exchangeEndOf, ownerOf, } from "./tree/sessionview.js";
|
|
27
|
+
import { aggregateFamily, attentionMemberOf, familyOf, } from "./tree/merge.js";
|
|
28
|
+
import { renderConversation, convLayout, scrollTo } from "./tree/view.js";
|
|
29
|
+
const CONFIGURED_PREFIX = loadConfig().prefixKey;
|
|
30
|
+
const PREFIX_KEY = (prefixByte(CONFIGURED_PREFIX) === undefined
|
|
31
|
+
? Key.ctrl("t")
|
|
32
|
+
: CONFIGURED_PREFIX.trim().toLowerCase());
|
|
33
|
+
/**
|
|
34
|
+
* Pi enables Kitty keyboard reporting in terminals such as iTerm2. Convert
|
|
35
|
+
* those enhanced sequences back to the legacy values used by Pines' own UI.
|
|
36
|
+
*/
|
|
37
|
+
export function normalizeUiKey(key) {
|
|
38
|
+
if (isKeyRelease(key))
|
|
39
|
+
return "";
|
|
40
|
+
const printable = decodeKittyPrintable(key);
|
|
41
|
+
if (printable !== undefined)
|
|
42
|
+
return printable;
|
|
43
|
+
const parsed = parseKey(key);
|
|
44
|
+
if (parsed?.length === 1)
|
|
45
|
+
return parsed;
|
|
46
|
+
if (parsed === Key.escape || parsed === Key.esc)
|
|
47
|
+
return "\x1b";
|
|
48
|
+
if (parsed === Key.enter || parsed === Key.return)
|
|
49
|
+
return "\r";
|
|
50
|
+
if (parsed === Key.tab)
|
|
51
|
+
return "\t";
|
|
52
|
+
if (parsed === Key.backspace)
|
|
53
|
+
return "\x7f";
|
|
54
|
+
if (parsed === Key.up)
|
|
55
|
+
return "\x1b[A";
|
|
56
|
+
if (parsed === Key.down)
|
|
57
|
+
return "\x1b[B";
|
|
58
|
+
if (parsed === Key.right)
|
|
59
|
+
return "\x1b[C";
|
|
60
|
+
if (parsed === Key.left)
|
|
61
|
+
return "\x1b[D";
|
|
62
|
+
if (parsed === Key.ctrl("c"))
|
|
63
|
+
return "\x03";
|
|
64
|
+
if (parsed === Key.ctrl("x"))
|
|
65
|
+
return "\x18";
|
|
66
|
+
// Line editing in overlays: Cmd+Backspace arrives as ctrl+u (iTerm2/Ghostty
|
|
67
|
+
// "natural editing") or super+backspace (kitty protocol); Option+Backspace
|
|
68
|
+
// as alt+backspace. Collapse them onto the classic readline control bytes.
|
|
69
|
+
if (parsed === Key.ctrl("u") || parsed === Key.super("backspace"))
|
|
70
|
+
return "\x15";
|
|
71
|
+
if (parsed === Key.ctrl("w") || parsed === Key.alt("backspace"))
|
|
72
|
+
return "\x17";
|
|
73
|
+
return key;
|
|
74
|
+
}
|
|
75
|
+
export async function runApp() {
|
|
76
|
+
if (!process.stdin.isTTY || !process.stdout.isTTY) {
|
|
77
|
+
process.stderr.write("pines: interactive mode needs a TTY (use 'pines spawn/status' otherwise)\n");
|
|
78
|
+
process.exitCode = 1;
|
|
79
|
+
return;
|
|
80
|
+
}
|
|
81
|
+
const out = process.stdout;
|
|
82
|
+
const stdin = process.stdin;
|
|
83
|
+
const vp = () => ({
|
|
84
|
+
width: out.columns ?? 80,
|
|
85
|
+
height: (out.rows ?? 24) - 1, // last row = status bar
|
|
86
|
+
});
|
|
87
|
+
const attachSize = () => ({ cols: out.columns ?? 80, rows: (out.rows ?? 24) - 1 });
|
|
88
|
+
/* ---------------------------- sidebar geometry --------------------------- */
|
|
89
|
+
const ui = loadUiState();
|
|
90
|
+
const MIN_SIDEBAR = 20;
|
|
91
|
+
/** Sidebar width in cells; 0 when hidden, not in forest, or too narrow. */
|
|
92
|
+
function sidebarW() {
|
|
93
|
+
if (mode.kind !== "forest" || !ui.sidebarVisible)
|
|
94
|
+
return 0;
|
|
95
|
+
const width = out.columns ?? 80;
|
|
96
|
+
if (width < 60)
|
|
97
|
+
return 0; // not enough room for two useful panes
|
|
98
|
+
return Math.max(MIN_SIDEBAR, Math.min(ui.sidebarWidth, Math.floor(width / 2)));
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The tree view's agents panel shares the sidebar system: same width
|
|
102
|
+
* setting, same divider, same S/[/] keys. 0 = hidden or no room.
|
|
103
|
+
*/
|
|
104
|
+
function treePanelW() {
|
|
105
|
+
if (mode.kind !== "tree" || !ui.sidebarVisible)
|
|
106
|
+
return 0;
|
|
107
|
+
const width = out.columns ?? 80;
|
|
108
|
+
if (width < 60)
|
|
109
|
+
return 0;
|
|
110
|
+
return Math.max(MIN_SIDEBAR, Math.min(ui.sidebarWidth, Math.floor(width / 2)));
|
|
111
|
+
}
|
|
112
|
+
/** X cell where the forest canvas starts (after sidebar + divider). */
|
|
113
|
+
function canvasX() {
|
|
114
|
+
const w = sidebarW();
|
|
115
|
+
return w > 0 ? w + 1 : 0;
|
|
116
|
+
}
|
|
117
|
+
/** Viewport of the forest canvas (right pane). */
|
|
118
|
+
function forestVp() {
|
|
119
|
+
const v = vp();
|
|
120
|
+
return { width: v.width - canvasX(), height: v.height };
|
|
121
|
+
}
|
|
122
|
+
// The splash grows while the daemon connects — masking startup, never
|
|
123
|
+
// adding to it. finish() plays the ending with the live agent count.
|
|
124
|
+
const boot = startBoot(out, stdin);
|
|
125
|
+
const client = await DaemonClient.connect(attachSize());
|
|
126
|
+
const forest = new Map();
|
|
127
|
+
for (const t of client.helloOk.forest)
|
|
128
|
+
forest.set(t.treeId, t);
|
|
129
|
+
// Mascot header facts: our version, the bundled pi's, and where state
|
|
130
|
+
// lives — shortened to ~ like a shell prompt.
|
|
131
|
+
const brand = (() => {
|
|
132
|
+
try {
|
|
133
|
+
const req = createRequire(import.meta.url);
|
|
134
|
+
const pkg = req("../../package.json");
|
|
135
|
+
const home = pinesHome().replace(homedir(), "~");
|
|
136
|
+
return {
|
|
137
|
+
version: pkg.version,
|
|
138
|
+
// The daemon launches pi and is the one honest source for which
|
|
139
|
+
// version that is (bundled manifest, or a probed piBin override).
|
|
140
|
+
// The declared dependency is only the fallback while it probes.
|
|
141
|
+
pi: client.helloOk.piVersion ??
|
|
142
|
+
pkg.dependencies?.["@earendil-works/pi-coding-agent"] ??
|
|
143
|
+
"?",
|
|
144
|
+
home,
|
|
145
|
+
};
|
|
146
|
+
}
|
|
147
|
+
catch {
|
|
148
|
+
return undefined;
|
|
149
|
+
}
|
|
150
|
+
})();
|
|
151
|
+
let mode = { kind: "forest" };
|
|
152
|
+
let overlay = null;
|
|
153
|
+
// Session-local peek at archived trees ('.'); intentionally not persisted —
|
|
154
|
+
// every launch starts with a clean forest.
|
|
155
|
+
let showArchived = false;
|
|
156
|
+
/** Trees the forest canvas and sidebar currently show. */
|
|
157
|
+
function visibleTrees() {
|
|
158
|
+
const all = [...forest.values()];
|
|
159
|
+
return showArchived ? all : all.filter((t) => !t.archived);
|
|
160
|
+
}
|
|
161
|
+
/* --------------------- conversations (fork families) --------------------- */
|
|
162
|
+
const summaryOf = (id) => forest.get(id);
|
|
163
|
+
const childrenOf = (sessionPath) => [...forest.values()].filter((t) => t.parentSessionPath === sessionPath);
|
|
164
|
+
const parentOf = (sessionPath) => [...forest.values()].find((t) => t.sessionPath === sessionPath);
|
|
165
|
+
/** Family root id of any member (walks fork lineage up to the original). */
|
|
166
|
+
function familyRootId(treeId) {
|
|
167
|
+
return familyOf(treeId, summaryOf, childrenOf, parentOf)[0]?.treeId ?? treeId;
|
|
168
|
+
}
|
|
169
|
+
/** All family members, DFS, root first. */
|
|
170
|
+
function familyMembers(treeId) {
|
|
171
|
+
return familyOf(familyRootId(treeId), summaryOf, childrenOf, parentOf);
|
|
172
|
+
}
|
|
173
|
+
/**
|
|
174
|
+
* The forest shows CONVERSATIONS: one aggregate summary per fork family
|
|
175
|
+
* (session files are plumbing). Identity/position come from the family
|
|
176
|
+
* root; status is the family's most attention-worthy member.
|
|
177
|
+
*/
|
|
178
|
+
// Grouping walks the family lineage per tree (quadratic-ish), and callers
|
|
179
|
+
// ask several times per frame — memoize on the forest's edit version.
|
|
180
|
+
let forestVersion = 0;
|
|
181
|
+
let convCache = null;
|
|
182
|
+
function conversations() {
|
|
183
|
+
if (convCache &&
|
|
184
|
+
convCache.version === forestVersion &&
|
|
185
|
+
convCache.archived === showArchived) {
|
|
186
|
+
return convCache.out;
|
|
187
|
+
}
|
|
188
|
+
const grouped = new Map();
|
|
189
|
+
for (const t of visibleTrees()) {
|
|
190
|
+
const rootId = familyRootId(t.treeId);
|
|
191
|
+
const arr = grouped.get(rootId);
|
|
192
|
+
if (arr)
|
|
193
|
+
arr.push(t);
|
|
194
|
+
else
|
|
195
|
+
grouped.set(rootId, [t]);
|
|
196
|
+
}
|
|
197
|
+
const out = [];
|
|
198
|
+
for (const rootId of grouped.keys()) {
|
|
199
|
+
const members = familyMembers(rootId).filter((t) => showArchived || !t.archived);
|
|
200
|
+
if (members.length === 0)
|
|
201
|
+
continue;
|
|
202
|
+
out.push(aggregateFamily(members));
|
|
203
|
+
}
|
|
204
|
+
convCache = { version: forestVersion, archived: showArchived, out };
|
|
205
|
+
return out;
|
|
206
|
+
}
|
|
207
|
+
/** Attach targeting a conversation: pick the member that needs you most. */
|
|
208
|
+
function attachConversation(treeId) {
|
|
209
|
+
const members = familyMembers(treeId);
|
|
210
|
+
void attach(members.length > 1 ? attentionMemberOf(members).treeId : treeId);
|
|
211
|
+
}
|
|
212
|
+
let camera = fitCamera(conversations(), forestVp());
|
|
213
|
+
let sidebarScroll = 0;
|
|
214
|
+
let sidebarLineToTree = [];
|
|
215
|
+
let dividerDrag = false;
|
|
216
|
+
// Start with the most attention-worthy tree selected so ↵/a/x work
|
|
217
|
+
// immediately; tab continues from it.
|
|
218
|
+
let selectedId = listOrder()[0] ?? null;
|
|
219
|
+
let spinnerFrame = 0;
|
|
220
|
+
let prefixArmed = false;
|
|
221
|
+
let toast = null;
|
|
222
|
+
let dragging = null;
|
|
223
|
+
let lastClick = null;
|
|
224
|
+
let creatingTree = false;
|
|
225
|
+
let pickMap = new Map();
|
|
226
|
+
/* ------------------------------- rendering ------------------------------- */
|
|
227
|
+
let renderQueued = false;
|
|
228
|
+
function requestRender() {
|
|
229
|
+
if (renderQueued || mode.kind === "attached")
|
|
230
|
+
return;
|
|
231
|
+
renderQueued = true;
|
|
232
|
+
setImmediate(() => {
|
|
233
|
+
renderQueued = false;
|
|
234
|
+
draw();
|
|
235
|
+
});
|
|
236
|
+
}
|
|
237
|
+
function statusCounts() {
|
|
238
|
+
let running = 0, waiting = 0, unseen = 0, crashed = 0, archived = 0;
|
|
239
|
+
for (const t of forest.values()) {
|
|
240
|
+
if (t.archived) {
|
|
241
|
+
archived++;
|
|
242
|
+
continue;
|
|
243
|
+
}
|
|
244
|
+
if (t.status === "running")
|
|
245
|
+
running++;
|
|
246
|
+
if (t.status === "waiting") {
|
|
247
|
+
waiting++;
|
|
248
|
+
if (!t.seen)
|
|
249
|
+
unseen++;
|
|
250
|
+
}
|
|
251
|
+
if (t.status === "crashed")
|
|
252
|
+
crashed++;
|
|
253
|
+
}
|
|
254
|
+
const n = conversations().length;
|
|
255
|
+
const parts = [`${n} ${n === 1 ? "tree" : "trees"}`];
|
|
256
|
+
if (running)
|
|
257
|
+
parts.push(`\x1b[33m◐${running}\x1b[0m`);
|
|
258
|
+
// Bold when any of the waiting agents is one you haven't seen yet.
|
|
259
|
+
if (waiting)
|
|
260
|
+
parts.push(`\x1b[${unseen > 0 ? "1;" : ""}38;5;44m●${waiting}\x1b[0m`);
|
|
261
|
+
if (crashed)
|
|
262
|
+
parts.push(`\x1b[31m●${crashed}\x1b[0m`);
|
|
263
|
+
if (archived)
|
|
264
|
+
parts.push(`\x1b[${MUTED}m${archived} archived\x1b[0m`);
|
|
265
|
+
return parts.join(" · ");
|
|
266
|
+
}
|
|
267
|
+
function statusBar(hints) {
|
|
268
|
+
const now = Date.now();
|
|
269
|
+
const toastPart = toast && toast.until > now ? ` \x1b[7m ${toast.text} \x1b[0m` : "";
|
|
270
|
+
let left = ` \x1b[1mpines\x1b[0m ${breadcrumb()} · ${statusCounts()}${toastPart}`;
|
|
271
|
+
const strip = (s) => s.replace(/\x1b\[[0-9;]*m/g, "");
|
|
272
|
+
// Clickable zoom-to-fit button (forest only). Cell range is remembered
|
|
273
|
+
// for the mouse handler; the '0' key does the same thing.
|
|
274
|
+
fitButton = null;
|
|
275
|
+
if (mode.kind === "forest" && !overlay) {
|
|
276
|
+
const x0 = strip(left).length + 1;
|
|
277
|
+
const label = "[⛶ fit]";
|
|
278
|
+
fitButton = { x0, x1: x0 + label.length - 1 };
|
|
279
|
+
left += ` \x1b[36m${label}\x1b[0m`;
|
|
280
|
+
}
|
|
281
|
+
const plainLen = strip(left).length;
|
|
282
|
+
const width = out.columns ?? 80;
|
|
283
|
+
// Never exceed the row: wrapped status bars push the whole frame around.
|
|
284
|
+
let h = hints;
|
|
285
|
+
const avail = width - 1 - plainLen - 1;
|
|
286
|
+
if (h.length > avail)
|
|
287
|
+
h = avail > 1 ? h.slice(0, avail - 1) + "…" : "";
|
|
288
|
+
const pad = Math.max(1, width - plainLen - h.length - 1);
|
|
289
|
+
// A long toast can make the left side alone overflow — clip the whole
|
|
290
|
+
// line as the last line of defense (and drop the button if it got cut).
|
|
291
|
+
const line = clipAnsi(`${left}${" ".repeat(pad)}\x1b[${MUTED}m${h}\x1b[0m`, width - 1);
|
|
292
|
+
if (fitButton && fitButton.x1 >= width - 1)
|
|
293
|
+
fitButton = null;
|
|
294
|
+
return line;
|
|
295
|
+
}
|
|
296
|
+
function breadcrumb() {
|
|
297
|
+
if (mode.kind === "forest")
|
|
298
|
+
return "forest";
|
|
299
|
+
if (mode.kind === "tree") {
|
|
300
|
+
const t = forest.get(mode.rootId);
|
|
301
|
+
return `forest ▸ ${t ? treeTitle(t).title : mode.rootId}`;
|
|
302
|
+
}
|
|
303
|
+
return `forest ▸ ${attachedName(mode.treeId)} ▸ pi`;
|
|
304
|
+
}
|
|
305
|
+
/**
|
|
306
|
+
* "conversation ▸ branch" for an attached member session — the member
|
|
307
|
+
* file's own title is plumbing; the user thinks in conversations and
|
|
308
|
+
* branch heads.
|
|
309
|
+
*/
|
|
310
|
+
function attachedName(memberId) {
|
|
311
|
+
const t = forest.get(memberId);
|
|
312
|
+
const rootId = familyRootId(memberId);
|
|
313
|
+
const conv = forest.get(rootId) ?? t;
|
|
314
|
+
const title = conv ? treeTitle(conv).title : memberId;
|
|
315
|
+
if (t && t.treeId !== rootId) {
|
|
316
|
+
const detail = sessionDetails.get(t.treeId);
|
|
317
|
+
const head = branchHeadOf({ summary: t, detail });
|
|
318
|
+
const excerpt = head ? detail?.nodes[head]?.excerpt : null;
|
|
319
|
+
if (excerpt)
|
|
320
|
+
return `${title} ▸ ${truncateStr(excerpt, 24)}`;
|
|
321
|
+
}
|
|
322
|
+
return title;
|
|
323
|
+
}
|
|
324
|
+
let lastCanvas = null;
|
|
325
|
+
/** Cell range of the [⛶ fit] button on the status row (forest mode only). */
|
|
326
|
+
let fitButton = null;
|
|
327
|
+
function draw() {
|
|
328
|
+
if (mode.kind === "attached")
|
|
329
|
+
return;
|
|
330
|
+
const view = vp();
|
|
331
|
+
let body;
|
|
332
|
+
let hints;
|
|
333
|
+
if (mode.kind === "forest") {
|
|
334
|
+
const cvp = forestVp();
|
|
335
|
+
const canvas = new Canvas(cvp.width, cvp.height);
|
|
336
|
+
pickMap = renderForest(canvas, {
|
|
337
|
+
trees: conversations(),
|
|
338
|
+
camera,
|
|
339
|
+
vp: cvp,
|
|
340
|
+
selectedId,
|
|
341
|
+
spinnerFrame,
|
|
342
|
+
style: forestStyle(ui.forestStyle),
|
|
343
|
+
now: Date.now(),
|
|
344
|
+
});
|
|
345
|
+
lastCanvas = canvas; // retained for mouse hit-testing
|
|
346
|
+
body = canvas.render();
|
|
347
|
+
const sw = sidebarW();
|
|
348
|
+
if (sw > 0) {
|
|
349
|
+
const convs = conversations();
|
|
350
|
+
const rows = sidebarRows(convs);
|
|
351
|
+
const headerH = sidebarHeaderH({ brand, width: sw, height: view.height });
|
|
352
|
+
sidebarScroll = sidebarScrollTo(rows, selectedId, sidebarScroll, view.height - headerH);
|
|
353
|
+
const sb = renderSidebar({
|
|
354
|
+
trees: new Map(convs.map((t) => [t.treeId, t])),
|
|
355
|
+
rows,
|
|
356
|
+
selectedId,
|
|
357
|
+
width: sw,
|
|
358
|
+
height: view.height,
|
|
359
|
+
scroll: sidebarScroll,
|
|
360
|
+
spinnerFrame,
|
|
361
|
+
now: Date.now(),
|
|
362
|
+
brand,
|
|
363
|
+
});
|
|
364
|
+
sidebarLineToTree = sb.lineToTree;
|
|
365
|
+
body = body.map((row, i) => `${sb.lines[i]}\x1b[${FAINT}m│\x1b[0m${row}`);
|
|
366
|
+
}
|
|
367
|
+
else {
|
|
368
|
+
sidebarLineToTree = [];
|
|
369
|
+
}
|
|
370
|
+
// Ordered by importance — narrow terminals truncate from the right.
|
|
371
|
+
hints =
|
|
372
|
+
"→ open · ↵ attach · / search · s similar · n new · v style · ± zoom · S sidebar · q quit ";
|
|
373
|
+
}
|
|
374
|
+
else {
|
|
375
|
+
body = renderTreeBody(view);
|
|
376
|
+
// The selected row explains what ⏎ does there; keep the bar terse and
|
|
377
|
+
// front-load what narrow terminals would otherwise cut off.
|
|
378
|
+
hints = "⏎ attach/branch · ← forest · b branch · / search · L label · 1-9 branch · ↑↓ move ";
|
|
379
|
+
}
|
|
380
|
+
if (overlay) {
|
|
381
|
+
composeOverlay(body, view);
|
|
382
|
+
hints =
|
|
383
|
+
overlay.kind === "search"
|
|
384
|
+
? "type to search · ↑/↓ select · ↵ jump · esc close "
|
|
385
|
+
: "↑/↓ select · ↵ choose · esc close ";
|
|
386
|
+
}
|
|
387
|
+
const frame = SYNC_BEGIN +
|
|
388
|
+
"\x1b[H" +
|
|
389
|
+
body.map((row) => row + "\x1b[K").join("\r\n") +
|
|
390
|
+
"\r\n" +
|
|
391
|
+
statusBar(hints) +
|
|
392
|
+
"\x1b[K" +
|
|
393
|
+
SYNC_END;
|
|
394
|
+
out.write(frame);
|
|
395
|
+
}
|
|
396
|
+
function composeOverlay(body, view) {
|
|
397
|
+
if (!overlay)
|
|
398
|
+
return;
|
|
399
|
+
const boxW = Math.min(74, view.width - 4);
|
|
400
|
+
const margin = Math.floor((view.width - boxW) / 2);
|
|
401
|
+
const pad = " ".repeat(margin);
|
|
402
|
+
const inner = boxW - 2;
|
|
403
|
+
const lines = [];
|
|
404
|
+
const bar = (s) => {
|
|
405
|
+
const clipped = clipAnsi(s, inner);
|
|
406
|
+
const fill = Math.max(0, inner - visibleLength(clipped));
|
|
407
|
+
return `${pad}\x1b[0m│${clipped}\x1b[0m${" ".repeat(fill)}│`;
|
|
408
|
+
};
|
|
409
|
+
lines.push(`${pad}\x1b[0m╭${"─".repeat(inner)}╮`);
|
|
410
|
+
if (overlay.kind === "search") {
|
|
411
|
+
lines.push(bar(` \x1b[1m/\x1b[0m ${overlay.query}\x1b[7m \x1b[0m`));
|
|
412
|
+
lines.push(`${pad}├${"─".repeat(inner)}┤`);
|
|
413
|
+
const hits = overlay.hits.slice(0, 12);
|
|
414
|
+
if (hits.length === 0) {
|
|
415
|
+
lines.push(bar(overlay.query ? ` \x1b[${MUTED}mno matches\x1b[0m` : ` \x1b[${MUTED}mtype to search all conversations\x1b[0m`));
|
|
416
|
+
}
|
|
417
|
+
const selected = overlay.selected;
|
|
418
|
+
hits.forEach((h, i) => {
|
|
419
|
+
const t = forest.get(familyRootId(h.treeId));
|
|
420
|
+
const name = truncateStr(t ? treeTitle(t).title : h.treeId, 16).padEnd(16);
|
|
421
|
+
const cursor = i === selected ? "\x1b[7m" : "";
|
|
422
|
+
const kind = h.kind === "user_msg" ? "▸" : h.kind === "summary" ? "⑂" : h.kind === "label" ? "◆" : "≡";
|
|
423
|
+
lines.push(bar(` ${cursor}${kind} \x1b[36m${name}\x1b[0m${cursor} ${truncateStr(h.snippet, inner - 24)}`));
|
|
424
|
+
});
|
|
425
|
+
}
|
|
426
|
+
else if (overlay.kind === "input") {
|
|
427
|
+
lines.push(bar(` \x1b[1m${overlay.title}\x1b[0m`));
|
|
428
|
+
lines.push(`${pad}├${"─".repeat(inner)}┤`);
|
|
429
|
+
const shown = overlay.value
|
|
430
|
+
? `${overlay.value}\x1b[7m \x1b[0m`
|
|
431
|
+
: `\x1b[7m \x1b[0m\x1b[${MUTED}m${overlay.placeholder}\x1b[0m`;
|
|
432
|
+
lines.push(bar(` ${shown}`));
|
|
433
|
+
}
|
|
434
|
+
else {
|
|
435
|
+
lines.push(bar(` \x1b[1m${overlay.title}\x1b[0m`));
|
|
436
|
+
lines.push(`${pad}├${"─".repeat(inner)}┤`);
|
|
437
|
+
overlay.options.forEach((opt, i) => {
|
|
438
|
+
const cursor = i === overlay.selected ? "\x1b[7m" : "";
|
|
439
|
+
lines.push(bar(` ${cursor} ${opt} \x1b[0m`));
|
|
440
|
+
});
|
|
441
|
+
}
|
|
442
|
+
lines.push(`${pad}\x1b[0m╰${"─".repeat(inner)}╯`);
|
|
443
|
+
const top = Math.max(1, Math.floor((view.height - lines.length) / 3));
|
|
444
|
+
for (let i = 0; i < lines.length && top + i < body.length; i++) {
|
|
445
|
+
body[top + i] = lines[i];
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
function truncateStr(s, n) {
|
|
449
|
+
return s.length <= n ? s : s.slice(0, Math.max(0, n - 1)) + "…";
|
|
450
|
+
}
|
|
451
|
+
/** Readline-style backward word delete (Ctrl+W / Option+Backspace). */
|
|
452
|
+
function deleteWordBack(s) {
|
|
453
|
+
return s.trimEnd().replace(/\S+$/, "");
|
|
454
|
+
}
|
|
455
|
+
function renderTreeBody(view) {
|
|
456
|
+
if (mode.kind !== "tree")
|
|
457
|
+
return [];
|
|
458
|
+
const lay = convLayout(mode.view, view.width, view.height, treePanelW());
|
|
459
|
+
mode.scroll = scrollTo(mode.selected, mode.scroll, lay.textH);
|
|
460
|
+
const { lines, miniHits } = renderConversation(mode.view, {
|
|
461
|
+
selected: mode.selected,
|
|
462
|
+
scroll: mode.scroll,
|
|
463
|
+
height: view.height,
|
|
464
|
+
width: view.width,
|
|
465
|
+
panelW: treePanelW(),
|
|
466
|
+
spinnerFrame,
|
|
467
|
+
now: Date.now(),
|
|
468
|
+
});
|
|
469
|
+
lastMiniHits = miniHits;
|
|
470
|
+
return lines;
|
|
471
|
+
}
|
|
472
|
+
/** Mini-map cell → tip number, captured at render for click-to-jump. */
|
|
473
|
+
let lastMiniHits;
|
|
474
|
+
/** Session detail cache shared by the session view + forest topo. */
|
|
475
|
+
const sessionDetails = new Map();
|
|
476
|
+
const detailInFlight = new Set();
|
|
477
|
+
function fetchDetail(treeId, force = false) {
|
|
478
|
+
if ((!force && sessionDetails.has(treeId)) || detailInFlight.has(treeId))
|
|
479
|
+
return;
|
|
480
|
+
detailInFlight.add(treeId);
|
|
481
|
+
void client
|
|
482
|
+
.request({ t: "get_tree", id: client.rid(), treeId })
|
|
483
|
+
.then((res) => {
|
|
484
|
+
if (res.ok && res.tree) {
|
|
485
|
+
sessionDetails.set(treeId, res.tree);
|
|
486
|
+
rebuildConv();
|
|
487
|
+
requestRender();
|
|
488
|
+
}
|
|
489
|
+
})
|
|
490
|
+
.catch(() => { }) // transient; re-requested next rebuild if still missing
|
|
491
|
+
.finally(() => detailInFlight.delete(treeId));
|
|
492
|
+
}
|
|
493
|
+
/** Rebuild the conversation view in place, keeping the cursor put. */
|
|
494
|
+
function rebuildConv(focus) {
|
|
495
|
+
if (mode.kind !== "tree")
|
|
496
|
+
return;
|
|
497
|
+
// No explicit focus: stay on the row under the cursor (live updates
|
|
498
|
+
// rebuild the view constantly — the cursor must not jump around).
|
|
499
|
+
if (!focus) {
|
|
500
|
+
const cur = mode.view.rows[mode.selected]?.a;
|
|
501
|
+
if (cur)
|
|
502
|
+
focus = { nodeId: cur.nodeId ?? cur.runId ?? cur.foldId ?? undefined };
|
|
503
|
+
}
|
|
504
|
+
mode.view = buildConvView({
|
|
505
|
+
rootTreeId: mode.rootId,
|
|
506
|
+
detailOf: (id) => sessionDetails.get(id),
|
|
507
|
+
summaryOf,
|
|
508
|
+
childrenOf,
|
|
509
|
+
parentOf,
|
|
510
|
+
expandedRuns: mode.expandedRuns,
|
|
511
|
+
unfolded: mode.unfolded,
|
|
512
|
+
viewportH: vp().height,
|
|
513
|
+
wantDetail: fetchDetail,
|
|
514
|
+
});
|
|
515
|
+
let idx = -1;
|
|
516
|
+
if (focus?.nodeId && !focus.toLeaf) {
|
|
517
|
+
const id = focus.nodeId;
|
|
518
|
+
idx = mode.view.rows.findIndex((r) => r.a.nodeId === id || r.a.runId === id || r.a.foldId === id);
|
|
519
|
+
}
|
|
520
|
+
if (idx < 0)
|
|
521
|
+
idx = cRowIndexOf(mode.view, mode.view.leafId);
|
|
522
|
+
mode.selected = idx >= 0 ? idx : Math.max(0, mode.view.rows.length - 1);
|
|
523
|
+
}
|
|
524
|
+
function showToast(text) {
|
|
525
|
+
toast = { text, until: Date.now() + 3000 };
|
|
526
|
+
requestRender();
|
|
527
|
+
setTimeout(() => requestRender(), 3100);
|
|
528
|
+
}
|
|
529
|
+
/* ----------------------------- data fetching ----------------------------- */
|
|
530
|
+
/**
|
|
531
|
+
* Open the CONVERSATION any member belongs to — one tree per family,
|
|
532
|
+
* always. Details of other members stream in as they load.
|
|
533
|
+
*/
|
|
534
|
+
async function openTree(treeId, selectNodeId) {
|
|
535
|
+
const rootId = familyRootId(treeId);
|
|
536
|
+
const res = await client.request({ t: "get_tree", id: client.rid(), treeId: rootId });
|
|
537
|
+
if (!res.ok || !res.tree) {
|
|
538
|
+
showToast(`open failed: ${res.err ?? "no tree"}`);
|
|
539
|
+
return;
|
|
540
|
+
}
|
|
541
|
+
sessionDetails.set(rootId, res.tree);
|
|
542
|
+
const treeMode = {
|
|
543
|
+
kind: "tree",
|
|
544
|
+
rootId,
|
|
545
|
+
view: {
|
|
546
|
+
rows: [],
|
|
547
|
+
leafId: null,
|
|
548
|
+
tips: [],
|
|
549
|
+
atree: { rows: [], leafId: null, folded: false },
|
|
550
|
+
detail: { treeId: rootId, leafId: null, rootIds: [], nodes: {} },
|
|
551
|
+
skel: [],
|
|
552
|
+
parkedAt: new Map(),
|
|
553
|
+
parkedCount: 0,
|
|
554
|
+
memberOrder: [],
|
|
555
|
+
pendingMembers: 0,
|
|
556
|
+
},
|
|
557
|
+
expandedRuns: new Set(),
|
|
558
|
+
unfolded: new Set(),
|
|
559
|
+
selected: 0,
|
|
560
|
+
scroll: 0,
|
|
561
|
+
};
|
|
562
|
+
mode = treeMode;
|
|
563
|
+
rebuildConv();
|
|
564
|
+
// A search jump may target a node hidden in a run or folded branch.
|
|
565
|
+
if (selectNodeId) {
|
|
566
|
+
for (let guard = 0; guard < 4 && cRowIndexOf(treeMode.view, selectNodeId) < 0; guard++) {
|
|
567
|
+
const box = aContainerOf(treeMode.view.atree, selectNodeId);
|
|
568
|
+
if (!box)
|
|
569
|
+
break;
|
|
570
|
+
(box.kind === "run" ? treeMode.expandedRuns : treeMode.unfolded).add(box.id);
|
|
571
|
+
rebuildConv();
|
|
572
|
+
}
|
|
573
|
+
rebuildConv({ nodeId: selectNodeId });
|
|
574
|
+
}
|
|
575
|
+
// Ack only the root: branches keep their needs-input state until you
|
|
576
|
+
// actually attach to them — opening the tree is looking, not answering.
|
|
577
|
+
client.send({ t: "ack_seen", treeId: rootId });
|
|
578
|
+
requestRender();
|
|
579
|
+
}
|
|
580
|
+
/* -------------------------------- overlays ------------------------------- */
|
|
581
|
+
function openSearch() {
|
|
582
|
+
overlay = { kind: "search", query: "", hits: [], selected: 0, timer: null };
|
|
583
|
+
requestRender();
|
|
584
|
+
}
|
|
585
|
+
/** Quartile cue for a cosine score (practical range ~0.25–1). */
|
|
586
|
+
function scoreBar(score) {
|
|
587
|
+
const filled = score >= 0.8 ? 4 : score >= 0.6 ? 3 : score >= 0.45 ? 2 : 1;
|
|
588
|
+
return "▰".repeat(filled) + "▱".repeat(4 - filled);
|
|
589
|
+
}
|
|
590
|
+
async function openSimilar() {
|
|
591
|
+
if (!selectedId)
|
|
592
|
+
return;
|
|
593
|
+
const treeId = selectedId;
|
|
594
|
+
const anchor = forest.get(treeId);
|
|
595
|
+
const res = await client.request({ t: "similar", id: client.rid(), treeId });
|
|
596
|
+
if (!res.ok) {
|
|
597
|
+
showToast(`similar failed: ${res.err ?? "unknown error"}`);
|
|
598
|
+
return;
|
|
599
|
+
}
|
|
600
|
+
// Hits are session files; the user thinks in conversations. Fold each
|
|
601
|
+
// hit to its family root, keep the best score per family, and drop the
|
|
602
|
+
// anchor's own family — a fork of the same conversation is not a find.
|
|
603
|
+
const seen = new Set([familyRootId(treeId)]);
|
|
604
|
+
const hits = [];
|
|
605
|
+
for (const h of res.similar ?? []) {
|
|
606
|
+
const rootId = familyRootId(h.treeId);
|
|
607
|
+
if (seen.has(rootId))
|
|
608
|
+
continue;
|
|
609
|
+
seen.add(rootId);
|
|
610
|
+
hits.push({ rootId, score: h.score });
|
|
611
|
+
}
|
|
612
|
+
if (hits.length === 0) {
|
|
613
|
+
showToast("no similar conversations yet (semantic layout may still be warming)");
|
|
614
|
+
return;
|
|
615
|
+
}
|
|
616
|
+
const options = hits.map((h) => {
|
|
617
|
+
const t = forest.get(h.rootId);
|
|
618
|
+
const name = truncateStr(t ? treeTitle(t).title : h.rootId, 30).padEnd(30);
|
|
619
|
+
const cwd = t?.cwd ? t.cwd.split("/").pop() ?? "" : "";
|
|
620
|
+
return `${scoreBar(h.score)} \x1b[36m${name}\x1b[0m \x1b[${MUTED}m${cwd}\x1b[0m`;
|
|
621
|
+
});
|
|
622
|
+
overlay = {
|
|
623
|
+
kind: "menu",
|
|
624
|
+
title: `similar to ${anchor ? treeTitle(anchor).title : treeId}`,
|
|
625
|
+
options,
|
|
626
|
+
selected: 0,
|
|
627
|
+
onPick: (i) => {
|
|
628
|
+
const hit = hits[i];
|
|
629
|
+
if (!hit)
|
|
630
|
+
return;
|
|
631
|
+
const t = forest.get(hit.rootId);
|
|
632
|
+
if (t) {
|
|
633
|
+
selectedId = t.treeId;
|
|
634
|
+
camera = { ...camera, cx: t.x, cy: t.y };
|
|
635
|
+
}
|
|
636
|
+
requestRender();
|
|
637
|
+
},
|
|
638
|
+
};
|
|
639
|
+
requestRender();
|
|
640
|
+
}
|
|
641
|
+
function runSearch() {
|
|
642
|
+
if (overlay?.kind !== "search")
|
|
643
|
+
return;
|
|
644
|
+
const q = overlay.query;
|
|
645
|
+
if (!q.trim()) {
|
|
646
|
+
overlay.hits = [];
|
|
647
|
+
requestRender();
|
|
648
|
+
return;
|
|
649
|
+
}
|
|
650
|
+
void client
|
|
651
|
+
.request({ t: "search", id: client.rid(), query: q })
|
|
652
|
+
.then((res) => {
|
|
653
|
+
if (overlay?.kind !== "search" || overlay.query !== q)
|
|
654
|
+
return;
|
|
655
|
+
overlay.hits = res.ok ? (res.hits ?? []) : [];
|
|
656
|
+
overlay.selected = 0;
|
|
657
|
+
requestRender();
|
|
658
|
+
})
|
|
659
|
+
.catch(() => { });
|
|
660
|
+
}
|
|
661
|
+
function onOverlayKey(key) {
|
|
662
|
+
if (!overlay)
|
|
663
|
+
return;
|
|
664
|
+
if (key === "\x1b" || key === "\x03") {
|
|
665
|
+
overlay = null;
|
|
666
|
+
requestRender();
|
|
667
|
+
return;
|
|
668
|
+
}
|
|
669
|
+
if (overlay.kind === "input") {
|
|
670
|
+
if (key === "\r") {
|
|
671
|
+
const submit = overlay.onSubmit;
|
|
672
|
+
const value = overlay.value.trim();
|
|
673
|
+
overlay = null;
|
|
674
|
+
submit(value);
|
|
675
|
+
requestRender();
|
|
676
|
+
return;
|
|
677
|
+
}
|
|
678
|
+
if (key === "\x7f" || key === "\b") {
|
|
679
|
+
overlay.value = overlay.value.slice(0, -1);
|
|
680
|
+
}
|
|
681
|
+
else if (key === "\x15") {
|
|
682
|
+
overlay.value = "";
|
|
683
|
+
}
|
|
684
|
+
else if (key === "\x17") {
|
|
685
|
+
overlay.value = deleteWordBack(overlay.value);
|
|
686
|
+
}
|
|
687
|
+
else if (!key.startsWith("\x1b")) {
|
|
688
|
+
const printable = [...key].filter((ch) => ch >= " " && ch !== "\x7f").join("");
|
|
689
|
+
overlay.value += printable;
|
|
690
|
+
}
|
|
691
|
+
requestRender();
|
|
692
|
+
return;
|
|
693
|
+
}
|
|
694
|
+
if (overlay.kind === "menu") {
|
|
695
|
+
if (key === "j" || key === "\x1b[B")
|
|
696
|
+
overlay.selected = Math.min(overlay.options.length - 1, overlay.selected + 1);
|
|
697
|
+
else if (key === "k" || key === "\x1b[A")
|
|
698
|
+
overlay.selected = Math.max(0, overlay.selected - 1);
|
|
699
|
+
else if (key === "\r") {
|
|
700
|
+
const pick = overlay.onPick;
|
|
701
|
+
const idx = overlay.selected;
|
|
702
|
+
overlay = null;
|
|
703
|
+
pick(idx);
|
|
704
|
+
return;
|
|
705
|
+
}
|
|
706
|
+
else if (/^[1-9]$/.test(key)) {
|
|
707
|
+
const idx = Number(key) - 1;
|
|
708
|
+
if (idx < overlay.options.length) {
|
|
709
|
+
const pick = overlay.onPick;
|
|
710
|
+
overlay = null;
|
|
711
|
+
pick(idx);
|
|
712
|
+
return;
|
|
713
|
+
}
|
|
714
|
+
}
|
|
715
|
+
requestRender();
|
|
716
|
+
return;
|
|
717
|
+
}
|
|
718
|
+
// Search overlay.
|
|
719
|
+
if (key === "\x1b[B")
|
|
720
|
+
overlay.selected = Math.min(Math.max(overlay.hits.length - 1, 0), overlay.selected + 1);
|
|
721
|
+
else if (key === "\x1b[A")
|
|
722
|
+
overlay.selected = Math.max(0, overlay.selected - 1);
|
|
723
|
+
else if (key === "\r") {
|
|
724
|
+
const hit = overlay.hits[overlay.selected];
|
|
725
|
+
overlay = null;
|
|
726
|
+
if (hit) {
|
|
727
|
+
const t = forest.get(hit.treeId);
|
|
728
|
+
if (t) {
|
|
729
|
+
selectedId = t.treeId;
|
|
730
|
+
camera = { ...camera, cx: t.x, cy: t.y };
|
|
731
|
+
}
|
|
732
|
+
void openTree(hit.treeId, hit.entryId);
|
|
733
|
+
}
|
|
734
|
+
requestRender();
|
|
735
|
+
return;
|
|
736
|
+
}
|
|
737
|
+
else if (key === "\x7f" || key === "\b") {
|
|
738
|
+
overlay.query = overlay.query.slice(0, -1);
|
|
739
|
+
scheduleSearch();
|
|
740
|
+
}
|
|
741
|
+
else if (key === "\x15") {
|
|
742
|
+
overlay.query = "";
|
|
743
|
+
scheduleSearch();
|
|
744
|
+
}
|
|
745
|
+
else if (key === "\x17") {
|
|
746
|
+
overlay.query = deleteWordBack(overlay.query);
|
|
747
|
+
scheduleSearch();
|
|
748
|
+
}
|
|
749
|
+
else if (!key.startsWith("\x1b")) {
|
|
750
|
+
const printable = [...key].filter((ch) => ch >= " " && ch !== "\x7f").join("");
|
|
751
|
+
if (printable) {
|
|
752
|
+
overlay.query += printable;
|
|
753
|
+
scheduleSearch();
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
requestRender();
|
|
757
|
+
}
|
|
758
|
+
function scheduleSearch() {
|
|
759
|
+
if (overlay?.kind !== "search")
|
|
760
|
+
return;
|
|
761
|
+
if (overlay.timer)
|
|
762
|
+
clearTimeout(overlay.timer);
|
|
763
|
+
overlay.timer = setTimeout(runSearch, 150);
|
|
764
|
+
}
|
|
765
|
+
/** Switch the forest's look, persist it, and say which one this is. */
|
|
766
|
+
function setForestStyle(id) {
|
|
767
|
+
ui.forestStyle = id;
|
|
768
|
+
saveUiState(ui);
|
|
769
|
+
const style = forestStyle(id);
|
|
770
|
+
showToast(`${style.name} — ${style.blurb}`);
|
|
771
|
+
// Styles paint different amounts of the canvas; clear so the last look
|
|
772
|
+
// leaves nothing of itself behind.
|
|
773
|
+
out.write("\x1b[2J");
|
|
774
|
+
requestRender();
|
|
775
|
+
}
|
|
776
|
+
function openStylePicker() {
|
|
777
|
+
const current = FOREST_STYLES.findIndex((s) => s.id === ui.forestStyle);
|
|
778
|
+
overlay = {
|
|
779
|
+
kind: "menu",
|
|
780
|
+
title: "forest look",
|
|
781
|
+
options: FOREST_STYLES.map((s, i) => `${i + 1} ${s.id === ui.forestStyle ? "▸" : " "} ${s.name.padEnd(14)}${s.blurb}`),
|
|
782
|
+
selected: current < 0 ? 0 : current,
|
|
783
|
+
onPick: (index) => {
|
|
784
|
+
const style = FOREST_STYLES[index];
|
|
785
|
+
if (style)
|
|
786
|
+
setForestStyle(style.id);
|
|
787
|
+
else
|
|
788
|
+
requestRender();
|
|
789
|
+
},
|
|
790
|
+
};
|
|
791
|
+
requestRender();
|
|
792
|
+
}
|
|
793
|
+
function openHelp() {
|
|
794
|
+
const prefixName = PREFIX_KEY;
|
|
795
|
+
overlay = {
|
|
796
|
+
kind: "menu",
|
|
797
|
+
title: "pines keys",
|
|
798
|
+
options: [
|
|
799
|
+
"arrows ↑/↓ select · → go deeper (forest→tree→pi) · ← go back up",
|
|
800
|
+
"sidebar S=toggle [/]=width drag divider=resize ↵/dbl-click=attach",
|
|
801
|
+
"forest wheel/±=zoom drag/hjkl=pan click/tab=select ↵=attach",
|
|
802
|
+
"forest a=attach n=new tree + attach x=kill agent R=relayout",
|
|
803
|
+
"forest o=jump to attention 0=fit all L=rename tree",
|
|
804
|
+
"forest A/ctrl+x=archive/unarchive tree .=show/hide archived",
|
|
805
|
+
"forest v=next look V=pick a look (canopy/classic/stars/cards/…)",
|
|
806
|
+
"forest s=similar conversations (semantic neighbors of the selection)",
|
|
807
|
+
"tree j/k=move ↵/→ = attach at a ● tip (an agent lives there), or grow",
|
|
808
|
+
"tree a new branch+agent from any other message 1-9=go to branch b L",
|
|
809
|
+
`pi view ${prefixName} ←/d=tree ${prefixName} f=forest ${prefixName} n=next attention`,
|
|
810
|
+
`pi view ${prefixName} ${prefixName}=send ${prefixName} to pi itself`,
|
|
811
|
+
"search / from forest or tree · ↑/↓ select · ↵ jump",
|
|
812
|
+
],
|
|
813
|
+
selected: 0,
|
|
814
|
+
onPick: () => { },
|
|
815
|
+
};
|
|
816
|
+
requestRender();
|
|
817
|
+
}
|
|
818
|
+
/** {owning session file, node id} under the cursor, if on a message row. */
|
|
819
|
+
function selectedNode() {
|
|
820
|
+
if (mode.kind !== "tree")
|
|
821
|
+
return undefined;
|
|
822
|
+
const row = mode.view.rows[mode.selected];
|
|
823
|
+
if (row?.a.kind === "node" && row.a.nodeId) {
|
|
824
|
+
const owner = ownerOf(mode.view, row.a.nodeId, (id) => sessionDetails.get(id));
|
|
825
|
+
if (owner)
|
|
826
|
+
return { treeId: owner, nodeId: row.a.nodeId };
|
|
827
|
+
}
|
|
828
|
+
return undefined;
|
|
829
|
+
}
|
|
830
|
+
function openBranchMenu() {
|
|
831
|
+
if (mode.kind !== "tree")
|
|
832
|
+
return;
|
|
833
|
+
const sel = selectedNode();
|
|
834
|
+
if (!sel) {
|
|
835
|
+
showToast("select a message first (⋯/[+] rows expand with enter)");
|
|
836
|
+
return;
|
|
837
|
+
}
|
|
838
|
+
const node = sessionDetails.get(sel.treeId)?.nodes[sel.nodeId];
|
|
839
|
+
// A branch begins with YOUR next message, so it grows from the end of
|
|
840
|
+
// this exchange; re-answering (skipping the reply) is the explicit case.
|
|
841
|
+
const snapped = exchangeEndOf(mode.view.detail, sel.nodeId);
|
|
842
|
+
const snapOwner = snapped === sel.nodeId
|
|
843
|
+
? sel.treeId
|
|
844
|
+
: (ownerOf(mode.view, snapped, (id) => sessionDetails.get(id)) ?? sel.treeId);
|
|
845
|
+
const canReanswer = snapped !== sel.nodeId;
|
|
846
|
+
const options = [
|
|
847
|
+
"branch + agent — your next message continues this exchange",
|
|
848
|
+
"branch only — a dormant branch after this exchange",
|
|
849
|
+
...(canReanswer ? ["re-answer — discard nothing, ask this question again"] : []),
|
|
850
|
+
];
|
|
851
|
+
overlay = {
|
|
852
|
+
kind: "menu",
|
|
853
|
+
title: `branch after: ${truncateStr(node?.excerpt || sel.nodeId, 48)}`,
|
|
854
|
+
options,
|
|
855
|
+
selected: 0,
|
|
856
|
+
onPick: (idx) => {
|
|
857
|
+
if (idx === 2) {
|
|
858
|
+
void forkAtNode(sel.treeId, sel.nodeId, { spawn: true, attach: true });
|
|
859
|
+
}
|
|
860
|
+
else {
|
|
861
|
+
void forkAtNode(snapOwner, snapped, { spawn: idx === 0, attach: idx === 0 });
|
|
862
|
+
}
|
|
863
|
+
},
|
|
864
|
+
};
|
|
865
|
+
requestRender();
|
|
866
|
+
}
|
|
867
|
+
function labelFlow() {
|
|
868
|
+
if (mode.kind !== "tree")
|
|
869
|
+
return;
|
|
870
|
+
const sel = selectedNode();
|
|
871
|
+
if (!sel) {
|
|
872
|
+
showToast("select a message first (⋯/[+] rows expand with enter)");
|
|
873
|
+
return;
|
|
874
|
+
}
|
|
875
|
+
const selNode = sessionDetails.get(sel.treeId)?.nodes[sel.nodeId];
|
|
876
|
+
const current = selNode?.label ?? "";
|
|
877
|
+
overlay = {
|
|
878
|
+
kind: "input",
|
|
879
|
+
title: `label: ${truncateStr(selNode?.excerpt || sel.nodeId, 34)}${current ? ` [${current}]` : ""}`,
|
|
880
|
+
value: "",
|
|
881
|
+
placeholder: current ? "empty clears the label" : "type a label",
|
|
882
|
+
onSubmit: (label) => {
|
|
883
|
+
void client
|
|
884
|
+
.request({
|
|
885
|
+
t: "set_label",
|
|
886
|
+
id: client.rid(),
|
|
887
|
+
treeId: sel.treeId,
|
|
888
|
+
nodeId: sel.nodeId,
|
|
889
|
+
label: label || null,
|
|
890
|
+
})
|
|
891
|
+
.then((res) => {
|
|
892
|
+
if (!res.ok)
|
|
893
|
+
showToast(`label failed: ${res.err}`);
|
|
894
|
+
else
|
|
895
|
+
fetchDetail(sel.treeId, true); // rebuild keeps the cursor here
|
|
896
|
+
requestRender();
|
|
897
|
+
});
|
|
898
|
+
},
|
|
899
|
+
};
|
|
900
|
+
requestRender();
|
|
901
|
+
}
|
|
902
|
+
/** Forest-level naming: L renames the selected tree (its forest label). */
|
|
903
|
+
function renameTreeFlow() {
|
|
904
|
+
if (mode.kind !== "forest")
|
|
905
|
+
return;
|
|
906
|
+
if (!selectedId) {
|
|
907
|
+
showToast("select a tree first (↑/↓ or click)");
|
|
908
|
+
return;
|
|
909
|
+
}
|
|
910
|
+
const treeId = selectedId;
|
|
911
|
+
const current = forest.get(treeId)?.name ?? "";
|
|
912
|
+
overlay = {
|
|
913
|
+
kind: "input",
|
|
914
|
+
title: `rename tree${current ? `: ${truncateStr(current, 48)}` : ""}`,
|
|
915
|
+
value: "",
|
|
916
|
+
placeholder: current ? "empty keeps the current name" : "type a name",
|
|
917
|
+
onSubmit: (name) => {
|
|
918
|
+
if (!name)
|
|
919
|
+
return;
|
|
920
|
+
void client
|
|
921
|
+
.request({ t: "rename_tree", id: client.rid(), treeId, name })
|
|
922
|
+
.then((res) => {
|
|
923
|
+
if (!res.ok)
|
|
924
|
+
showToast(`rename failed: ${res.err}`);
|
|
925
|
+
else
|
|
926
|
+
showToast(`renamed to ${name}`);
|
|
927
|
+
requestRender();
|
|
928
|
+
});
|
|
929
|
+
},
|
|
930
|
+
};
|
|
931
|
+
requestRender();
|
|
932
|
+
}
|
|
933
|
+
/**
|
|
934
|
+
* Archive toggle for the selected tree: archived trees keep their session
|
|
935
|
+
* file and stay searchable but leave the canvas and sidebar ('.' reveals
|
|
936
|
+
* them; resuming or A again brings one back).
|
|
937
|
+
*/
|
|
938
|
+
async function archiveFlow() {
|
|
939
|
+
if (mode.kind !== "forest" || !selectedId) {
|
|
940
|
+
if (mode.kind === "forest")
|
|
941
|
+
showToast("select a tree first (↑/↓ or click)");
|
|
942
|
+
return;
|
|
943
|
+
}
|
|
944
|
+
const treeId = selectedId;
|
|
945
|
+
const t = forest.get(treeId);
|
|
946
|
+
if (!t)
|
|
947
|
+
return;
|
|
948
|
+
const archiving = !t.archived;
|
|
949
|
+
// A conversation archives as a WHOLE — leaving members behind would
|
|
950
|
+
// resurface the family under a branch's identity.
|
|
951
|
+
const members = familyMembers(treeId);
|
|
952
|
+
if (archiving && members.some((m) => m.live)) {
|
|
953
|
+
showToast("a branch has a live agent — kill it first (x)");
|
|
954
|
+
return;
|
|
955
|
+
}
|
|
956
|
+
// The row is about to leave the list — pick the selection's landing spot now.
|
|
957
|
+
const order = sidebarOrder(sidebarRows(conversations()));
|
|
958
|
+
const idx = order.indexOf(treeId);
|
|
959
|
+
for (const m of members) {
|
|
960
|
+
const res = await client.request({
|
|
961
|
+
t: "set_archived",
|
|
962
|
+
id: client.rid(),
|
|
963
|
+
treeId: m.treeId,
|
|
964
|
+
archived: archiving,
|
|
965
|
+
});
|
|
966
|
+
if (!res.ok) {
|
|
967
|
+
showToast(`${archiving ? "archive" : "unarchive"} failed: ${res.err}`);
|
|
968
|
+
return;
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
showToast(`${archiving ? "archived" : "unarchived"} ${treeTitle(t).title}${archiving && !showArchived ? " (. shows archived)" : ""}`);
|
|
972
|
+
if (archiving && !showArchived && selectedId === treeId) {
|
|
973
|
+
selectedId = order[idx + 1] ?? order[idx - 1] ?? null;
|
|
974
|
+
}
|
|
975
|
+
requestRender();
|
|
976
|
+
}
|
|
977
|
+
/**
|
|
978
|
+
* Grow a new branch at a node. Under the hood this materializes a new
|
|
979
|
+
* session file (so the branch can carry its own agent, in parallel, never
|
|
980
|
+
* blocking anyone) — but visually it is just a new branch of the SAME
|
|
981
|
+
* tree. The forest never gains an item from this.
|
|
982
|
+
*/
|
|
983
|
+
async function forkAtNode(treeId, nodeId, opts) {
|
|
984
|
+
const res = await client.request({
|
|
985
|
+
t: "branch",
|
|
986
|
+
id: client.rid(),
|
|
987
|
+
treeId,
|
|
988
|
+
nodeId,
|
|
989
|
+
mode: "new-tree",
|
|
990
|
+
spawn: opts.spawn,
|
|
991
|
+
});
|
|
992
|
+
if (!res.ok) {
|
|
993
|
+
showToast(`fork failed: ${res.err}`);
|
|
994
|
+
requestRender();
|
|
995
|
+
return;
|
|
996
|
+
}
|
|
997
|
+
if (opts.attach && res.newTreeId) {
|
|
998
|
+
await attach(res.newTreeId);
|
|
999
|
+
return;
|
|
1000
|
+
}
|
|
1001
|
+
showToast(opts.spawn ? "new branch — agent running in parallel" : "new branch (dormant)");
|
|
1002
|
+
if (mode.kind === "tree")
|
|
1003
|
+
rebuildConv(); // cursor stays on the branch point
|
|
1004
|
+
requestRender();
|
|
1005
|
+
}
|
|
1006
|
+
/* ------------------------------ attach/detach ---------------------------- */
|
|
1007
|
+
async function attach(treeId) {
|
|
1008
|
+
const rec = forest.get(treeId);
|
|
1009
|
+
if (!rec)
|
|
1010
|
+
return;
|
|
1011
|
+
if (!rec.live) {
|
|
1012
|
+
const res = await client.request({ t: "resume_tree", id: client.rid(), treeId });
|
|
1013
|
+
if (!res.ok) {
|
|
1014
|
+
showToast(`resume failed: ${res.err}`);
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
const res = await client.request({
|
|
1019
|
+
t: "attach",
|
|
1020
|
+
id: client.rid(),
|
|
1021
|
+
treeId,
|
|
1022
|
+
...attachSize(),
|
|
1023
|
+
});
|
|
1024
|
+
if (!("snapshot" in res)) {
|
|
1025
|
+
showToast(`attach failed: ${res.err}`);
|
|
1026
|
+
return;
|
|
1027
|
+
}
|
|
1028
|
+
mode = { kind: "attached", treeId };
|
|
1029
|
+
client.send({ t: "ack_seen", treeId });
|
|
1030
|
+
// Pi renders inside pines' alternate screen. The pines client remains
|
|
1031
|
+
// alive and intercepts its prefix while the daemon owns the real pi PTY.
|
|
1032
|
+
out.write(MOUSE_DISABLE + GUEST_INPUT_ENABLE);
|
|
1033
|
+
// Pin pi's scrolling to rows 1..N-1 (its PTY is sized rows-1): the bottom
|
|
1034
|
+
// row stays ours for the status bar instead of scrolling away with pi.
|
|
1035
|
+
out.write(`\x1b[2J\x1b[1;${(out.rows ?? 24) - 1}r\x1b[H`);
|
|
1036
|
+
out.write(Buffer.from(res.snapshot, "base64").toString("utf8"));
|
|
1037
|
+
drawAttachedBar();
|
|
1038
|
+
}
|
|
1039
|
+
/**
|
|
1040
|
+
* The bottom row stays pines' own while attached (pi is sized rows-1):
|
|
1041
|
+
* breadcrumb + the prefix escapes, else there is no visible way out.
|
|
1042
|
+
* Cursor save/restore keeps pi's cursor untouched; throttled on output.
|
|
1043
|
+
*/
|
|
1044
|
+
let attachedBarTimer = null;
|
|
1045
|
+
function drawAttachedBar() {
|
|
1046
|
+
if (mode.kind !== "attached")
|
|
1047
|
+
return;
|
|
1048
|
+
const name = attachedName(mode.treeId);
|
|
1049
|
+
const width = out.columns ?? 80;
|
|
1050
|
+
const row = out.rows ?? 24;
|
|
1051
|
+
const left = ` \x1b[1mpines\x1b[0m ▸ ${name} ▸ pi`;
|
|
1052
|
+
const hints = "ctrl+t ← tree · ctrl+t f forest · ctrl+t n next · ctrl+t ctrl+t sends ctrl+t ";
|
|
1053
|
+
const plainLen = visibleLength(left);
|
|
1054
|
+
let h = hints;
|
|
1055
|
+
const avail = width - 1 - plainLen - 1;
|
|
1056
|
+
if (h.length > avail)
|
|
1057
|
+
h = avail > 1 ? h.slice(0, avail - 1) + "…" : "";
|
|
1058
|
+
const pad = Math.max(1, width - plainLen - h.length - 1);
|
|
1059
|
+
out.write(SYNC_BEGIN +
|
|
1060
|
+
"\x1b7" + // save cursor
|
|
1061
|
+
`\x1b[${row};1H\x1b[K` +
|
|
1062
|
+
`${left}${" ".repeat(pad)}\x1b[${MUTED}m${h}\x1b[0m` +
|
|
1063
|
+
"\x1b8" + // restore cursor
|
|
1064
|
+
SYNC_END);
|
|
1065
|
+
}
|
|
1066
|
+
function scheduleAttachedBar() {
|
|
1067
|
+
if (attachedBarTimer)
|
|
1068
|
+
return;
|
|
1069
|
+
attachedBarTimer = setTimeout(() => {
|
|
1070
|
+
attachedBarTimer = null;
|
|
1071
|
+
drawAttachedBar();
|
|
1072
|
+
}, 120);
|
|
1073
|
+
}
|
|
1074
|
+
function detachToTree() {
|
|
1075
|
+
if (mode.kind !== "attached")
|
|
1076
|
+
return;
|
|
1077
|
+
const treeId = mode.treeId;
|
|
1078
|
+
client.send({ t: "detach", treeId });
|
|
1079
|
+
out.write("\x1b[r" + GUEST_INPUT_DISABLE + MOUSE_ENABLE); // reset scroll region
|
|
1080
|
+
mode = { kind: "forest" };
|
|
1081
|
+
// Back out to the CONVERSATION, cursor on the branch we were just in.
|
|
1082
|
+
void openTree(treeId, forest.get(treeId)?.leafId ?? null);
|
|
1083
|
+
}
|
|
1084
|
+
function detachToForest() {
|
|
1085
|
+
if (mode.kind !== "attached")
|
|
1086
|
+
return;
|
|
1087
|
+
client.send({ t: "detach", treeId: mode.treeId });
|
|
1088
|
+
out.write("\x1b[r" + GUEST_INPUT_DISABLE + MOUSE_ENABLE); // reset scroll region
|
|
1089
|
+
mode = { kind: "forest" };
|
|
1090
|
+
requestRender();
|
|
1091
|
+
}
|
|
1092
|
+
/* ------------------------------ event wiring ----------------------------- */
|
|
1093
|
+
client.on("forest_update", ({ upsert, remove }) => {
|
|
1094
|
+
forestVersion++;
|
|
1095
|
+
let newest;
|
|
1096
|
+
for (const t of upsert ?? []) {
|
|
1097
|
+
const prev = forest.get(t.treeId);
|
|
1098
|
+
forest.set(t.treeId, t);
|
|
1099
|
+
if (!prev && !t.parentSessionPath && (!newest || t.mtime > newest.mtime))
|
|
1100
|
+
newest = t;
|
|
1101
|
+
if (prev && prev.mtime !== t.mtime) {
|
|
1102
|
+
// Session view renders from cached details: refresh (not delete —
|
|
1103
|
+
// the stale rows keep the screen steady until the fetch lands).
|
|
1104
|
+
if (sessionDetails.has(t.treeId))
|
|
1105
|
+
fetchDetail(t.treeId, true);
|
|
1106
|
+
}
|
|
1107
|
+
// Surface crashes as they happen — a red dot alone explains nothing.
|
|
1108
|
+
if (prev && prev.status !== "crashed" && t.status === "crashed") {
|
|
1109
|
+
const exit = t.lastExitCode != null ? ` (exit ${t.lastExitCode})` : "";
|
|
1110
|
+
showToast(`agent crashed: ${treeTitle(t).title}${exit} — see ~/.pines/daemon.log`);
|
|
1111
|
+
}
|
|
1112
|
+
}
|
|
1113
|
+
for (const id of remove ?? []) {
|
|
1114
|
+
forest.delete(id);
|
|
1115
|
+
if (selectedId === id)
|
|
1116
|
+
selectedId = null;
|
|
1117
|
+
}
|
|
1118
|
+
// A newly discovered/spawned tree should be visibly present, not silently
|
|
1119
|
+
// placed outside the current camera. Preserve zoom; center only when it
|
|
1120
|
+
// spawned out of view — a visible one shouldn't yank the camera.
|
|
1121
|
+
if (newest) {
|
|
1122
|
+
selectedId = newest.treeId;
|
|
1123
|
+
if (ensureVisible(camera, forestVp(), newest.x, newest.y) !== camera) {
|
|
1124
|
+
camera = { ...camera, cx: newest.x, cy: newest.y };
|
|
1125
|
+
}
|
|
1126
|
+
}
|
|
1127
|
+
// Selection is a conversation: normalize member ids to the family root
|
|
1128
|
+
// (the startup pick can land on a member ingested before its parent).
|
|
1129
|
+
if (selectedId) {
|
|
1130
|
+
const root = familyRootId(selectedId);
|
|
1131
|
+
if (root !== selectedId && forest.has(root))
|
|
1132
|
+
selectedId = root;
|
|
1133
|
+
}
|
|
1134
|
+
// Tips read live status straight from these summaries.
|
|
1135
|
+
if (mode.kind === "tree")
|
|
1136
|
+
rebuildConv();
|
|
1137
|
+
requestRender();
|
|
1138
|
+
});
|
|
1139
|
+
client.on("output", (treeId, data) => {
|
|
1140
|
+
if (mode.kind === "attached" && mode.treeId === treeId) {
|
|
1141
|
+
out.write(data);
|
|
1142
|
+
scheduleAttachedBar();
|
|
1143
|
+
}
|
|
1144
|
+
});
|
|
1145
|
+
client.on("agent_exit", (treeId, code) => {
|
|
1146
|
+
if (mode.kind === "attached" && mode.treeId === treeId) {
|
|
1147
|
+
out.write(`\r\n\x1b[${MUTED}m[agent exited (${code}) — Ctrl+t f returns to the forest]\x1b[0m\r\n`);
|
|
1148
|
+
}
|
|
1149
|
+
});
|
|
1150
|
+
client.on("toast", ({ text }) => showToast(text));
|
|
1151
|
+
client.on("close", () => {
|
|
1152
|
+
cleanup();
|
|
1153
|
+
process.stderr.write("\npines: daemon connection closed\n");
|
|
1154
|
+
process.exit(0);
|
|
1155
|
+
});
|
|
1156
|
+
out.on("resize", () => {
|
|
1157
|
+
if (mode.kind === "attached") {
|
|
1158
|
+
client.send({ t: "resize", treeId: mode.treeId, ...attachSize() });
|
|
1159
|
+
scheduleAttachedBar();
|
|
1160
|
+
}
|
|
1161
|
+
else {
|
|
1162
|
+
// Full clear: cells outside the new frame would otherwise linger.
|
|
1163
|
+
out.write("\x1b[2J");
|
|
1164
|
+
requestRender();
|
|
1165
|
+
}
|
|
1166
|
+
});
|
|
1167
|
+
/* Spinner: animate only while a running tree exists and we're rendering. */
|
|
1168
|
+
const spinnerTimer = setInterval(() => {
|
|
1169
|
+
if (mode.kind === "attached")
|
|
1170
|
+
return;
|
|
1171
|
+
let anyRunning = false;
|
|
1172
|
+
for (const t of forest.values())
|
|
1173
|
+
if (t.status === "running" && t.live)
|
|
1174
|
+
anyRunning = true;
|
|
1175
|
+
if (anyRunning) {
|
|
1176
|
+
spinnerFrame = (spinnerFrame + 1) % SPINNER_FRAMES.length;
|
|
1177
|
+
requestRender();
|
|
1178
|
+
}
|
|
1179
|
+
}, 125);
|
|
1180
|
+
spinnerTimer.unref();
|
|
1181
|
+
function cleanup() {
|
|
1182
|
+
clearInterval(spinnerTimer);
|
|
1183
|
+
stdin.setRawMode(false);
|
|
1184
|
+
stdin.pause();
|
|
1185
|
+
out.write(GUEST_INPUT_DISABLE + MOUSE_DISABLE + ALT_SCREEN_LEAVE + "\x1b[0m");
|
|
1186
|
+
}
|
|
1187
|
+
function quit() {
|
|
1188
|
+
cleanup();
|
|
1189
|
+
client.close();
|
|
1190
|
+
process.exit(0);
|
|
1191
|
+
}
|
|
1192
|
+
/* ------------------------------ interactions ----------------------------- */
|
|
1193
|
+
/** One canonical ordering everywhere: the sidebar's visible list order. */
|
|
1194
|
+
function listOrder() {
|
|
1195
|
+
return sidebarOrder(sidebarRows(conversations()));
|
|
1196
|
+
}
|
|
1197
|
+
/** Move the selection through the sidebar's list order (no wrap). */
|
|
1198
|
+
function stepSidebarSelection(dir) {
|
|
1199
|
+
const order = sidebarOrder(sidebarRows(conversations()));
|
|
1200
|
+
if (order.length === 0)
|
|
1201
|
+
return;
|
|
1202
|
+
const idx = order.indexOf(selectedId ?? "");
|
|
1203
|
+
const next = idx < 0
|
|
1204
|
+
? dir === 1
|
|
1205
|
+
? 0
|
|
1206
|
+
: order.length - 1
|
|
1207
|
+
: Math.max(0, Math.min(order.length - 1, idx + dir));
|
|
1208
|
+
selectedId = order[next];
|
|
1209
|
+
const t = forest.get(selectedId);
|
|
1210
|
+
if (t)
|
|
1211
|
+
camera = ensureVisible(camera, forestVp(), t.x, t.y);
|
|
1212
|
+
requestRender();
|
|
1213
|
+
}
|
|
1214
|
+
function cycleSelection(dir = 1) {
|
|
1215
|
+
// Tab walks the SAME order the sidebar displays, wrapping at the ends —
|
|
1216
|
+
// what you see is what you cycle.
|
|
1217
|
+
const orderList = listOrder();
|
|
1218
|
+
if (orderList.length === 0)
|
|
1219
|
+
return;
|
|
1220
|
+
const idx = orderList.indexOf(selectedId ?? "");
|
|
1221
|
+
selectedId = orderList[(idx + dir + orderList.length) % orderList.length];
|
|
1222
|
+
// Scroll-into-view-if-needed: the camera only moves when the next tree
|
|
1223
|
+
// is outside the viewport, and then only far enough to reveal it.
|
|
1224
|
+
const t = forest.get(selectedId);
|
|
1225
|
+
if (t)
|
|
1226
|
+
camera = ensureVisible(camera, forestVp(), t.x, t.y);
|
|
1227
|
+
requestRender();
|
|
1228
|
+
}
|
|
1229
|
+
function jumpToAttention() {
|
|
1230
|
+
// The top of the list IS the most attention-worthy tree.
|
|
1231
|
+
const targetId = listOrder()[0];
|
|
1232
|
+
const target = targetId ? forest.get(targetId) : undefined;
|
|
1233
|
+
if (!target)
|
|
1234
|
+
return;
|
|
1235
|
+
selectedId = target.treeId;
|
|
1236
|
+
// Center-if-outside: an intentional jump centers on a far-away target,
|
|
1237
|
+
// but an already-visible one just gets the selection highlight.
|
|
1238
|
+
if (ensureVisible(camera, forestVp(), target.x, target.y) !== camera) {
|
|
1239
|
+
camera = { ...camera, cx: target.x, cy: target.y };
|
|
1240
|
+
}
|
|
1241
|
+
requestRender();
|
|
1242
|
+
}
|
|
1243
|
+
async function newTreeFlow() {
|
|
1244
|
+
if (creatingTree)
|
|
1245
|
+
return;
|
|
1246
|
+
creatingTree = true;
|
|
1247
|
+
try {
|
|
1248
|
+
const res = await client.request({
|
|
1249
|
+
t: "spawn_tree",
|
|
1250
|
+
id: client.rid(),
|
|
1251
|
+
cwd: process.cwd(),
|
|
1252
|
+
});
|
|
1253
|
+
if (!res.ok || !res.newTreeId) {
|
|
1254
|
+
showToast(`spawn failed: ${res.err ?? "no tree id"}`);
|
|
1255
|
+
return;
|
|
1256
|
+
}
|
|
1257
|
+
selectedId = res.newTreeId;
|
|
1258
|
+
const rec = forest.get(res.newTreeId);
|
|
1259
|
+
if (rec)
|
|
1260
|
+
camera = { ...camera, cx: rec.x, cy: rec.y };
|
|
1261
|
+
await attach(res.newTreeId);
|
|
1262
|
+
}
|
|
1263
|
+
catch (err) {
|
|
1264
|
+
showToast(`spawn failed: ${err instanceof Error ? err.message : String(err)}`);
|
|
1265
|
+
}
|
|
1266
|
+
finally {
|
|
1267
|
+
creatingTree = false;
|
|
1268
|
+
requestRender();
|
|
1269
|
+
}
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Where keyboard zoom should aim: the selected tree when it's on screen,
|
|
1273
|
+
* else the content centroid, else the viewport center. Zooming toward the
|
|
1274
|
+
* middle of nowhere just produces an empty screen.
|
|
1275
|
+
*/
|
|
1276
|
+
function zoomAnchorCell(view) {
|
|
1277
|
+
const inView = (p) => p.x >= 0 && p.x < view.width && p.y >= 0 && p.y < view.height;
|
|
1278
|
+
const sel = selectedId ? forest.get(selectedId) : undefined;
|
|
1279
|
+
if (sel) {
|
|
1280
|
+
const p = worldToCell(camera, view, sel.x, sel.y);
|
|
1281
|
+
if (inView(p))
|
|
1282
|
+
return p;
|
|
1283
|
+
}
|
|
1284
|
+
const trees = visibleTrees();
|
|
1285
|
+
if (trees.length > 0) {
|
|
1286
|
+
const cx = trees.reduce((s, t) => s + t.x, 0) / trees.length;
|
|
1287
|
+
const cy = trees.reduce((s, t) => s + t.y, 0) / trees.length;
|
|
1288
|
+
const p = worldToCell(camera, view, cx, cy);
|
|
1289
|
+
if (inView(p))
|
|
1290
|
+
return p;
|
|
1291
|
+
}
|
|
1292
|
+
return { x: view.width / 2, y: view.height / 2 };
|
|
1293
|
+
}
|
|
1294
|
+
function onForestKey(key) {
|
|
1295
|
+
const pan = 8 / camera.zoom;
|
|
1296
|
+
const view = forestVp();
|
|
1297
|
+
switch (key) {
|
|
1298
|
+
case "q":
|
|
1299
|
+
case "\x03":
|
|
1300
|
+
quit();
|
|
1301
|
+
break;
|
|
1302
|
+
// Miller-columns navigation on arrows: ↑/↓ select, → descends into the
|
|
1303
|
+
// selected tree, ← is already at the top level. Panning stays on hjkl
|
|
1304
|
+
// (and drag): arrows navigate, letters move the camera.
|
|
1305
|
+
case "h":
|
|
1306
|
+
camera = { ...camera, cx: camera.cx - pan };
|
|
1307
|
+
break;
|
|
1308
|
+
case "l":
|
|
1309
|
+
camera = { ...camera, cx: camera.cx + pan };
|
|
1310
|
+
break;
|
|
1311
|
+
case "k":
|
|
1312
|
+
camera = { ...camera, cy: camera.cy - pan };
|
|
1313
|
+
break;
|
|
1314
|
+
case "j":
|
|
1315
|
+
camera = { ...camera, cy: camera.cy + pan };
|
|
1316
|
+
break;
|
|
1317
|
+
case "\x1b[A":
|
|
1318
|
+
if (sidebarW() > 0)
|
|
1319
|
+
stepSidebarSelection(-1);
|
|
1320
|
+
else
|
|
1321
|
+
cycleSelection(-1);
|
|
1322
|
+
return;
|
|
1323
|
+
case "\x1b[B":
|
|
1324
|
+
if (sidebarW() > 0)
|
|
1325
|
+
stepSidebarSelection(1);
|
|
1326
|
+
else
|
|
1327
|
+
cycleSelection(1);
|
|
1328
|
+
return;
|
|
1329
|
+
case "\x1b[C":
|
|
1330
|
+
if (selectedId)
|
|
1331
|
+
void openTree(selectedId);
|
|
1332
|
+
return;
|
|
1333
|
+
case "\x1b[D":
|
|
1334
|
+
return; // already at the root of the hierarchy
|
|
1335
|
+
case "\r":
|
|
1336
|
+
// Claude Agents muscle memory: Enter jumps straight into the session.
|
|
1337
|
+
if (selectedId)
|
|
1338
|
+
attachConversation(selectedId);
|
|
1339
|
+
return;
|
|
1340
|
+
case "S":
|
|
1341
|
+
// Blind-toggling while auto-hidden would flip an invisible state.
|
|
1342
|
+
if ((out.columns ?? 80) < 60) {
|
|
1343
|
+
showToast("terminal too narrow for the sidebar (needs ≥60 cols)");
|
|
1344
|
+
return;
|
|
1345
|
+
}
|
|
1346
|
+
ui.sidebarVisible = !ui.sidebarVisible;
|
|
1347
|
+
saveUiState(ui);
|
|
1348
|
+
out.write("\x1b[2J");
|
|
1349
|
+
requestRender();
|
|
1350
|
+
return;
|
|
1351
|
+
case "v":
|
|
1352
|
+
setForestStyle(nextStyleId(ui.forestStyle));
|
|
1353
|
+
return;
|
|
1354
|
+
case "V":
|
|
1355
|
+
openStylePicker();
|
|
1356
|
+
return;
|
|
1357
|
+
case "[":
|
|
1358
|
+
case "]": {
|
|
1359
|
+
if (sidebarW() === 0)
|
|
1360
|
+
return;
|
|
1361
|
+
const width = out.columns ?? 80;
|
|
1362
|
+
ui.sidebarWidth = Math.max(MIN_SIDEBAR, Math.min(Math.floor(width / 2), ui.sidebarWidth + (key === "]" ? 2 : -2)));
|
|
1363
|
+
saveUiState(ui);
|
|
1364
|
+
out.write("\x1b[2J");
|
|
1365
|
+
requestRender();
|
|
1366
|
+
return;
|
|
1367
|
+
}
|
|
1368
|
+
case "+":
|
|
1369
|
+
case "=": {
|
|
1370
|
+
const a = zoomAnchorCell(view);
|
|
1371
|
+
camera = zoomAt(camera, view, a.x, a.y, 1.3);
|
|
1372
|
+
break;
|
|
1373
|
+
}
|
|
1374
|
+
case "-":
|
|
1375
|
+
case "_": {
|
|
1376
|
+
const a = zoomAnchorCell(view);
|
|
1377
|
+
camera = zoomAt(camera, view, a.x, a.y, 1 / 1.3);
|
|
1378
|
+
break;
|
|
1379
|
+
}
|
|
1380
|
+
case "0":
|
|
1381
|
+
camera = fitCamera(conversations(), forestVp());
|
|
1382
|
+
break;
|
|
1383
|
+
case "\t":
|
|
1384
|
+
cycleSelection();
|
|
1385
|
+
return;
|
|
1386
|
+
case "o":
|
|
1387
|
+
jumpToAttention();
|
|
1388
|
+
return;
|
|
1389
|
+
case "a":
|
|
1390
|
+
if (selectedId)
|
|
1391
|
+
attachConversation(selectedId);
|
|
1392
|
+
return;
|
|
1393
|
+
case "n":
|
|
1394
|
+
void newTreeFlow();
|
|
1395
|
+
return;
|
|
1396
|
+
case "/":
|
|
1397
|
+
openSearch();
|
|
1398
|
+
return;
|
|
1399
|
+
case "s":
|
|
1400
|
+
void openSimilar();
|
|
1401
|
+
return;
|
|
1402
|
+
case "?":
|
|
1403
|
+
openHelp();
|
|
1404
|
+
return;
|
|
1405
|
+
case "L":
|
|
1406
|
+
void renameTreeFlow();
|
|
1407
|
+
return;
|
|
1408
|
+
case "A":
|
|
1409
|
+
case "\x18": // Ctrl+X — Claude Agents muscle memory for archive
|
|
1410
|
+
void archiveFlow();
|
|
1411
|
+
return;
|
|
1412
|
+
case ".": {
|
|
1413
|
+
showArchived = !showArchived;
|
|
1414
|
+
const n = [...forest.values()].filter((t) => t.archived).length;
|
|
1415
|
+
showToast(showArchived ? `showing archived (${n})` : "hiding archived");
|
|
1416
|
+
// Don't leave the selection pointing at a tree that just vanished.
|
|
1417
|
+
if (!showArchived && selectedId && forest.get(selectedId)?.archived)
|
|
1418
|
+
selectedId = null;
|
|
1419
|
+
requestRender();
|
|
1420
|
+
return;
|
|
1421
|
+
}
|
|
1422
|
+
case "R":
|
|
1423
|
+
client.send({ t: "relayout", id: client.rid() });
|
|
1424
|
+
showToast("relayout requested");
|
|
1425
|
+
return;
|
|
1426
|
+
case "x": {
|
|
1427
|
+
if (!selectedId)
|
|
1428
|
+
return;
|
|
1429
|
+
const live = familyMembers(selectedId).filter((m) => m.live);
|
|
1430
|
+
if (live.length === 0) {
|
|
1431
|
+
showToast("no live agent in this conversation");
|
|
1432
|
+
return;
|
|
1433
|
+
}
|
|
1434
|
+
const victim = attentionMemberOf(live);
|
|
1435
|
+
client.send({ t: "kill_agent", treeId: victim.treeId });
|
|
1436
|
+
const kt = forest.get(selectedId);
|
|
1437
|
+
showToast(`killed agent of ${kt ? treeTitle(kt).title : selectedId}`);
|
|
1438
|
+
return;
|
|
1439
|
+
}
|
|
1440
|
+
default:
|
|
1441
|
+
return;
|
|
1442
|
+
}
|
|
1443
|
+
requestRender();
|
|
1444
|
+
}
|
|
1445
|
+
/** Panel/mini click (or 1-9 by panel order): cursor to that branch tip. */
|
|
1446
|
+
function gotoTipNode(nodeId) {
|
|
1447
|
+
if (mode.kind !== "tree")
|
|
1448
|
+
return;
|
|
1449
|
+
rebuildConv({ nodeId });
|
|
1450
|
+
requestRender();
|
|
1451
|
+
}
|
|
1452
|
+
/** Attach to the agent at a tip; several sessions on one node = tiny menu. */
|
|
1453
|
+
function attachTip(tips) {
|
|
1454
|
+
if (tips.length === 1) {
|
|
1455
|
+
void attach(tips[0].treeId);
|
|
1456
|
+
return;
|
|
1457
|
+
}
|
|
1458
|
+
const headOf = (id) => mode.kind === "tree"
|
|
1459
|
+
? mode.view.tips.find((p) => p.summary.treeId === id)?.headExcerpt
|
|
1460
|
+
: undefined;
|
|
1461
|
+
overlay = {
|
|
1462
|
+
kind: "menu",
|
|
1463
|
+
title: "several agents end at this message — attach which?",
|
|
1464
|
+
options: tips.map((t) => `${headOf(t.treeId) ?? treeTitle(t).title} · ${t.status === "running" ? "working" : t.status} · ${humanAge(t.mtime, Date.now())}`),
|
|
1465
|
+
selected: 0,
|
|
1466
|
+
onPick: (idx) => {
|
|
1467
|
+
const t = tips[idx];
|
|
1468
|
+
if (t)
|
|
1469
|
+
void attach(t.treeId);
|
|
1470
|
+
},
|
|
1471
|
+
};
|
|
1472
|
+
requestRender();
|
|
1473
|
+
}
|
|
1474
|
+
function onTreeKey(key) {
|
|
1475
|
+
if (mode.kind !== "tree")
|
|
1476
|
+
return;
|
|
1477
|
+
switch (key) {
|
|
1478
|
+
case "\x1b":
|
|
1479
|
+
case "q":
|
|
1480
|
+
case "\x1b[D":
|
|
1481
|
+
// ← ascends: tree → forest. One conversation = one level.
|
|
1482
|
+
mode = { kind: "forest" };
|
|
1483
|
+
requestRender();
|
|
1484
|
+
return;
|
|
1485
|
+
case "\x03":
|
|
1486
|
+
quit();
|
|
1487
|
+
break;
|
|
1488
|
+
case "j":
|
|
1489
|
+
case "\x1b[B":
|
|
1490
|
+
mode.selected = Math.min(mode.view.rows.length - 1, mode.selected + 1);
|
|
1491
|
+
requestRender();
|
|
1492
|
+
return;
|
|
1493
|
+
case "k":
|
|
1494
|
+
case "\x1b[A":
|
|
1495
|
+
mode.selected = Math.max(0, mode.selected - 1);
|
|
1496
|
+
requestRender();
|
|
1497
|
+
return;
|
|
1498
|
+
case "1":
|
|
1499
|
+
case "2":
|
|
1500
|
+
case "3":
|
|
1501
|
+
case "4":
|
|
1502
|
+
case "5":
|
|
1503
|
+
case "6":
|
|
1504
|
+
case "7":
|
|
1505
|
+
case "8":
|
|
1506
|
+
case "9":
|
|
1507
|
+
{
|
|
1508
|
+
const tip = mode.view.tips[Number(key) - 1];
|
|
1509
|
+
if (tip)
|
|
1510
|
+
gotoTipNode(tip.nodeId);
|
|
1511
|
+
}
|
|
1512
|
+
return;
|
|
1513
|
+
case "\r":
|
|
1514
|
+
case "\x1b[C": {
|
|
1515
|
+
// ⏎/→: open ⋯/[+] rows; attach at a tip (an agent lives there);
|
|
1516
|
+
// anywhere else, grow a new branch + agent from that message.
|
|
1517
|
+
const row = mode.view.rows[mode.selected];
|
|
1518
|
+
if (!row)
|
|
1519
|
+
return;
|
|
1520
|
+
if (row.a.kind === "elision" && row.a.runId) {
|
|
1521
|
+
const runId = row.a.runId;
|
|
1522
|
+
if (mode.expandedRuns.has(runId))
|
|
1523
|
+
mode.expandedRuns.delete(runId);
|
|
1524
|
+
else
|
|
1525
|
+
mode.expandedRuns.add(runId);
|
|
1526
|
+
rebuildConv({ nodeId: runId });
|
|
1527
|
+
requestRender();
|
|
1528
|
+
return;
|
|
1529
|
+
}
|
|
1530
|
+
if (row.a.kind === "fold" && row.a.foldId) {
|
|
1531
|
+
mode.unfolded.add(row.a.foldId);
|
|
1532
|
+
rebuildConv({ nodeId: row.a.foldId });
|
|
1533
|
+
requestRender();
|
|
1534
|
+
return;
|
|
1535
|
+
}
|
|
1536
|
+
if (row.a.kind === "node" && row.a.nodeId) {
|
|
1537
|
+
if (row.tips?.length) {
|
|
1538
|
+
attachTip(row.tips);
|
|
1539
|
+
return;
|
|
1540
|
+
}
|
|
1541
|
+
// A branch BEGINS with a user message: quick-branching from a
|
|
1542
|
+
// question (or mid-run) lands after its answer, never beside it.
|
|
1543
|
+
const snapped = exchangeEndOf(mode.view.detail, row.a.nodeId);
|
|
1544
|
+
// An unused fork already parked here? Resume it instead of
|
|
1545
|
+
// minting yet another session file for the same spot.
|
|
1546
|
+
const parked = mode.view.parkedAt.get(snapped) ?? mode.view.parkedAt.get(row.a.nodeId);
|
|
1547
|
+
if (parked?.length) {
|
|
1548
|
+
showToast("resuming a parked fork from this message");
|
|
1549
|
+
void attach(parked[0].treeId);
|
|
1550
|
+
return;
|
|
1551
|
+
}
|
|
1552
|
+
const owner = ownerOf(mode.view, snapped, (id) => sessionDetails.get(id));
|
|
1553
|
+
if (owner)
|
|
1554
|
+
void forkAtNode(owner, snapped, { spawn: true, attach: true });
|
|
1555
|
+
return;
|
|
1556
|
+
}
|
|
1557
|
+
return;
|
|
1558
|
+
}
|
|
1559
|
+
case "b":
|
|
1560
|
+
openBranchMenu();
|
|
1561
|
+
return;
|
|
1562
|
+
case "L":
|
|
1563
|
+
void labelFlow();
|
|
1564
|
+
return;
|
|
1565
|
+
case "S":
|
|
1566
|
+
ui.sidebarVisible = !ui.sidebarVisible;
|
|
1567
|
+
saveUiState(ui);
|
|
1568
|
+
out.write("\x1b[2J");
|
|
1569
|
+
requestRender();
|
|
1570
|
+
return;
|
|
1571
|
+
case "[":
|
|
1572
|
+
case "]": {
|
|
1573
|
+
const width = out.columns ?? 80;
|
|
1574
|
+
ui.sidebarWidth = Math.max(MIN_SIDEBAR, Math.min(Math.floor(width / 2), ui.sidebarWidth + (key === "]" ? 2 : -2)));
|
|
1575
|
+
saveUiState(ui);
|
|
1576
|
+
out.write("\x1b[2J");
|
|
1577
|
+
requestRender();
|
|
1578
|
+
return;
|
|
1579
|
+
}
|
|
1580
|
+
case "/":
|
|
1581
|
+
openSearch();
|
|
1582
|
+
return;
|
|
1583
|
+
case "?":
|
|
1584
|
+
openHelp();
|
|
1585
|
+
return;
|
|
1586
|
+
default:
|
|
1587
|
+
return;
|
|
1588
|
+
}
|
|
1589
|
+
}
|
|
1590
|
+
function onForestMouse(ev) {
|
|
1591
|
+
const view = vp();
|
|
1592
|
+
// Status-row clicks: the [⛶ fit] button; nothing else down there is a target.
|
|
1593
|
+
if (ev.y >= view.height) {
|
|
1594
|
+
if (ev.kind === "press" &&
|
|
1595
|
+
ev.button === 0 &&
|
|
1596
|
+
fitButton &&
|
|
1597
|
+
ev.x >= fitButton.x0 &&
|
|
1598
|
+
ev.x <= fitButton.x1) {
|
|
1599
|
+
camera = fitCamera(conversations(), forestVp());
|
|
1600
|
+
requestRender();
|
|
1601
|
+
}
|
|
1602
|
+
return;
|
|
1603
|
+
}
|
|
1604
|
+
const sw = sidebarW();
|
|
1605
|
+
if (sw > 0) {
|
|
1606
|
+
// Divider drag resizes the split; width persists on release.
|
|
1607
|
+
if (dividerDrag) {
|
|
1608
|
+
if (ev.kind === "drag") {
|
|
1609
|
+
ui.sidebarWidth = Math.max(MIN_SIDEBAR, Math.min(Math.floor(view.width / 2), ev.x));
|
|
1610
|
+
requestRender();
|
|
1611
|
+
}
|
|
1612
|
+
else if (ev.kind === "release") {
|
|
1613
|
+
dividerDrag = false;
|
|
1614
|
+
saveUiState(ui);
|
|
1615
|
+
out.write("\x1b[2J");
|
|
1616
|
+
requestRender();
|
|
1617
|
+
}
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
if (ev.kind === "press" && ev.button === 0 && ev.x === sw) {
|
|
1621
|
+
dividerDrag = true;
|
|
1622
|
+
return;
|
|
1623
|
+
}
|
|
1624
|
+
if (ev.x < sw) {
|
|
1625
|
+
onSidebarMouse(ev);
|
|
1626
|
+
return;
|
|
1627
|
+
}
|
|
1628
|
+
}
|
|
1629
|
+
// Canvas events are in canvas-local coordinates.
|
|
1630
|
+
const cvp = forestVp();
|
|
1631
|
+
const cx = ev.x - canvasX();
|
|
1632
|
+
if (ev.kind === "wheel-up" || ev.kind === "wheel-down") {
|
|
1633
|
+
const factor = ev.kind === "wheel-up" ? 1.25 : 0.8;
|
|
1634
|
+
camera = zoomAt(camera, cvp, cx, ev.y, factor);
|
|
1635
|
+
// Deep zoom on a selected/hovered tree opens it.
|
|
1636
|
+
if (camera.zoom >= OPEN_TREE_ZOOM) {
|
|
1637
|
+
const target = selectedId ?? hitTest(cx, ev.y);
|
|
1638
|
+
if (target) {
|
|
1639
|
+
camera = { ...camera, zoom: clampZoom(6) };
|
|
1640
|
+
void openTree(target);
|
|
1641
|
+
return;
|
|
1642
|
+
}
|
|
1643
|
+
}
|
|
1644
|
+
requestRender();
|
|
1645
|
+
return;
|
|
1646
|
+
}
|
|
1647
|
+
if (ev.kind === "press" && ev.button === 0) {
|
|
1648
|
+
dragging = { lastX: cx, lastY: ev.y };
|
|
1649
|
+
const hit = hitTest(cx, ev.y);
|
|
1650
|
+
if (hit) {
|
|
1651
|
+
const now = Date.now();
|
|
1652
|
+
if (lastClick &&
|
|
1653
|
+
lastClick.pane === "canvas" &&
|
|
1654
|
+
lastClick.treeId === hit &&
|
|
1655
|
+
now - lastClick.at < 350) {
|
|
1656
|
+
lastClick = null;
|
|
1657
|
+
void openTree(hit);
|
|
1658
|
+
return;
|
|
1659
|
+
}
|
|
1660
|
+
lastClick = { treeId: hit, at: now, pane: "canvas" };
|
|
1661
|
+
selectedId = hit;
|
|
1662
|
+
requestRender();
|
|
1663
|
+
}
|
|
1664
|
+
return;
|
|
1665
|
+
}
|
|
1666
|
+
if (ev.kind === "drag" && dragging) {
|
|
1667
|
+
const dx = cx - dragging.lastX;
|
|
1668
|
+
const dy = ev.y - dragging.lastY;
|
|
1669
|
+
dragging = { lastX: cx, lastY: ev.y };
|
|
1670
|
+
camera = {
|
|
1671
|
+
...camera,
|
|
1672
|
+
cx: camera.cx - dx / camera.zoom,
|
|
1673
|
+
cy: camera.cy - dy / (camera.zoom * 0.5),
|
|
1674
|
+
};
|
|
1675
|
+
requestRender();
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (ev.kind === "release") {
|
|
1679
|
+
dragging = null;
|
|
1680
|
+
dividerDrag = false; // never let a divider drag survive its release
|
|
1681
|
+
}
|
|
1682
|
+
}
|
|
1683
|
+
/** Sidebar pane: click selects, double-click attaches, wheel steps. */
|
|
1684
|
+
function onSidebarMouse(ev) {
|
|
1685
|
+
if (ev.kind === "wheel-up")
|
|
1686
|
+
return stepSidebarSelection(-1);
|
|
1687
|
+
if (ev.kind === "wheel-down")
|
|
1688
|
+
return stepSidebarSelection(1);
|
|
1689
|
+
if (ev.kind !== "press" || ev.button !== 0)
|
|
1690
|
+
return;
|
|
1691
|
+
const treeId = sidebarLineToTree[ev.y] ?? null;
|
|
1692
|
+
if (!treeId)
|
|
1693
|
+
return;
|
|
1694
|
+
const now = Date.now();
|
|
1695
|
+
if (lastClick &&
|
|
1696
|
+
lastClick.pane === "sidebar" &&
|
|
1697
|
+
lastClick.treeId === treeId &&
|
|
1698
|
+
now - lastClick.at < 350) {
|
|
1699
|
+
lastClick = null;
|
|
1700
|
+
attachConversation(treeId);
|
|
1701
|
+
return;
|
|
1702
|
+
}
|
|
1703
|
+
lastClick = { treeId, at: now, pane: "sidebar" };
|
|
1704
|
+
selectedId = treeId;
|
|
1705
|
+
const t = forest.get(treeId);
|
|
1706
|
+
if (t)
|
|
1707
|
+
camera = ensureVisible(camera, forestVp(), t.x, t.y);
|
|
1708
|
+
requestRender();
|
|
1709
|
+
}
|
|
1710
|
+
function onTreeMouse(ev) {
|
|
1711
|
+
if (mode.kind !== "tree")
|
|
1712
|
+
return;
|
|
1713
|
+
const view = vp();
|
|
1714
|
+
const lay = convLayout(mode.view, view.width, view.height, treePanelW());
|
|
1715
|
+
// Divider drag resizes the shared sidebar width; persists on release.
|
|
1716
|
+
if (lay.sideW > 0) {
|
|
1717
|
+
if (dividerDrag) {
|
|
1718
|
+
if (ev.kind === "drag") {
|
|
1719
|
+
ui.sidebarWidth = Math.max(MIN_SIDEBAR, Math.min(Math.floor(view.width / 2), ev.x));
|
|
1720
|
+
requestRender();
|
|
1721
|
+
}
|
|
1722
|
+
else if (ev.kind === "release") {
|
|
1723
|
+
dividerDrag = false;
|
|
1724
|
+
saveUiState(ui);
|
|
1725
|
+
out.write("\x1b[2J");
|
|
1726
|
+
requestRender();
|
|
1727
|
+
}
|
|
1728
|
+
return;
|
|
1729
|
+
}
|
|
1730
|
+
if (ev.kind === "press" && ev.button === 0 && ev.x === lay.sideW) {
|
|
1731
|
+
dividerDrag = true;
|
|
1732
|
+
return;
|
|
1733
|
+
}
|
|
1734
|
+
}
|
|
1735
|
+
if (ev.kind === "wheel-up") {
|
|
1736
|
+
mode.selected = Math.max(0, mode.selected - 3);
|
|
1737
|
+
requestRender();
|
|
1738
|
+
}
|
|
1739
|
+
else if (ev.kind === "wheel-down") {
|
|
1740
|
+
mode.selected = Math.min(mode.view.rows.length - 1, mode.selected + 3);
|
|
1741
|
+
requestRender();
|
|
1742
|
+
}
|
|
1743
|
+
else if (ev.kind === "press" && ev.button === 0) {
|
|
1744
|
+
// Agents panel: the floating mini hit-tests its own cells; agent rows
|
|
1745
|
+
// start below it. Click = go to that branch tip.
|
|
1746
|
+
if (lay.sideW > 0 && ev.x < lay.sideW) {
|
|
1747
|
+
if (lay.mapH > 0 && ev.y >= lay.mapTop && ev.y < lay.mapTop + lay.mapH) {
|
|
1748
|
+
const nodeId = lastMiniHits?.get(`${ev.x},${ev.y - lay.mapTop}`);
|
|
1749
|
+
if (nodeId)
|
|
1750
|
+
gotoTipNode(nodeId);
|
|
1751
|
+
return;
|
|
1752
|
+
}
|
|
1753
|
+
const tip = mode.view.tips[ev.y - lay.agentsTop];
|
|
1754
|
+
if (tip)
|
|
1755
|
+
gotoTipNode(tip.nodeId);
|
|
1756
|
+
return;
|
|
1757
|
+
}
|
|
1758
|
+
const idx = mode.scroll + ev.y;
|
|
1759
|
+
if (idx >= 0 && idx < mode.view.rows.length) {
|
|
1760
|
+
mode.selected = idx;
|
|
1761
|
+
requestRender();
|
|
1762
|
+
}
|
|
1763
|
+
}
|
|
1764
|
+
}
|
|
1765
|
+
function hitTest(x, y) {
|
|
1766
|
+
// Pick ids were assigned during the last forest render.
|
|
1767
|
+
if (!lastCanvas)
|
|
1768
|
+
return null;
|
|
1769
|
+
const id = lastCanvas.pickAt(x, y);
|
|
1770
|
+
return id >= 0 ? (pickMap.get(id) ?? null) : null;
|
|
1771
|
+
}
|
|
1772
|
+
/* ------------------------------- input loop ------------------------------ */
|
|
1773
|
+
const router = new InputRouter();
|
|
1774
|
+
router.on("key", (key) => {
|
|
1775
|
+
void (async () => {
|
|
1776
|
+
if (mode.kind === "attached") {
|
|
1777
|
+
// Prefix interception; everything else passes through raw.
|
|
1778
|
+
const bytes = Buffer.from(key, "utf8");
|
|
1779
|
+
const prefix = matchesKey(key, PREFIX_KEY);
|
|
1780
|
+
if (prefixArmed) {
|
|
1781
|
+
// Kitty protocol reports a release after the prefix press. It is
|
|
1782
|
+
// part of that same key, not the command following the prefix.
|
|
1783
|
+
if (isKeyRelease(key))
|
|
1784
|
+
return;
|
|
1785
|
+
prefixArmed = false;
|
|
1786
|
+
if (matchesKey(key, Key.ctrl("c")))
|
|
1787
|
+
return;
|
|
1788
|
+
// ← ascends one level (pi → tree), matching the plain-arrow
|
|
1789
|
+
// navigation in pines' own views; plain ← belongs to pi itself.
|
|
1790
|
+
if (matchesKey(key, Key.escape) || matchesKey(key, "d") || matchesKey(key, Key.left))
|
|
1791
|
+
return detachToTree();
|
|
1792
|
+
if (matchesKey(key, "f"))
|
|
1793
|
+
return detachToForest();
|
|
1794
|
+
if (matchesKey(key, "n")) {
|
|
1795
|
+
detachToForest();
|
|
1796
|
+
jumpToAttention();
|
|
1797
|
+
return;
|
|
1798
|
+
}
|
|
1799
|
+
if (prefix) {
|
|
1800
|
+
client.send({
|
|
1801
|
+
t: "input",
|
|
1802
|
+
treeId: mode.treeId,
|
|
1803
|
+
data: bytes.toString("base64"),
|
|
1804
|
+
});
|
|
1805
|
+
return;
|
|
1806
|
+
}
|
|
1807
|
+
return; // unknown chord: swallow
|
|
1808
|
+
}
|
|
1809
|
+
if (prefix) {
|
|
1810
|
+
if (!isKeyRelease(key))
|
|
1811
|
+
prefixArmed = true;
|
|
1812
|
+
return;
|
|
1813
|
+
}
|
|
1814
|
+
// Kitty release events (CSI …:3u) never reach pi: presses are what
|
|
1815
|
+
// it acts on, and the release of the very key that triggered the
|
|
1816
|
+
// attach lands while pi is still starting — before raw mode — where
|
|
1817
|
+
// the PTY line discipline echoes it as literal text at the top of
|
|
1818
|
+
// the screen.
|
|
1819
|
+
if (isKeyRelease(key))
|
|
1820
|
+
return;
|
|
1821
|
+
client.send({ t: "input", treeId: mode.treeId, data: bytes.toString("base64") });
|
|
1822
|
+
return;
|
|
1823
|
+
}
|
|
1824
|
+
const uiKey = normalizeUiKey(key);
|
|
1825
|
+
if (!uiKey)
|
|
1826
|
+
return;
|
|
1827
|
+
if (overlay) {
|
|
1828
|
+
onOverlayKey(uiKey);
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
if (mode.kind === "forest")
|
|
1832
|
+
onForestKey(uiKey);
|
|
1833
|
+
else
|
|
1834
|
+
onTreeKey(uiKey);
|
|
1835
|
+
})();
|
|
1836
|
+
});
|
|
1837
|
+
router.on("mouse", (ev) => {
|
|
1838
|
+
if (overlay)
|
|
1839
|
+
return; // overlays are keyboard-driven
|
|
1840
|
+
if (mode.kind === "forest")
|
|
1841
|
+
onForestMouse(ev);
|
|
1842
|
+
else if (mode.kind === "tree")
|
|
1843
|
+
onTreeMouse(ev);
|
|
1844
|
+
});
|
|
1845
|
+
const feed = (b) => router.feed(b);
|
|
1846
|
+
stdin.setRawMode(true);
|
|
1847
|
+
stdin.resume();
|
|
1848
|
+
stdin.on("data", feed);
|
|
1849
|
+
process.on("SIGTERM", () => quit());
|
|
1850
|
+
// Every fire-and-forget flow (attach, fork, archive, open…) awaits daemon
|
|
1851
|
+
// requests that REJECT on a 10s timeout. A slow daemon must degrade to a
|
|
1852
|
+
// toast, never crash the TUI with an unhandled rejection.
|
|
1853
|
+
process.on("unhandledRejection", (err) => {
|
|
1854
|
+
showToast(`error: ${err instanceof Error ? err.message : String(err)}`);
|
|
1855
|
+
});
|
|
1856
|
+
await boot.finish(() => {
|
|
1857
|
+
let n = 0;
|
|
1858
|
+
for (const t of forest.values())
|
|
1859
|
+
if (t.live)
|
|
1860
|
+
n++;
|
|
1861
|
+
return n;
|
|
1862
|
+
});
|
|
1863
|
+
out.write(ALT_SCREEN_ENTER + MOUSE_ENABLE);
|
|
1864
|
+
requestRender();
|
|
1865
|
+
}
|
|
1866
|
+
/* Re-exported for tests. */
|
|
1867
|
+
export { statusGlyph, statusSgr };
|
|
1868
|
+
//# sourceMappingURL=app.js.map
|