@neosh/sidebar 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/main.ts +3283 -0
- package/package.json +21 -0
- package/plugin.toml +11 -0
package/main.ts
ADDED
|
@@ -0,0 +1,3283 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The sidebar: your projects, the conversations inside them, and which one is working.
|
|
3
|
+
*
|
|
4
|
+
* Two sections and nothing else. Favourites on top because the projects you actually live in are a
|
|
5
|
+
* short list and a long one buries them; everything else under `PROJECTS`, most recently used
|
|
6
|
+
* first, foldable so a project you are not in costs one line instead of ten.
|
|
7
|
+
*
|
|
8
|
+
* It is deliberately *not* a dashboard. Changed files, the model and the token meter all used to be
|
|
9
|
+
* here and all of them are better in the footer, which is always visible and does not push your
|
|
10
|
+
* conversations off the bottom of the screen. A panel that shows six kinds of thing teaches you to
|
|
11
|
+
* stop reading it.
|
|
12
|
+
*
|
|
13
|
+
* The bottom four lines are the keys for whatever the cursor is on, and they change with it. That
|
|
14
|
+
* is the part worth defending: a terminal UI that hides its verbs is a terminal UI you use three of.
|
|
15
|
+
*
|
|
16
|
+
* Motion is frugal — one shared clock in `@neosh/api/ui`, running only while a turn is in flight,
|
|
17
|
+
* and only the working row animates. Continuous repaint costs power for no information.
|
|
18
|
+
*
|
|
19
|
+
* It is a plugin. Everything here is on the public API, `plugins.disabled = ["sidebar"]` turns it
|
|
20
|
+
* off, and a sidebar of your own loads after this one and wins. But replacing it is the *last*
|
|
21
|
+
* resort rather than the first, and three things are here so that it usually is not necessary:
|
|
22
|
+
*
|
|
23
|
+
* - **Every key in this panel is an ordinary binding**, on the buffer kind `neosh.sidebar`, pointed
|
|
24
|
+
* at a command with a name. `^Z` lists them, the palette runs them, and one line in your
|
|
25
|
+
* `init.ts` moves any of them. There is no private switch statement any more — there was, and
|
|
26
|
+
* what it meant was that adding one key to this panel meant forking all twelve hundred lines of
|
|
27
|
+
* it.
|
|
28
|
+
* - **`sidebar.section` is a contribution point.** Anything you can describe as rows appears in the
|
|
29
|
+
* column, in the position you ask for, invoking your commands.
|
|
30
|
+
* - **`sidebar.action` is a verb on a row.** Say the key, the label and the command; this panel
|
|
31
|
+
* binds it, shows it in the hint strip when it applies, and invokes your command with the row
|
|
32
|
+
* under the cursor as arguments.
|
|
33
|
+
*
|
|
34
|
+
* What a project *is* — pinned, ordered, folded — lives in shared project vars rather than in this
|
|
35
|
+
* plugin's private state, so a panel of your own, a status segment or a picker all read the same
|
|
36
|
+
* favourites rather than each starting empty.
|
|
37
|
+
*/
|
|
38
|
+
|
|
39
|
+
import { byteLength, projectScope } from "@neosh/api";
|
|
40
|
+
import type {
|
|
41
|
+
AgentSummary,
|
|
42
|
+
Contribution,
|
|
43
|
+
Disposable,
|
|
44
|
+
DrawnRow,
|
|
45
|
+
Neosh,
|
|
46
|
+
PluginContext,
|
|
47
|
+
NodeInfo,
|
|
48
|
+
SessionInfo,
|
|
49
|
+
Message,
|
|
50
|
+
SwarmAgent,
|
|
51
|
+
SwarmNode,
|
|
52
|
+
SwarmStranger,
|
|
53
|
+
BufferId,
|
|
54
|
+
VarScope,
|
|
55
|
+
ViewId,
|
|
56
|
+
WindowId,
|
|
57
|
+
WorktreeInfo,
|
|
58
|
+
} from "@neosh/api";
|
|
59
|
+
import {
|
|
60
|
+
confirm,
|
|
61
|
+
confirmDestructive,
|
|
62
|
+
configureMotion,
|
|
63
|
+
CursoredList,
|
|
64
|
+
type Decoration,
|
|
65
|
+
type DecorationItem,
|
|
66
|
+
badgeWidth as badgeColumns,
|
|
67
|
+
decorateRow,
|
|
68
|
+
elapsed,
|
|
69
|
+
type ListRow,
|
|
70
|
+
mergeDecorations,
|
|
71
|
+
placeSections,
|
|
72
|
+
type SectionItem,
|
|
73
|
+
sectionRows as contributedRows,
|
|
74
|
+
money,
|
|
75
|
+
onTick,
|
|
76
|
+
pathPicker,
|
|
77
|
+
picker,
|
|
78
|
+
type PickerItem,
|
|
79
|
+
prompt,
|
|
80
|
+
pulseBright,
|
|
81
|
+
spinnerFrame,
|
|
82
|
+
} from "@neosh/api/ui";
|
|
83
|
+
|
|
84
|
+
/** What a row points at, so the cursor survives the list being rebuilt underneath it. */
|
|
85
|
+
type Target =
|
|
86
|
+
| { kind: "project"; cwd: string }
|
|
87
|
+
| { kind: "session"; id: string; cwd: string }
|
|
88
|
+
/** The row that opens another directory. A row rather than only a key, because a verb nobody
|
|
89
|
+
* can see is a verb nobody uses. */
|
|
90
|
+
| { kind: "add" }
|
|
91
|
+
/** A row somebody else contributed. `command` runs on `↵`, with `args` as given. */
|
|
92
|
+
| { kind: "custom"; command?: string; args?: string[] }
|
|
93
|
+
/** A conversation on another computer. Addressed as `(node, session)`; the session id alone is
|
|
94
|
+
* unique only on its own machine. */
|
|
95
|
+
| { kind: "remote"; node: string; session: string; cwd: string; host: string };
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* What the sidebar reads to find out what is not its own.
|
|
99
|
+
*
|
|
100
|
+
* Two points rather than one because they are answers to different questions. A *section* is
|
|
101
|
+
* content — rows in the column, which the contributor owns and re-contributes when its data
|
|
102
|
+
* changes. An *action* is a verb — a key on a row this panel already draws, which this panel binds
|
|
103
|
+
* and invokes on the contributor's behalf so that the row under the cursor can be passed along.
|
|
104
|
+
*/
|
|
105
|
+
const POINT_SECTION = "sidebar.section";
|
|
106
|
+
const POINT_ACTION = "sidebar.action";
|
|
107
|
+
/**
|
|
108
|
+
* Something to put *on* a row this panel already draws — a git badge on a project, a colour on a
|
|
109
|
+
* conversation — keyed by what the row is about rather than where it is.
|
|
110
|
+
*
|
|
111
|
+
* The third kind of thing a plugin can do to a panel it did not write, and the one the original
|
|
112
|
+
* contribution vocabulary left out: a section is rows of your own, an action is a verb on ours, and this is a mark on ours. Pure
|
|
113
|
+
* data, merged at draw time, so a decorator costs the panel nothing on the paint path and can be
|
|
114
|
+
* listed and disabled like any contribution. It is nvim-tree's decorator API as a contribution.
|
|
115
|
+
*/
|
|
116
|
+
const POINT_DECORATION = "sidebar.decoration";
|
|
117
|
+
|
|
118
|
+
/**
|
|
119
|
+
* The blocks this panel draws, in order, for a section to sit `before` or `after`.
|
|
120
|
+
*
|
|
121
|
+
* Published rather than implied: `above`/`below` gave a contributor two slots and no way to land
|
|
122
|
+
* between two of anybody else's sections. A section may also name another section's id.
|
|
123
|
+
*/
|
|
124
|
+
const SLOTS = ["projects", "add"] as const;
|
|
125
|
+
type Slot = (typeof SLOTS)[number];
|
|
126
|
+
|
|
127
|
+
/** The key a decoration is filed under: what the row is *about*. */
|
|
128
|
+
function targetKey(t: Target | undefined): string | null {
|
|
129
|
+
if (!t) return null;
|
|
130
|
+
if (t.kind === "project") return `project:${t.cwd}`;
|
|
131
|
+
if (t.kind === "session") return `session:${t.id}`;
|
|
132
|
+
return null;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/**
|
|
136
|
+
* A verb on a row, contributed by somebody else.
|
|
137
|
+
*
|
|
138
|
+
* The command is invoked with the row under the cursor as arguments — `[kind, cwd]` for a project,
|
|
139
|
+
* `[kind, cwd, sessionId]` for a conversation — because a key press carries no arguments of its
|
|
140
|
+
* own and a plugin that had to track the cursor separately would be one race away from acting on
|
|
141
|
+
* the wrong row.
|
|
142
|
+
*/
|
|
143
|
+
interface ActionItem {
|
|
144
|
+
/** Key notation, as `keymap.set` takes it: `d`, `<C-y>`, `gd`. */
|
|
145
|
+
key: string;
|
|
146
|
+
/** What it does, for the hint strip and for `^Z`. */
|
|
147
|
+
label: string;
|
|
148
|
+
command: string;
|
|
149
|
+
/**
|
|
150
|
+
* Which rows it applies to. Defaults to `any`.
|
|
151
|
+
*
|
|
152
|
+
* `custom` is a row somebody contributed through `sidebar.section` — usually the contributor's
|
|
153
|
+
* own. Without it a plugin can put rows in this column and then has nowhere to put a key that
|
|
154
|
+
* belongs to them: `any` binds the key on every row in the panel and advertises it on all of
|
|
155
|
+
* them, which is a verb about the plan gauge appearing while the cursor is on a conversation.
|
|
156
|
+
*/
|
|
157
|
+
on?: "project" | "session" | "custom" | "any";
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/** A project as the panel thinks of it: a directory, and what is going on in it. */
|
|
161
|
+
interface Project {
|
|
162
|
+
cwd: string;
|
|
163
|
+
name: string;
|
|
164
|
+
favorite: boolean;
|
|
165
|
+
sessions: SessionInfo[];
|
|
166
|
+
/** The cross-machine identity, for matching against what other computers have. */
|
|
167
|
+
key: string;
|
|
168
|
+
/**
|
|
169
|
+
* Linked worktrees of this checkout, nested rather than listed beside it.
|
|
170
|
+
*
|
|
171
|
+
* A worktree used to be a top-level project — correct by the old rule that a worktree is a
|
|
172
|
+
* project, and wrong as a *list*: four scratch trees of one repository read as four unrelated projects,
|
|
173
|
+
* and the thing they have in common is the thing the column no longer said. The name carried
|
|
174
|
+
* the relationship (`neosh · brisk-otter`) precisely because the structure did not. Each entry
|
|
175
|
+
* is an ordinary {@link Project} — its own cwd, its own fold and rank vars — whose `worktrees`
|
|
176
|
+
* is always empty, because git does not nest them either.
|
|
177
|
+
*/
|
|
178
|
+
worktrees: Project[];
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
const NS = "neosh.sidebar";
|
|
182
|
+
/** What this panel's buffer says it is. Everything a third party binds or finds hangs off this. */
|
|
183
|
+
const KIND = "neosh.sidebar";
|
|
184
|
+
|
|
185
|
+
/** Emitted on every cursor move, with the row under it as `data` — a {@link Target} or `null`. */
|
|
186
|
+
const EVENT_CURSOR = "sidebar.cursor";
|
|
187
|
+
|
|
188
|
+
/**
|
|
189
|
+
* What this panel offers a plugin that imports it: `import { api } from "plugin:sidebar"`.
|
|
190
|
+
*
|
|
191
|
+
* The same answers are on the commands `sidebar.cursor` and `sidebar.rows` for a plugin that would
|
|
192
|
+
* rather `cmd.call` than depend on us; this is the typed, zero-round-trip form for one that has
|
|
193
|
+
* put `requires = ["sidebar"]` in its manifest. Filled in by `activate`, so a caller that reads it
|
|
194
|
+
* before then sees a panel that is not open and has no rows — which is true.
|
|
195
|
+
*/
|
|
196
|
+
export const api = {
|
|
197
|
+
/** The buffer kind, for `keymap.set` at `buf_kind` scope and `win.ofKind`. */
|
|
198
|
+
kind: KIND,
|
|
199
|
+
/** The contribution points this panel reads. */
|
|
200
|
+
points: { section: POINT_SECTION, action: POINT_ACTION, decoration: POINT_DECORATION },
|
|
201
|
+
/** The blocks a section may sit `before` or `after`, in the order they are drawn. */
|
|
202
|
+
slots: SLOTS as readonly string[],
|
|
203
|
+
/** The row under the cursor, or `null` when the panel is closed or on nothing. */
|
|
204
|
+
cursor: (): Target | null => null,
|
|
205
|
+
/** Every row that can be landed on, top to bottom. */
|
|
206
|
+
rows: (): Target[] => [],
|
|
207
|
+
/** Redraw now rather than on the next tick. */
|
|
208
|
+
refresh: async (): Promise<void> => {},
|
|
209
|
+
};
|
|
210
|
+
|
|
211
|
+
/**
|
|
212
|
+
* Project vars, which are shared rather than ours.
|
|
213
|
+
*
|
|
214
|
+
* These used to be three arrays in this plugin's private state, which worked exactly as long as
|
|
215
|
+
* this was the only panel: a sidebar of your own started with no favourites, and pinning a project
|
|
216
|
+
* here was invisible to a status segment that wanted to say so. They are namespaced `sidebar.*`
|
|
217
|
+
* because that is what a var key is expected to look like, not because they belong to us — anybody
|
|
218
|
+
* may set them, and this panel redraws when they do.
|
|
219
|
+
*/
|
|
220
|
+
const VAR_FAVORITE = "sidebar.favorite";
|
|
221
|
+
const VAR_RANK = "sidebar.rank";
|
|
222
|
+
const VAR_FOLDED = "sidebar.folded";
|
|
223
|
+
/**
|
|
224
|
+
* The projects, in workspace scope. This list *is* what the panel shows.
|
|
225
|
+
*
|
|
226
|
+
* It used to be an index — a hint, with the real list derived from the conversations that happened
|
|
227
|
+
* to live somewhere. That is the bug this replaced: deleting the last conversation in a directory
|
|
228
|
+
* deleted the directory too, so clearing out a project you had spent a month in removed it from the
|
|
229
|
+
* panel and there was nothing left to start the next conversation from. A project is a place you
|
|
230
|
+
* work, not a property of the work in it.
|
|
231
|
+
*
|
|
232
|
+
* Kept current from three ends — every conversation's directory goes in, so does any directory
|
|
233
|
+
* somebody sets a project var on, and so does anything added by hand — and taken out of by exactly
|
|
234
|
+
* one thing, which is asking for it to go. See [`Arrangement.forget`].
|
|
235
|
+
*/
|
|
236
|
+
const VAR_KNOWN = "sidebar.projects";
|
|
237
|
+
/**
|
|
238
|
+
* What a project is called, remembered for when there is nothing in it to ask.
|
|
239
|
+
*
|
|
240
|
+
* The name comes from the host, which reads it off the repository — `neosh (feat/thing)` for a
|
|
241
|
+
* worktree rather than the directory's own `wt-fe3c0d93` — and it arrives stamped on a conversation.
|
|
242
|
+
* A project with no conversations left has nobody to ask, and falling back to the basename renames
|
|
243
|
+
* a worktree you have used for a fortnight into something you do not recognise the moment you empty
|
|
244
|
+
* it. Written when it is known, read when it is not.
|
|
245
|
+
*/
|
|
246
|
+
const VAR_NAME = "sidebar.name";
|
|
247
|
+
/**
|
|
248
|
+
* The checkout a directory is a linked worktree of, for when there is nothing in it to ask.
|
|
249
|
+
*
|
|
250
|
+
* Which tree belongs to which repository is read off a conversation's `repo_root`, so a worktree
|
|
251
|
+
* you have emptied has nobody to say it — and without this it stops being a worktree the moment it
|
|
252
|
+
* stops having conversations, jumps out of the repository it belongs to and lands at the top level
|
|
253
|
+
* as a project of its own. Which is the worktree-nesting rule, arrived at from the other
|
|
254
|
+
* direction: a tree does not become a project by being idle.
|
|
255
|
+
*/
|
|
256
|
+
const VAR_ROOT = "sidebar.root";
|
|
257
|
+
/**
|
|
258
|
+
* Which conversations have stopped and are waiting on an answer.
|
|
259
|
+
*
|
|
260
|
+
* Written by whatever serves `ask_user` — the bundled `questions` plugin, or yours instead of it —
|
|
261
|
+
* and read here, which is the whole reason it is a var and not that plugin's private state. Nothing
|
|
262
|
+
* else in this column can be worked out: a conversation blocked on a question still has a turn in
|
|
263
|
+
* flight, so `active_turn` is set and the row it draws is the spinner, identical to the twenty above
|
|
264
|
+
* it that are actually thinking. An answer nobody is going to give looks like work in progress until
|
|
265
|
+
* you happen to open it.
|
|
266
|
+
*
|
|
267
|
+
* So it outranks *working* wherever the two meet below, and it wears `Status.Pending` — the
|
|
268
|
+
* palette's own group for waiting on something outside the program, which is what the footer's
|
|
269
|
+
* `Question.Waiting` links to, so the two say the same thing in the same colour without this panel
|
|
270
|
+
* having to know the other one exists.
|
|
271
|
+
*/
|
|
272
|
+
const VAR_ASKING = "question.asking";
|
|
273
|
+
/**
|
|
274
|
+
* The same, for a permission prompt. Written by whatever serves `permission_pre` — the bundled
|
|
275
|
+
* `approvals` plugin — and folded into the same set: a conversation blocked on "may I run this" is
|
|
276
|
+
* blocked exactly as one blocked on "which database", and drew as an ordinary spinner until this
|
|
277
|
+
* was read.
|
|
278
|
+
*/
|
|
279
|
+
const VAR_PERMITTING = "permission.asking";
|
|
280
|
+
|
|
281
|
+
export async function activate({ neosh, subscriptions }: PluginContext) {
|
|
282
|
+
await declareOptions(neosh);
|
|
283
|
+
|
|
284
|
+
const applyMotion = async () => {
|
|
285
|
+
configureMotion({
|
|
286
|
+
enabled: (await neosh.opt.get<boolean>("ui.motion")) ?? true,
|
|
287
|
+
ascii: (await neosh.opt.get<boolean>("ui.ascii_only")) ?? false,
|
|
288
|
+
});
|
|
289
|
+
};
|
|
290
|
+
await applyMotion();
|
|
291
|
+
|
|
292
|
+
// How you left it. Read once into memory; every later write goes to the cache and to the shared
|
|
293
|
+
// var store together, so a redraw never waits on a round trip.
|
|
294
|
+
const arrangement = new Arrangement(neosh);
|
|
295
|
+
await arrangement.load(
|
|
296
|
+
(await neosh.session.list({ includeArchived: true }).catch(() => [] as SessionInfo[]))
|
|
297
|
+
.map((s) => s.cwd),
|
|
298
|
+
);
|
|
299
|
+
|
|
300
|
+
// Read once and then kept current from `vars.onChange`, like the arrangement and for the same
|
|
301
|
+
// reason: a draw runs on a 100 ms tick while a turn is in flight and must not spend a round trip
|
|
302
|
+
// per frame asking a question whose answer changes twice an hour.
|
|
303
|
+
const waiting = {
|
|
304
|
+
questions: asked(
|
|
305
|
+
await neosh.vars.get<string[]>({ scope: "global" }, VAR_ASKING).catch(() => null),
|
|
306
|
+
),
|
|
307
|
+
permissions: asked(
|
|
308
|
+
await neosh.vars.get<string[]>({ scope: "global" }, VAR_PERMITTING).catch(() => null),
|
|
309
|
+
),
|
|
310
|
+
};
|
|
311
|
+
let asking = new Set([...waiting.questions, ...waiting.permissions]);
|
|
312
|
+
|
|
313
|
+
// One panel per terminal.
|
|
314
|
+
//
|
|
315
|
+
// A workspace can have several and they are not copies of each other: which conversation is
|
|
316
|
+
// open, which row the cursor is on, whether the column is showing at all. All of that is
|
|
317
|
+
// navigation, and navigation is what a view *is* — so the buffer is per view too, since the
|
|
318
|
+
// cursor and the unfolded row are drawn into its text.
|
|
319
|
+
const panels = new Map<ViewId, Panel>();
|
|
320
|
+
|
|
321
|
+
const makePanel = async (view: ViewId): Promise<Panel> => {
|
|
322
|
+
// The kind is what makes this panel something other plugins can act on: it is the scope their
|
|
323
|
+
// keymaps bind at and the handle `win.ofKind` finds it by. One argument, and the difference
|
|
324
|
+
// between a panel you can extend and one you can only replace.
|
|
325
|
+
const buf = await neosh.buf.create({ name: "[sidebar]", scratch: true, kind: KIND });
|
|
326
|
+
const ns = await neosh.ns.create(NS);
|
|
327
|
+
// In this terminal, and only this one. Everything else on `here` is the call it always was.
|
|
328
|
+
const here = neosh.view.at(view);
|
|
329
|
+
// The panel's own width, as of the last frame. The list needs it to unfold the row under the
|
|
330
|
+
// cursor, and reading the option again per render would be a round trip inside a redraw.
|
|
331
|
+
let panelWidth = 34;
|
|
332
|
+
const list = new CursoredList<Target>(here, buf, ns, {
|
|
333
|
+
width: () => panelWidth,
|
|
334
|
+
// Said out loud on every move, so a plugin can follow the cursor without polling — a
|
|
335
|
+
// preview of the conversation under it, a status segment naming the project. One event per
|
|
336
|
+
// keystroke, which is what the composer already costs.
|
|
337
|
+
onMove: () => void neosh.event.emit(EVENT_CURSOR, list.value ?? null),
|
|
338
|
+
});
|
|
339
|
+
let win: WindowId | null = null;
|
|
340
|
+
let focused = false;
|
|
341
|
+
// Typed before a motion and consumed by it. Shared with the key table and with the draw, which
|
|
342
|
+
// is what puts it on screen while it is half typed.
|
|
343
|
+
const count = { pending: "" };
|
|
344
|
+
let capture: Disposable | null = null;
|
|
345
|
+
let running = false;
|
|
346
|
+
|
|
347
|
+
// Serialises redraws. Two overlapping refreshes interleave their `setLines` and `mark` calls
|
|
348
|
+
// and leave highlights pointing at rows that have already been replaced.
|
|
349
|
+
let drawing = false;
|
|
350
|
+
let again = false;
|
|
351
|
+
|
|
352
|
+
const draw = async () => {
|
|
353
|
+
if (win === null) return;
|
|
354
|
+
if (drawing) {
|
|
355
|
+
again = true;
|
|
356
|
+
return;
|
|
357
|
+
}
|
|
358
|
+
drawing = true;
|
|
359
|
+
try {
|
|
360
|
+
do {
|
|
361
|
+
again = false;
|
|
362
|
+
// Read per frame rather than cached at load: a setting arrives from `config.toml` after
|
|
363
|
+
// this plugin has declared it, and a value captured at activation would be the default
|
|
364
|
+
// forever.
|
|
365
|
+
const [width, ascii, hints] = await Promise.all([
|
|
366
|
+
neosh.opt.get<number>("sidebar.width").catch(() => null),
|
|
367
|
+
neosh.opt.get<boolean>("ui.ascii_only").catch(() => null),
|
|
368
|
+
neosh.opt.get<boolean>("sidebar.hints").catch(() => null),
|
|
369
|
+
]);
|
|
370
|
+
panelWidth = width ?? 34;
|
|
371
|
+
const built = await collect(here, arrangement, {
|
|
372
|
+
width: panelWidth,
|
|
373
|
+
ascii: ascii ?? false,
|
|
374
|
+
hints: hints ?? true,
|
|
375
|
+
focused,
|
|
376
|
+
selected: list.value,
|
|
377
|
+
actions: actions(),
|
|
378
|
+
asking,
|
|
379
|
+
count: count.pending,
|
|
380
|
+
// Filled in by `collect`, which is what reads the swarm and the decorations.
|
|
381
|
+
decorations: new Map(),
|
|
382
|
+
remote: new Map(),
|
|
383
|
+
hosts: new Map(),
|
|
384
|
+
});
|
|
385
|
+
running = built.running;
|
|
386
|
+
list.setRows(built.rows, same);
|
|
387
|
+
await list.render({
|
|
388
|
+
showCursor: focused,
|
|
389
|
+
win: win ?? undefined,
|
|
390
|
+
pinned: built.pinned,
|
|
391
|
+
});
|
|
392
|
+
} while (again);
|
|
393
|
+
} finally {
|
|
394
|
+
drawing = false;
|
|
395
|
+
}
|
|
396
|
+
};
|
|
397
|
+
|
|
398
|
+
const open = async () => {
|
|
399
|
+
if (win === null) {
|
|
400
|
+
// Read defensively: a reload takes every declared option away and puts it back, and this
|
|
401
|
+
// can be called from a view arriving in the gap. A panel that throws there used to take
|
|
402
|
+
// the whole plugin runtime with it.
|
|
403
|
+
const width = (await neosh.opt.get<number>("sidebar.width").catch(() => null)) ?? 34;
|
|
404
|
+
win = await here.win.open(buf, "left", { size: width });
|
|
405
|
+
}
|
|
406
|
+
await draw();
|
|
407
|
+
// And again once the frontend has said how tall the panel turned out to be. The foot is held
|
|
408
|
+
// against the bottom edge, which is a measurement — and on the first frame there is nothing
|
|
409
|
+
// to measure yet, so without this the strip sits under the last project until something else
|
|
410
|
+
// happens to redraw.
|
|
411
|
+
// Not held in `subscriptions`: the panel is opened and closed as often as `^B` is pressed,
|
|
412
|
+
// and the runtime cancels a plugin's timers when it unloads anyway.
|
|
413
|
+
neosh.timer.after(120, () => void draw());
|
|
414
|
+
};
|
|
415
|
+
|
|
416
|
+
const leave = async () => {
|
|
417
|
+
if (!focused) return;
|
|
418
|
+
focused = false;
|
|
419
|
+
capture?.dispose();
|
|
420
|
+
capture = null;
|
|
421
|
+
await here.focus.pop().catch(() => {});
|
|
422
|
+
await draw();
|
|
423
|
+
};
|
|
424
|
+
|
|
425
|
+
const close = async () => {
|
|
426
|
+
if (win === null) return;
|
|
427
|
+
const w = win;
|
|
428
|
+
win = null;
|
|
429
|
+
await leave();
|
|
430
|
+
await neosh.win.close(w).catch(() => {});
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
const enter = async () => {
|
|
434
|
+
await open();
|
|
435
|
+
if (win === null || focused) return;
|
|
436
|
+
focused = true;
|
|
437
|
+
await neosh.focus.push(win);
|
|
438
|
+
// Every key the bindings did not claim comes here, which is what makes `j`, `f` and `?` mean
|
|
439
|
+
// something in the panel while `^Q` still quits.
|
|
440
|
+
capture = await neosh.keymap.capture(win, `${NS}.key`).catch(() => null);
|
|
441
|
+
// Land on the conversation you are in, not wherever the cursor happened to be — and *this*
|
|
442
|
+
// terminal's, which is the whole reason the panel is per view.
|
|
443
|
+
const current = await here.session.current().catch(() => null);
|
|
444
|
+
if (current) {
|
|
445
|
+
// Unfold the project it lives in, or "go to where I am" lands on a collapsed heading. For
|
|
446
|
+
// a conversation in a worktree that is two folds: the repository's, then the worktree's.
|
|
447
|
+
if (current.repo_root) arrangement.unfold(current.repo_root);
|
|
448
|
+
arrangement.unfold(current.cwd);
|
|
449
|
+
list.select((t) => t.kind === "session" && t.id === current.id);
|
|
450
|
+
}
|
|
451
|
+
await draw();
|
|
452
|
+
};
|
|
453
|
+
|
|
454
|
+
return {
|
|
455
|
+
view,
|
|
456
|
+
here,
|
|
457
|
+
buf,
|
|
458
|
+
list,
|
|
459
|
+
count,
|
|
460
|
+
draw,
|
|
461
|
+
open,
|
|
462
|
+
close,
|
|
463
|
+
enter,
|
|
464
|
+
leave,
|
|
465
|
+
isOpen: () => win !== null,
|
|
466
|
+
isRunning: () => running,
|
|
467
|
+
width: () => panelWidth,
|
|
468
|
+
setWidth: (n: number) => {
|
|
469
|
+
panelWidth = n;
|
|
470
|
+
if (win !== null) void neosh.win.resize(win, n).then(draw).catch(() => {});
|
|
471
|
+
},
|
|
472
|
+
height: async () => {
|
|
473
|
+
if (win === null) return 20;
|
|
474
|
+
const v = await neosh.win.viewport(win).catch(() => null);
|
|
475
|
+
return v?.height ?? 20;
|
|
476
|
+
},
|
|
477
|
+
dispose: () => {
|
|
478
|
+
capture?.dispose();
|
|
479
|
+
capture = null;
|
|
480
|
+
// Not closed here: the terminal has gone and the host took its windows with it. Closing a
|
|
481
|
+
// window that is already gone is an error message about nothing.
|
|
482
|
+
win = null;
|
|
483
|
+
},
|
|
484
|
+
};
|
|
485
|
+
};
|
|
486
|
+
|
|
487
|
+
/** The panel in a named terminal, or the one being served when nothing is named. */
|
|
488
|
+
const panel = (view?: ViewId): Panel | null => {
|
|
489
|
+
if (view !== undefined) return panels.get(view) ?? null;
|
|
490
|
+
// A command run by name rather than by key — `^K`, a plugin — names no terminal. There is
|
|
491
|
+
// usually one, and when there is not, the one that has a window open is the one somebody is
|
|
492
|
+
// looking at.
|
|
493
|
+
const all = [...panels.values()];
|
|
494
|
+
return all.find((p) => p.isOpen()) ?? all[0] ?? null;
|
|
495
|
+
};
|
|
496
|
+
|
|
497
|
+
const each = (fn: (p: Panel) => unknown) => {
|
|
498
|
+
for (const p of panels.values()) void fn(p);
|
|
499
|
+
};
|
|
500
|
+
|
|
501
|
+
const drawAll = () => each((p) => p.draw());
|
|
502
|
+
// The module-level `api`, filled in now that there is something behind it. "The" cursor with
|
|
503
|
+
// several terminals is the served panel's — the same answer every by-name command gives.
|
|
504
|
+
api.cursor = () => panel()?.list.value ?? null;
|
|
505
|
+
api.rows = () => panel()?.list.values ?? [];
|
|
506
|
+
api.refresh = async () => drawAll();
|
|
507
|
+
|
|
508
|
+
// The verbs other plugins put on our rows. Bound here rather than by them so the row under the
|
|
509
|
+
// cursor can be handed along; see `installActions`.
|
|
510
|
+
const installed = installActions(neosh, panel, drawAll);
|
|
511
|
+
const actions = installed.actions;
|
|
512
|
+
subscriptions.push(installed.dispose);
|
|
513
|
+
|
|
514
|
+
await registerCommands({ neosh, subscriptions, arrangement, panel, each });
|
|
515
|
+
|
|
516
|
+
// Text changes: a turn started or ended, the conversation changed, a setting moved.
|
|
517
|
+
subscriptions.push(neosh.agent.onTurnStart(drawAll));
|
|
518
|
+
subscriptions.push(neosh.agent.onTurnEnd(drawAll));
|
|
519
|
+
subscriptions.push(neosh.agent.onToolStart(drawAll));
|
|
520
|
+
subscriptions.push(neosh.session.onChange(drawAll));
|
|
521
|
+
// What a conversation is still running after its turn ended. The only state on these rows that
|
|
522
|
+
// moves without a turn starting or ending — that is the whole point of it — so nothing else this
|
|
523
|
+
// panel already listens to would ever bring it. Filtered to the one kind, because a driver
|
|
524
|
+
// reports its context size every few seconds and redrawing the column for that is a column that
|
|
525
|
+
// redraws for nothing.
|
|
526
|
+
subscriptions.push(
|
|
527
|
+
neosh.agent.onActivity((e) => {
|
|
528
|
+
if (e.activity.kind === "background") drawAll();
|
|
529
|
+
}),
|
|
530
|
+
);
|
|
531
|
+
// Somebody pinned, folded or tagged a project — possibly us, possibly a plugin that has never
|
|
532
|
+
// heard of this one. Both arrive here, and the arrangement takes them in the same way.
|
|
533
|
+
subscriptions.push(
|
|
534
|
+
neosh.vars.onChange((e) => {
|
|
535
|
+
if (arrangement.observe(e.scope, e.key, e.value)) drawAll();
|
|
536
|
+
// Somebody started waiting on an answer, or stopped. Deleting the var is how "nobody is
|
|
537
|
+
// asking" arrives, and it comes through here with `value` unset rather than as an empty list.
|
|
538
|
+
if (e.scope.scope === "global" && (e.key === VAR_ASKING || e.key === VAR_PERMITTING)) {
|
|
539
|
+
if (e.key === VAR_ASKING) waiting.questions = asked(e.value);
|
|
540
|
+
else waiting.permissions = asked(e.value);
|
|
541
|
+
asking = new Set([...waiting.questions, ...waiting.permissions]);
|
|
542
|
+
drawAll();
|
|
543
|
+
}
|
|
544
|
+
}),
|
|
545
|
+
);
|
|
546
|
+
// Rows somebody contributed came or went. Redrawing on this is what stops a plugin that loads
|
|
547
|
+
// after us contributing rows nobody sees until the next unrelated refresh.
|
|
548
|
+
subscriptions.push(
|
|
549
|
+
neosh.ext.onChange((e) => {
|
|
550
|
+
if (e.point === POINT_SECTION || e.point === POINT_DECORATION) drawAll();
|
|
551
|
+
}),
|
|
552
|
+
);
|
|
553
|
+
subscriptions.push(
|
|
554
|
+
neosh.opt.onChange((e) => {
|
|
555
|
+
if (e.name === "ui.motion" || e.name === "ui.ascii_only") {
|
|
556
|
+
void applyMotion().then(drawAll);
|
|
557
|
+
} else if (e.name === "sidebar.width") {
|
|
558
|
+
// In place. Reopening was how this used to work, and it gave the panel a new window id and
|
|
559
|
+
// dropped whatever had the keyboard — so resizing from inside the panel threw the cursor
|
|
560
|
+
// back to the composer on every press.
|
|
561
|
+
const n = typeof e.value === "number" ? e.value : null;
|
|
562
|
+
if (n !== null) each((p) => p.setWidth(n));
|
|
563
|
+
} else if (e.name.startsWith("sidebar.") || e.name === "ui.theme") {
|
|
564
|
+
drawAll();
|
|
565
|
+
}
|
|
566
|
+
}),
|
|
567
|
+
);
|
|
568
|
+
|
|
569
|
+
// Attributes change: the shared 100 ms clock, and only while a turn is in flight. An idle
|
|
570
|
+
// workspace has no timer at all.
|
|
571
|
+
subscriptions.push(
|
|
572
|
+
onTick(() => {
|
|
573
|
+
each((p) => {
|
|
574
|
+
if (p.isOpen() && p.isRunning()) void p.draw();
|
|
575
|
+
});
|
|
576
|
+
}),
|
|
577
|
+
);
|
|
578
|
+
|
|
579
|
+
await installFooter(neosh, subscriptions);
|
|
580
|
+
|
|
581
|
+
const period = (await neosh.opt.get<number>("sidebar.refresh_ms")) ?? 4000;
|
|
582
|
+
subscriptions.push(neosh.timer.every(period, drawAll));
|
|
583
|
+
|
|
584
|
+
// One panel per terminal, and one for every terminal that was already here when this plugin
|
|
585
|
+
// loaded. `sidebar.open` decides whether it starts showing, per view rather than once for the
|
|
586
|
+
// workspace: `^B` in one window is `^B` in that window.
|
|
587
|
+
const startOpen = (await neosh.opt.get<boolean>("sidebar.open")) ?? true;
|
|
588
|
+
subscriptions.push(
|
|
589
|
+
neosh.view.onOpen((view) => {
|
|
590
|
+
void (async () => {
|
|
591
|
+
if (panels.has(view)) return;
|
|
592
|
+
const made = await makePanel(view);
|
|
593
|
+
panels.set(view, made);
|
|
594
|
+
if (startOpen) await made.open();
|
|
595
|
+
})().catch((e: unknown) => {
|
|
596
|
+
// A panel that cannot be built is one terminal without a column, not a workspace without
|
|
597
|
+
// plugins. Unhandled, the rejection stops the runtime and takes every other plugin with
|
|
598
|
+
// it — which is what a reload racing this used to do.
|
|
599
|
+
panels.delete(view);
|
|
600
|
+
neosh.log.warn(`sidebar: ${String(e)}`);
|
|
601
|
+
});
|
|
602
|
+
}),
|
|
603
|
+
);
|
|
604
|
+
subscriptions.push(
|
|
605
|
+
neosh.view.onClose((view) => {
|
|
606
|
+
// The windows went with the terminal; what is left is what we were keeping about them, which
|
|
607
|
+
// would otherwise be a capture pointed at a window that no longer exists.
|
|
608
|
+
panels.get(view)?.dispose();
|
|
609
|
+
panels.delete(view);
|
|
610
|
+
}),
|
|
611
|
+
);
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
function same(a: Target, b: Target): boolean {
|
|
615
|
+
if (a.kind === "remote") {
|
|
616
|
+
return b.kind === "remote" && a.node === b.node && a.session === b.session;
|
|
617
|
+
}
|
|
618
|
+
if (a.kind === "session") return b.kind === "session" && a.id === b.id;
|
|
619
|
+
if (a.kind === "project") return b.kind === "project" && a.cwd === b.cwd;
|
|
620
|
+
return a.kind === b.kind;
|
|
621
|
+
}
|
|
622
|
+
|
|
623
|
+
async function declareOptions(neosh: Neosh): Promise<void> {
|
|
624
|
+
await neosh.opt.declare({
|
|
625
|
+
name: "sidebar.open",
|
|
626
|
+
type: { type: "bool" },
|
|
627
|
+
default: true,
|
|
628
|
+
description: "Show the sidebar at startup.",
|
|
629
|
+
});
|
|
630
|
+
await neosh.opt.declare({
|
|
631
|
+
name: "sidebar.width",
|
|
632
|
+
type: { type: "int", min: 16, max: 120 },
|
|
633
|
+
default: 34,
|
|
634
|
+
description: "Sidebar width in columns.",
|
|
635
|
+
});
|
|
636
|
+
await neosh.opt.declare({
|
|
637
|
+
name: "sidebar.hints",
|
|
638
|
+
type: { type: "bool" },
|
|
639
|
+
default: true,
|
|
640
|
+
description:
|
|
641
|
+
"Show the keys for whatever the cursor is on, at the foot of the panel. Turn it off once they are in your fingers.",
|
|
642
|
+
});
|
|
643
|
+
await neosh.opt.declare({
|
|
644
|
+
name: "sidebar.refresh_ms",
|
|
645
|
+
type: { type: "int", min: 200, max: 60000 },
|
|
646
|
+
default: 4000,
|
|
647
|
+
description: "How often to re-read the workspace when nothing has told us it changed.",
|
|
648
|
+
});
|
|
649
|
+
}
|
|
650
|
+
|
|
651
|
+
// ---------------------------------------------------------------------------
|
|
652
|
+
// Arrangement
|
|
653
|
+
// ---------------------------------------------------------------------------
|
|
654
|
+
|
|
655
|
+
/**
|
|
656
|
+
* Which projects are pinned, what order you put them in, and which ones are folded.
|
|
657
|
+
*
|
|
658
|
+
* All of it in **project vars**, which is the difference between an arrangement and a private
|
|
659
|
+
* arrangement: a var is scoped to the project it describes and anybody may read or write it, so a
|
|
660
|
+
* second panel sees the same favourites and setting `sidebar.favorite` from your own plugin is a
|
|
661
|
+
* one-line way to pin something.
|
|
662
|
+
*
|
|
663
|
+
* Cached in memory and kept current from `vars.onChange`, because a panel redraws several times a
|
|
664
|
+
* second while a turn is running and a round trip per project per frame is not a thing to pay for.
|
|
665
|
+
* The cache is safe precisely *because* the change event is broadcast: every write, ours or
|
|
666
|
+
* anybody's, comes back through the same door.
|
|
667
|
+
*
|
|
668
|
+
* Not options, for the reason it has never been options: an option is configuration *you* write,
|
|
669
|
+
* and an editor that rewrites your config file because you pressed `f` is one you stop trusting
|
|
670
|
+
* with the file.
|
|
671
|
+
*/
|
|
672
|
+
class Arrangement {
|
|
673
|
+
/** Every project we know of, and its vars. The cache is the read path; vars are the truth. */
|
|
674
|
+
private cache = new Map<string, Record<string, unknown>>();
|
|
675
|
+
private known: string[] = [];
|
|
676
|
+
|
|
677
|
+
constructor(private readonly neosh: Neosh) {}
|
|
678
|
+
|
|
679
|
+
async load(cwds: string[]): Promise<void> {
|
|
680
|
+
const stored = await this.neosh.vars
|
|
681
|
+
.get<string[]>({ scope: "global" }, VAR_KNOWN)
|
|
682
|
+
.catch(() => null);
|
|
683
|
+
this.known = unique([...strings(stored), ...cwds]);
|
|
684
|
+
const all = await Promise.all(
|
|
685
|
+
this.known.map((cwd) =>
|
|
686
|
+
this.neosh.vars.all(projectScope(cwd)).catch(() => ({} as Record<string, unknown>))
|
|
687
|
+
),
|
|
688
|
+
);
|
|
689
|
+
this.known.forEach((cwd, i) => this.cache.set(cwd, all[i] ?? {}));
|
|
690
|
+
if (stored === null) await this.migrate();
|
|
691
|
+
// Written back whenever the startup list added to it — including the very first start, when
|
|
692
|
+
// nothing was stored. The var is the list other plugins are told to read (a decorator asks it
|
|
693
|
+
// which projects to decorate), and a list that only reached the disk once a *second* project
|
|
694
|
+
// appeared was a list that said nothing on the day it mattered most.
|
|
695
|
+
if (this.known.length !== strings(stored).length) {
|
|
696
|
+
await this.neosh.vars.set({ scope: "global" }, VAR_KNOWN, this.known);
|
|
697
|
+
}
|
|
698
|
+
}
|
|
699
|
+
|
|
700
|
+
/**
|
|
701
|
+
* Bring across an arrangement made before any of this was shared.
|
|
702
|
+
*
|
|
703
|
+
* These three lived in this plugin's private state until project vars existed. Losing somebody's
|
|
704
|
+
* pins because the store moved underneath them is not an acceptable way to ship an improvement,
|
|
705
|
+
* so the old keys are read once — on the one startup where the index does not exist yet — and
|
|
706
|
+
* written into vars. The old state is left where it is rather than deleted: it costs a few bytes,
|
|
707
|
+
* and a downgrade that finds it still there is a downgrade that still works.
|
|
708
|
+
*/
|
|
709
|
+
private async migrate(): Promise<void> {
|
|
710
|
+
const [favorites, order, folded] = await Promise.all([
|
|
711
|
+
this.neosh.state.get<string[]>("favorites").catch(() => null),
|
|
712
|
+
this.neosh.state.get<string[]>("order").catch(() => null),
|
|
713
|
+
this.neosh.state.get<string[]>("folded").catch(() => null),
|
|
714
|
+
]);
|
|
715
|
+
const pins = strings(favorites);
|
|
716
|
+
const ranks = strings(order);
|
|
717
|
+
const shut = strings(folded);
|
|
718
|
+
if (pins.length === 0 && ranks.length === 0 && shut.length === 0) return;
|
|
719
|
+
|
|
720
|
+
await this.note(unique([...pins, ...ranks, ...shut]));
|
|
721
|
+
await Promise.all([
|
|
722
|
+
...pins.map((cwd) => this.set(cwd, VAR_FAVORITE, true)),
|
|
723
|
+
...ranks.map((cwd, i) => this.set(cwd, VAR_RANK, i)),
|
|
724
|
+
...shut.map((cwd) => this.set(cwd, VAR_FOLDED, true)),
|
|
725
|
+
]);
|
|
726
|
+
this.neosh.log.info(`brought ${this.known.length} project arrangements across to shared vars`);
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
/**
|
|
730
|
+
* Take in a change somebody made — including our own writes, which arrive by the same route.
|
|
731
|
+
*
|
|
732
|
+
* Answers whether it was about a project at all, so a caller can skip a redraw for a var that has
|
|
733
|
+
* nothing to do with this panel.
|
|
734
|
+
*/
|
|
735
|
+
observe(scope: VarScope, key: string, value: unknown): boolean {
|
|
736
|
+
if (scope.scope !== "project") return false;
|
|
737
|
+
const entry = this.cache.get(scope.cwd) ?? {};
|
|
738
|
+
if (value === null || value === undefined) delete entry[key];
|
|
739
|
+
else entry[key] = value;
|
|
740
|
+
this.cache.set(scope.cwd, entry);
|
|
741
|
+
// A project somebody else has just said something about is a project this panel should be
|
|
742
|
+
// showing. Without this, pinning a directory from another plugin sets a var nothing ever reads.
|
|
743
|
+
//
|
|
744
|
+
// A var being *removed* is not somebody saying something about a project — it is the last thing
|
|
745
|
+
// `forget` does, and treating it as an announcement would put the project straight back.
|
|
746
|
+
if (value !== null && value !== undefined && !this.known.includes(scope.cwd)) {
|
|
747
|
+
this.known.push(scope.cwd);
|
|
748
|
+
void this.neosh.vars.set({ scope: "global" }, VAR_KNOWN, this.known);
|
|
749
|
+
}
|
|
750
|
+
return true;
|
|
751
|
+
}
|
|
752
|
+
|
|
753
|
+
/** Remember directories that turned up in the conversation list. */
|
|
754
|
+
async note(cwds: string[]): Promise<void> {
|
|
755
|
+
const fresh = cwds.filter((c) => !this.known.includes(c));
|
|
756
|
+
if (fresh.length === 0) return;
|
|
757
|
+
this.known.push(...fresh);
|
|
758
|
+
await Promise.all(
|
|
759
|
+
fresh.map(async (cwd) => {
|
|
760
|
+
this.cache.set(
|
|
761
|
+
cwd,
|
|
762
|
+
await this.neosh.vars.all(projectScope(cwd)).catch(() => ({})),
|
|
763
|
+
);
|
|
764
|
+
}),
|
|
765
|
+
);
|
|
766
|
+
await this.neosh.vars.set({ scope: "global" }, VAR_KNOWN, this.known);
|
|
767
|
+
}
|
|
768
|
+
|
|
769
|
+
/** Every project, whether or not there is anything going on in it. The panel's list. */
|
|
770
|
+
all(): string[] {
|
|
771
|
+
return [...this.known];
|
|
772
|
+
}
|
|
773
|
+
|
|
774
|
+
/** What this project was called the last time anything knew. `null` when nothing ever did. */
|
|
775
|
+
name(cwd: string): string | null {
|
|
776
|
+
const v = this.cache.get(cwd)?.[VAR_NAME];
|
|
777
|
+
return typeof v === "string" && v !== "" ? v : null;
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/** The checkout this one is a worktree of, if anything ever said so. */
|
|
781
|
+
root(cwd: string): string | null {
|
|
782
|
+
const v = this.cache.get(cwd)?.[VAR_ROOT];
|
|
783
|
+
return typeof v === "string" && v !== "" && v !== cwd ? v : null;
|
|
784
|
+
}
|
|
785
|
+
|
|
786
|
+
/**
|
|
787
|
+
* Write down what the host calls a project, for when it is empty.
|
|
788
|
+
*
|
|
789
|
+
* Called from the draw path with every project that has a conversation to read a name off, so it
|
|
790
|
+
* has to be free when nothing has changed — which it is: the cache is checked first and the
|
|
791
|
+
* common case is a comparison per project per frame.
|
|
792
|
+
*/
|
|
793
|
+
remember(named: Array<{ cwd: string; name: string; root?: string }>): void {
|
|
794
|
+
for (const { cwd, name, root } of named) {
|
|
795
|
+
if (name !== "" && this.name(cwd) !== name) void this.set(cwd, VAR_NAME, name);
|
|
796
|
+
if (root !== undefined && root !== cwd && this.root(cwd) !== root) {
|
|
797
|
+
void this.set(cwd, VAR_ROOT, root);
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Take a project out of the list.
|
|
804
|
+
*
|
|
805
|
+
* The one thing that removes one, and the reason the list can be trusted to survive its
|
|
806
|
+
* conversations. Only this panel's own vars go: another plugin's note about the directory is that
|
|
807
|
+
* plugin's to keep, and a directory it still cares about comes back the next time it says so.
|
|
808
|
+
*/
|
|
809
|
+
async forget(cwd: string): Promise<void> {
|
|
810
|
+
this.known = this.known.filter((c) => c !== cwd);
|
|
811
|
+
this.cache.delete(cwd);
|
|
812
|
+
await this.neosh.vars.set({ scope: "global" }, VAR_KNOWN, this.known);
|
|
813
|
+
await Promise.all(
|
|
814
|
+
[VAR_FAVORITE, VAR_RANK, VAR_FOLDED, VAR_NAME, VAR_ROOT].map((key) =>
|
|
815
|
+
this.neosh.vars.remove(projectScope(cwd), key).catch(() => {})
|
|
816
|
+
),
|
|
817
|
+
);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
isFavorite(cwd: string): boolean {
|
|
821
|
+
return this.cache.get(cwd)?.[VAR_FAVORITE] === true;
|
|
822
|
+
}
|
|
823
|
+
|
|
824
|
+
isFolded(cwd: string): boolean {
|
|
825
|
+
return this.cache.get(cwd)?.[VAR_FOLDED] === true;
|
|
826
|
+
}
|
|
827
|
+
|
|
828
|
+
/** Where a project sorts. Anything you have never moved sorts after everything you have. */
|
|
829
|
+
rank(cwd: string): number {
|
|
830
|
+
const v = this.cache.get(cwd)?.[VAR_RANK];
|
|
831
|
+
return typeof v === "number" && Number.isFinite(v) ? v : Number.MAX_SAFE_INTEGER;
|
|
832
|
+
}
|
|
833
|
+
|
|
834
|
+
async toggleFavorite(cwd: string): Promise<boolean> {
|
|
835
|
+
const next = !this.isFavorite(cwd);
|
|
836
|
+
await this.set(cwd, VAR_FAVORITE, next ? true : null);
|
|
837
|
+
return next;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
async toggleFold(cwd: string): Promise<void> {
|
|
841
|
+
await this.set(cwd, VAR_FOLDED, this.isFolded(cwd) ? null : true);
|
|
842
|
+
}
|
|
843
|
+
|
|
844
|
+
/** Open a project without a keystroke — used when jumping to the conversation you are in. */
|
|
845
|
+
unfold(cwd: string): void {
|
|
846
|
+
if (!this.isFolded(cwd)) return;
|
|
847
|
+
void this.set(cwd, VAR_FOLDED, null);
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/**
|
|
851
|
+
* Move a project one place within its own group.
|
|
852
|
+
*
|
|
853
|
+
* Every rank in the group is written back, not just the two that swapped. Without that, the first
|
|
854
|
+
* `K` on a project you have never moved would reorder against ranks that do not exist and jump
|
|
855
|
+
* somewhere you did not ask for — the list has to mean what is on screen before it can be
|
|
856
|
+
* rearranged.
|
|
857
|
+
*
|
|
858
|
+
* No wrapping: a reorder that teleports the thing you are dragging to the far end is a reorder
|
|
859
|
+
* you undo.
|
|
860
|
+
*/
|
|
861
|
+
async move(groups: Project[][], cwd: string, delta: number): Promise<boolean> {
|
|
862
|
+
// A worktree moves among its siblings under the same repository; a project among its group.
|
|
863
|
+
const group: Project[] | undefined =
|
|
864
|
+
groups.flat().find((p) => p.worktrees.some((t) => t.cwd === cwd))?.worktrees ??
|
|
865
|
+
groups.find((g) => g.some((p) => p.cwd === cwd));
|
|
866
|
+
if (!group) return false;
|
|
867
|
+
const from = group.findIndex((p) => p.cwd === cwd);
|
|
868
|
+
const to = from + delta;
|
|
869
|
+
if (to < 0 || to >= group.length) return false;
|
|
870
|
+
|
|
871
|
+
const moved = group.map((p) => p.cwd);
|
|
872
|
+
const [held] = moved.splice(from, 1);
|
|
873
|
+
moved.splice(to, 0, held!);
|
|
874
|
+
await Promise.all(moved.map((c, i) => this.set(c, VAR_RANK, i)));
|
|
875
|
+
return true;
|
|
876
|
+
}
|
|
877
|
+
|
|
878
|
+
/** One write, straight through the cache so the next frame is right without waiting for the event. */
|
|
879
|
+
private async set(cwd: string, key: string, value: unknown): Promise<void> {
|
|
880
|
+
const entry = this.cache.get(cwd) ?? {};
|
|
881
|
+
if (value === null) {
|
|
882
|
+
delete entry[key];
|
|
883
|
+
this.cache.set(cwd, entry);
|
|
884
|
+
await this.neosh.vars.remove(projectScope(cwd), key);
|
|
885
|
+
return;
|
|
886
|
+
}
|
|
887
|
+
entry[key] = value;
|
|
888
|
+
this.cache.set(cwd, entry);
|
|
889
|
+
await this.neosh.vars.set(projectScope(cwd), key, value);
|
|
890
|
+
}
|
|
891
|
+
}
|
|
892
|
+
|
|
893
|
+
function strings(v: unknown): string[] {
|
|
894
|
+
return Array.isArray(v) ? v.filter((x): x is string => typeof x === "string") : [];
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
function unique(v: string[]): string[] {
|
|
898
|
+
return [...new Set(v)];
|
|
899
|
+
}
|
|
900
|
+
|
|
901
|
+
// ---------------------------------------------------------------------------
|
|
902
|
+
// Keys
|
|
903
|
+
// ---------------------------------------------------------------------------
|
|
904
|
+
|
|
905
|
+
/**
|
|
906
|
+
* One terminal's panel.
|
|
907
|
+
*
|
|
908
|
+
* Everything here is per view because all of it is navigation: which row the cursor is on, whether
|
|
909
|
+
* the column is showing, how wide it is, a count half typed. The buffer is per view too, since the
|
|
910
|
+
* cursor and the unfolded row are drawn into its text — two terminals reading one project list is
|
|
911
|
+
* the same list rendered twice, and `j` in one must not move the other.
|
|
912
|
+
*/
|
|
913
|
+
interface Panel {
|
|
914
|
+
readonly view: ViewId;
|
|
915
|
+
/** The whole API, bound to this terminal. A window opened through it lands here. */
|
|
916
|
+
readonly here: Neosh;
|
|
917
|
+
readonly buf: BufferId;
|
|
918
|
+
readonly list: CursoredList<Target>;
|
|
919
|
+
/**
|
|
920
|
+
* A count typed before a motion — `5j`, `12G`.
|
|
921
|
+
*
|
|
922
|
+
* State between two keystrokes, so it lives beside the panel rather than in the key table, and
|
|
923
|
+
* it is drawn in the hint strip: a count you cannot see is a keypress that appears to have done
|
|
924
|
+
* nothing at all.
|
|
925
|
+
*/
|
|
926
|
+
readonly count: { pending: string };
|
|
927
|
+
draw(): Promise<void>;
|
|
928
|
+
open(): Promise<void>;
|
|
929
|
+
close(): Promise<void>;
|
|
930
|
+
enter(): Promise<void>;
|
|
931
|
+
leave(): Promise<void>;
|
|
932
|
+
isOpen(): boolean;
|
|
933
|
+
/** Whether anything in this panel's list has a turn in flight, for the animation tick. */
|
|
934
|
+
isRunning(): boolean;
|
|
935
|
+
width(): number;
|
|
936
|
+
setWidth(n: number): void;
|
|
937
|
+
/** How many rows of panel there are, for the two keys that mean "a screen". */
|
|
938
|
+
height(): Promise<number>;
|
|
939
|
+
dispose(): void;
|
|
940
|
+
}
|
|
941
|
+
|
|
942
|
+
interface Wiring {
|
|
943
|
+
neosh: Neosh;
|
|
944
|
+
subscriptions: PluginContext["subscriptions"];
|
|
945
|
+
arrangement: Arrangement;
|
|
946
|
+
/**
|
|
947
|
+
* The panel a key was pressed in.
|
|
948
|
+
*
|
|
949
|
+
* Every verb in this file starts here. A command run by name rather than by key names no
|
|
950
|
+
* terminal, and then it is whichever one has the panel open — there is usually exactly one, and
|
|
951
|
+
* "the one somebody is looking at" is the only answer that is ever right.
|
|
952
|
+
*/
|
|
953
|
+
panel(view?: ViewId): Panel | null;
|
|
954
|
+
/** Do something in every terminal's panel. */
|
|
955
|
+
each(fn: (p: Panel) => unknown): void;
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
async function registerCommands(w: Wiring): Promise<void> {
|
|
959
|
+
const { neosh, arrangement } = w;
|
|
960
|
+
|
|
961
|
+
/** Rebuild the grouping the arrangement reorders against, without redrawing. */
|
|
962
|
+
const groups = async (): Promise<Project[][]> => {
|
|
963
|
+
const sessions = await neosh.session.list().catch(() => [] as SessionInfo[]);
|
|
964
|
+
return group(sessions, arrangement, new Map());
|
|
965
|
+
};
|
|
966
|
+
|
|
967
|
+
/**
|
|
968
|
+
* Register one verb, and bind it inside this panel.
|
|
969
|
+
*
|
|
970
|
+
* Every key in this list goes through here, which is the point: there is no private key handler
|
|
971
|
+
* any more, so `^Z` lists the panel's keys with the rest, `^K` runs them, and
|
|
972
|
+
* `keymap.set("chat", "d", "…", { scope: { kind: "buf_kind", name: "neosh.sidebar" } })` from
|
|
973
|
+
* anybody's `init.ts` replaces one. The scope is the buffer kind rather than the window, because
|
|
974
|
+
* the window is opened and closed as you toggle the panel and a binding on it would go with it.
|
|
975
|
+
*
|
|
976
|
+
* `redraw` defaults to true because most verbs change what the column says. The ones that leave
|
|
977
|
+
* the panel — or open a picker that redraws on the way back — pass false, since drawing into a
|
|
978
|
+
* window that is losing focus is a frame of the wrong thing.
|
|
979
|
+
*/
|
|
980
|
+
const verb = async (
|
|
981
|
+
name: string,
|
|
982
|
+
key: string | null,
|
|
983
|
+
desc: string,
|
|
984
|
+
fn: (p: Panel, target: Target | undefined, args: string[]) => Promise<void> | void,
|
|
985
|
+
opts: { redraw?: boolean } = {},
|
|
986
|
+
): Promise<void> => {
|
|
987
|
+
w.subscriptions.push(
|
|
988
|
+
// The panel the key was pressed in. Every verb below acts on one terminal's column — its
|
|
989
|
+
// cursor, its fold state, its half-typed count — and with several open, "the panel" is not
|
|
990
|
+
// an answer. A command run by name rather than by key names no terminal and gets whichever
|
|
991
|
+
// one is showing.
|
|
992
|
+
await neosh.cmd.register(name, async (args, key) => {
|
|
993
|
+
const p = w.panel(key?.view);
|
|
994
|
+
if (p === null) return;
|
|
995
|
+
await fn(p, p.list.value, args);
|
|
996
|
+
// A count belongs to the motion typed straight after it. Anything else ends it — otherwise
|
|
997
|
+
// a `5` you thought better of sits there and turns the next `j` into five.
|
|
998
|
+
p.count.pending = "";
|
|
999
|
+
if (opts.redraw !== false) await p.draw();
|
|
1000
|
+
}, { desc }),
|
|
1001
|
+
);
|
|
1002
|
+
if (key !== null) {
|
|
1003
|
+
await neosh.keymap.set("chat", key, name, {
|
|
1004
|
+
scope: { kind: "buf_kind", name: KIND },
|
|
1005
|
+
desc,
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
};
|
|
1009
|
+
|
|
1010
|
+
// ---- moving ----
|
|
1011
|
+
//
|
|
1012
|
+
// Vim's motions, because this is a cursor in a column in a terminal and that is the vocabulary
|
|
1013
|
+
// everybody's hands already have. A count works the way it does there — `5j` is five rows, `3G`
|
|
1014
|
+
// is the third — and it counts *rows you can land on*, so the headings and rules the cursor
|
|
1015
|
+
// already skips do not silently eat two of the five.
|
|
1016
|
+
const scope = { kind: "buf_kind", name: KIND } as const;
|
|
1017
|
+
const also = async (key: string, command: string, desc: string): Promise<void> => {
|
|
1018
|
+
await neosh.keymap.set("chat", key, command, { scope, desc });
|
|
1019
|
+
};
|
|
1020
|
+
|
|
1021
|
+
/** Take the count that was typed in one panel, if one was, and forget it. */
|
|
1022
|
+
const take = (p: Panel, fallback = 1): number => {
|
|
1023
|
+
const n = Number.parseInt(p.count.pending, 10);
|
|
1024
|
+
p.count.pending = "";
|
|
1025
|
+
// Bounded, because the count is typed and `999999j` is a keystroke that would walk the list a
|
|
1026
|
+
// million times before drawing anything.
|
|
1027
|
+
return Number.isFinite(n) && n > 0 ? Math.min(n, 999) : fallback;
|
|
1028
|
+
};
|
|
1029
|
+
|
|
1030
|
+
await verb(`${NS}.down`, "j", "Next row", (p) => void p.list.move(take(p)));
|
|
1031
|
+
await verb(`${NS}.up`, "k", "Previous row", (p) => void p.list.move(-take(p)));
|
|
1032
|
+
await also("<Down>", `${NS}.down`, "Next row");
|
|
1033
|
+
await also("<Up>", `${NS}.up`, "Previous row");
|
|
1034
|
+
await also("<C-n>", `${NS}.down`, "Next row");
|
|
1035
|
+
await also("<C-p>", `${NS}.up`, "Previous row");
|
|
1036
|
+
|
|
1037
|
+
// A screen is however tall the panel turned out to be, which only the frontend knows — asked for
|
|
1038
|
+
// rather than assumed, the same way every other display measurement here is. Half a screen for
|
|
1039
|
+
// `^D`/`^U` because that is what it is everywhere, and a whole one loses the row you were on.
|
|
1040
|
+
const by = (fraction: number, sign: 1 | -1) => async (p: Panel): Promise<void> => {
|
|
1041
|
+
const rows = Math.max(2, await p.height());
|
|
1042
|
+
const step = Math.max(1, Math.floor(rows * fraction)) * take(p);
|
|
1043
|
+
// No wrapping on a page step: `^D` at the foot of the list means there is no more of it, and a
|
|
1044
|
+
// cursor that reappears at the top has thrown away the place you were reading from.
|
|
1045
|
+
p.list.move(sign * step, { wrap: false });
|
|
1046
|
+
};
|
|
1047
|
+
await verb(`${NS}.half.down`, "<C-d>", "Half a screen down", by(0.5, 1));
|
|
1048
|
+
await verb(`${NS}.half.up`, "<C-u>", "Half a screen up", by(0.5, -1));
|
|
1049
|
+
// A whole screen is `PgUp`/`PgDn` and deliberately *not* `^F`/`^B`. Those two are `^F` archive
|
|
1050
|
+
// and `^B` hide the panel — both of which somebody in this panel is more likely to want than a
|
|
1051
|
+
// page step in a column that is rarely taller than one screen, and a key that means something
|
|
1052
|
+
// else only while the cursor happens to be here is the worst kind of key.
|
|
1053
|
+
await verb(`${NS}.page.down`, "<PageDown>", "A screen down", by(1, 1));
|
|
1054
|
+
await verb(`${NS}.page.up`, "<PageUp>", "A screen up", by(1, -1));
|
|
1055
|
+
|
|
1056
|
+
/** The count, when a verb wants the number itself rather than a repetition. */
|
|
1057
|
+
const typed = (p: Panel): number | null => {
|
|
1058
|
+
const n = Number.parseInt(p.count.pending, 10);
|
|
1059
|
+
p.count.pending = "";
|
|
1060
|
+
return Number.isFinite(n) && n > 0 ? n : null;
|
|
1061
|
+
};
|
|
1062
|
+
// With a count these are a *row* — `5gg` and `5G` are both the fifth — and without one they are
|
|
1063
|
+
// the two ends, exactly as in Vim.
|
|
1064
|
+
await verb(`${NS}.top`, "gg", "The first row, or the n-th with a count", (p) => {
|
|
1065
|
+
const n = typed(p);
|
|
1066
|
+
if (n === null) p.list.toEnd("first");
|
|
1067
|
+
else p.list.nth(n);
|
|
1068
|
+
});
|
|
1069
|
+
await verb(`${NS}.bottom`, "G", "The last row, or the n-th with a count", (p) => {
|
|
1070
|
+
const n = typed(p);
|
|
1071
|
+
if (n === null) p.list.toEnd("last");
|
|
1072
|
+
else p.list.nth(n);
|
|
1073
|
+
});
|
|
1074
|
+
|
|
1075
|
+
/**
|
|
1076
|
+
* A digit, before a motion.
|
|
1077
|
+
*
|
|
1078
|
+
* One command for all ten, reading which digit it was off the key that ran it — so `^Z` lists it
|
|
1079
|
+
* once and `init.ts` can move it, rather than ten near-identical verbs. A leading `0` is not a
|
|
1080
|
+
* count anywhere and is left to whatever else might want the key.
|
|
1081
|
+
*/
|
|
1082
|
+
w.subscriptions.push(
|
|
1083
|
+
await neosh.cmd.register(`${NS}.count`, async (_args, key) => {
|
|
1084
|
+
const p = w.panel(key?.view);
|
|
1085
|
+
const code = key?.key.code;
|
|
1086
|
+
if (p === null || code?.kind !== "char") return;
|
|
1087
|
+
if (p.count.pending === "" && code.c === "0") return;
|
|
1088
|
+
// Bounded while it is being typed, not only when it is read: three digits is more rows than
|
|
1089
|
+
// this panel will ever have, and it stops a leant-on key growing a string forever.
|
|
1090
|
+
if (p.count.pending.length < 3) p.count.pending += code.c;
|
|
1091
|
+
// Drawn straight away — the strip is the only thing saying the digit landed anywhere.
|
|
1092
|
+
await p.draw();
|
|
1093
|
+
}, { desc: "Begin a count for the next motion" }),
|
|
1094
|
+
);
|
|
1095
|
+
for (const digit of "0123456789") {
|
|
1096
|
+
await also(digit, `${NS}.count`, "Count for the next motion");
|
|
1097
|
+
}
|
|
1098
|
+
|
|
1099
|
+
// ---- how wide the column is ----
|
|
1100
|
+
//
|
|
1101
|
+
// A resize rather than a reopen: reopening gives the window a new id and drops whatever had the
|
|
1102
|
+
// keyboard, so the panel would throw you back to the composer on every press. The width is the
|
|
1103
|
+
// *setting*, so it is the same number `config.toml` sets and one place decides it.
|
|
1104
|
+
const resize = (delta: number) => async (p: Panel): Promise<void> => {
|
|
1105
|
+
const now = (await neosh.opt.get<number>("sidebar.width")) ?? 34;
|
|
1106
|
+
const want = Math.max(16, Math.min(120, now + delta * take(p)));
|
|
1107
|
+
if (want !== now) await neosh.opt.set("sidebar.width", want);
|
|
1108
|
+
};
|
|
1109
|
+
await verb(`${NS}.wider`, ">", "Widen the panel", resize(2), { redraw: false });
|
|
1110
|
+
await verb(`${NS}.narrower`, "<lt>", "Narrow the panel", resize(-2), { redraw: false });
|
|
1111
|
+
await verb(`${NS}.width.reset`, "=", "Back to the default width", async () => {
|
|
1112
|
+
await neosh.opt.reset("sidebar.width");
|
|
1113
|
+
}, { redraw: false });
|
|
1114
|
+
|
|
1115
|
+
// ---- leaving ----
|
|
1116
|
+
await verb(`${NS}.leave`, "<Esc>", "Back to the composer", (p) => p.leave(), { redraw: false });
|
|
1117
|
+
await neosh.keymap.set("chat", "q", `${NS}.leave`, { scope: { kind: "buf_kind", name: KIND } });
|
|
1118
|
+
await neosh.keymap.set("chat", "<C-c>", `${NS}.leave`, { scope: { kind: "buf_kind", name: KIND } });
|
|
1119
|
+
|
|
1120
|
+
// ---- the row under the cursor ----
|
|
1121
|
+
await verb(
|
|
1122
|
+
`${NS}.select`,
|
|
1123
|
+
"<CR>",
|
|
1124
|
+
"Open a conversation, fold a project, or run the row",
|
|
1125
|
+
(p, target) => activateTarget(neosh, arrangement, target, p),
|
|
1126
|
+
{ redraw: false },
|
|
1127
|
+
);
|
|
1128
|
+
await verb(`${NS}.fold`, "<Space>", "Fold or unfold this project", async (p, target) => {
|
|
1129
|
+
if (target?.kind !== "project") return;
|
|
1130
|
+
await arrangement.toggleFold(target.cwd);
|
|
1131
|
+
});
|
|
1132
|
+
await verb(`${NS}.favorite`, "f", "Pin this project to the top", async (p, target) => {
|
|
1133
|
+
let cwd = owningProject(target);
|
|
1134
|
+
if (cwd === null) return;
|
|
1135
|
+
// Pinning is about the top of the column, and a worktree is never at the top of the column —
|
|
1136
|
+
// `f` on one pins the repository it belongs to, the same way `f` on a conversation pins the
|
|
1137
|
+
// project it is in.
|
|
1138
|
+
const inside = (await neosh.session.list().catch(() => [] as SessionInfo[]))
|
|
1139
|
+
.find((s) => s.cwd === cwd && s.repo_root && s.repo_root !== s.cwd);
|
|
1140
|
+
if (inside?.repo_root) cwd = inside.repo_root;
|
|
1141
|
+
// No message. The row moves to the top of the column and gains a pin, in the panel the cursor
|
|
1142
|
+
// is already in — saying so in the corner as well is the same fact twice, and a corner that is
|
|
1143
|
+
// usually restating something visible is one people stop reading.
|
|
1144
|
+
await arrangement.toggleFavorite(cwd);
|
|
1145
|
+
});
|
|
1146
|
+
|
|
1147
|
+
// Shift moves the thing rather than the cursor — the one convention every list that can be
|
|
1148
|
+
// rearranged already shares.
|
|
1149
|
+
//
|
|
1150
|
+
// From a conversation row it moves the project that conversation is in. Requiring the cursor to
|
|
1151
|
+
// be on the heading first made the feature invisible: you are looking at the project when you are
|
|
1152
|
+
// looking at what is inside it.
|
|
1153
|
+
const reorder = (delta: number) => async (_p: Panel, target: Target | undefined) => {
|
|
1154
|
+
const cwd = owningProject(target);
|
|
1155
|
+
if (cwd === null) return;
|
|
1156
|
+
await arrangement.move(await groups(), cwd, delta);
|
|
1157
|
+
};
|
|
1158
|
+
await verb(`${NS}.move.down`, "J", "Move this project down", reorder(1));
|
|
1159
|
+
await verb(`${NS}.move.up`, "K", "Move this project up", reorder(-1));
|
|
1160
|
+
|
|
1161
|
+
await verb(`${NS}.rename`, "r", "Rename this conversation", async (p, target) => {
|
|
1162
|
+
if (target?.kind !== "session") return;
|
|
1163
|
+
await renameSession(neosh, target.id);
|
|
1164
|
+
});
|
|
1165
|
+
// The panel's own copy verb, same letter the transcript uses. What a row is *at* is the thing
|
|
1166
|
+
// you paste into a terminal, an editor or a message, and retyping a generated worktree path is
|
|
1167
|
+
// the kind of chore this panel exists to remove.
|
|
1168
|
+
await verb(`${NS}.copy.path`, "y", "Copy this row's directory", async (p, target) => {
|
|
1169
|
+
const cwd = owningProject(target);
|
|
1170
|
+
if (cwd === null) return;
|
|
1171
|
+
await neosh.edit.copy(cwd);
|
|
1172
|
+
neosh.notify(`copied ${cwd}`);
|
|
1173
|
+
}, { redraw: false });
|
|
1174
|
+
// The everyday verb, and it is reversible. Archiving takes a conversation out of the list without
|
|
1175
|
+
// taking anything away, which is what people were reaching for `x` to do before `x` deleted
|
|
1176
|
+
// things. Where it goes is `a`, not four dim rows at the foot of this panel.
|
|
1177
|
+
await verb(`${NS}.archive`, "x", "Archive this conversation", async (p, target) => {
|
|
1178
|
+
if (target?.kind !== "session") return;
|
|
1179
|
+
await setArchived(neosh, target.id, true);
|
|
1180
|
+
});
|
|
1181
|
+
// Shifted, because this is the one that cannot be undone.
|
|
1182
|
+
//
|
|
1183
|
+
// One key, two rows, and the same sentence: take this off the list for good. On a heading that is
|
|
1184
|
+
// the project — which is the verb that had been missing entirely, and the reason the panel used
|
|
1185
|
+
// to remove a project *for* you the moment you deleted the last thing in it.
|
|
1186
|
+
await verb(
|
|
1187
|
+
`${NS}.delete`,
|
|
1188
|
+
"X",
|
|
1189
|
+
"Delete this conversation, or remove this project",
|
|
1190
|
+
async (p, target) => {
|
|
1191
|
+
if (target?.kind === "project") {
|
|
1192
|
+
await removeProject(neosh, arrangement, target.cwd);
|
|
1193
|
+
return;
|
|
1194
|
+
}
|
|
1195
|
+
if (target?.kind !== "session") return;
|
|
1196
|
+
await deleteSession(neosh, target.id);
|
|
1197
|
+
},
|
|
1198
|
+
);
|
|
1199
|
+
await verb(`${NS}.new`, "n", "New conversation in this project", async (p, target) => {
|
|
1200
|
+
// The same question `^N` asks, about the project you are looking at — which is the whole reason
|
|
1201
|
+
// the cursor is there. It used to create one outright, and that was the bug: `n` and `^N` were
|
|
1202
|
+
// one letter and a modifier apart and did visibly different things, so the only way to know
|
|
1203
|
+
// which one you wanted was to have already learned that they differ. Here is still the first
|
|
1204
|
+
// row, so `n ⏎` is what `n` always did.
|
|
1205
|
+
const cwd = owningProject(target) ?? undefined;
|
|
1206
|
+
await p.leave();
|
|
1207
|
+
await newConversation(neosh, arrangement, cwd);
|
|
1208
|
+
}, { redraw: false });
|
|
1209
|
+
|
|
1210
|
+
// ---- doors out of the panel ----
|
|
1211
|
+
//
|
|
1212
|
+
// `a` is not here. What you have archived is the `archive` plugin's panel, and the key on these
|
|
1213
|
+
// rows that opens it is an `archive.action` contribution — which is this panel's own extension
|
|
1214
|
+
// point, used by somebody else, rather than a door drawn in by hand. Turn that plugin off and the
|
|
1215
|
+
// key goes with it, instead of pointing at a command that is no longer there.
|
|
1216
|
+
await verb(`${NS}.add`, "o", "Add a project", async (p) => {
|
|
1217
|
+
await p.leave();
|
|
1218
|
+
await neosh.cmd.exec("project.open").catch(() => {});
|
|
1219
|
+
}, { redraw: false });
|
|
1220
|
+
await verb(`${NS}.help`, "?", "The keys for this row", async (p) => {
|
|
1221
|
+
await neosh.cmd.exec("help.keys").catch(() => {});
|
|
1222
|
+
}, { redraw: false });
|
|
1223
|
+
|
|
1224
|
+
/**
|
|
1225
|
+
* Where the keys nothing claimed go: nowhere.
|
|
1226
|
+
*
|
|
1227
|
+
* Not a handler — a sink. Without it an unbound letter in this panel falls through as unhandled
|
|
1228
|
+
* and the chat frontend types it into the composer, so pressing `z` here would silently start
|
|
1229
|
+
* writing a message. Every key that *does* something is a binding above; this is only what stops
|
|
1230
|
+
* the rest from doing something somewhere else.
|
|
1231
|
+
*/
|
|
1232
|
+
w.subscriptions.push(
|
|
1233
|
+
await neosh.cmd.register(`${NS}.key`, () => {}, { desc: "Swallow an unbound key in the panel" }),
|
|
1234
|
+
);
|
|
1235
|
+
|
|
1236
|
+
w.subscriptions.push(
|
|
1237
|
+
await neosh.cmd.register("sidebar.toggle", async (_args, key) => {
|
|
1238
|
+
// Toggling visibility, not focus: `^T` is how you get in. In *this* terminal: `^B` is about
|
|
1239
|
+
// the column in front of you and says nothing about anybody else's.
|
|
1240
|
+
const p = w.panel(key?.view);
|
|
1241
|
+
if (p === null) return;
|
|
1242
|
+
if (p.isOpen()) await p.close();
|
|
1243
|
+
else await p.open();
|
|
1244
|
+
}, { desc: "Show or hide the sidebar" }),
|
|
1245
|
+
);
|
|
1246
|
+
w.subscriptions.push(
|
|
1247
|
+
await neosh.cmd.register("sidebar.focus", async (_args, key) => {
|
|
1248
|
+
await w.panel(key?.view)?.enter();
|
|
1249
|
+
}, { desc: "Move into the project list" }),
|
|
1250
|
+
);
|
|
1251
|
+
w.subscriptions.push(
|
|
1252
|
+
await neosh.cmd.register("sidebar.refresh", async (_args, key) => {
|
|
1253
|
+
// By name and from no key, this means "the panel is stale" rather than "this one is", so it
|
|
1254
|
+
// redraws every terminal's.
|
|
1255
|
+
if (key) await w.panel(key.view)?.draw();
|
|
1256
|
+
else w.each((p) => p.draw());
|
|
1257
|
+
}, { desc: "Redraw the sidebar now" }),
|
|
1258
|
+
);
|
|
1259
|
+
// The two questions a plugin on top of this panel asks, as commands so `cmd.call` answers them
|
|
1260
|
+
// without a dependency. What a key press passes as arguments, a call gets as the answer.
|
|
1261
|
+
w.subscriptions.push(
|
|
1262
|
+
await neosh.cmd.register("sidebar.cursor", (_args, key) => {
|
|
1263
|
+
// The terminal the call came from, when it came from one; the served panel's otherwise.
|
|
1264
|
+
return w.panel(key?.view)?.list.value ?? null;
|
|
1265
|
+
}, { desc: "The row under the sidebar's cursor" }),
|
|
1266
|
+
);
|
|
1267
|
+
w.subscriptions.push(
|
|
1268
|
+
await neosh.cmd.register("sidebar.rows", (_args, key) => {
|
|
1269
|
+
return w.panel(key?.view)?.list.values ?? [];
|
|
1270
|
+
}, { desc: "Every row in the sidebar you can land on" }),
|
|
1271
|
+
);
|
|
1272
|
+
w.subscriptions.push(
|
|
1273
|
+
await neosh.cmd.register(
|
|
1274
|
+
"session.new",
|
|
1275
|
+
(args) => newConversation(neosh, arrangement, args[0]),
|
|
1276
|
+
{ desc: "Start a new conversation — here, or in a worktree of its own" },
|
|
1277
|
+
),
|
|
1278
|
+
);
|
|
1279
|
+
w.subscriptions.push(
|
|
1280
|
+
await neosh.cmd.register("session.new.here", async (p) => {
|
|
1281
|
+
await neosh.session.create();
|
|
1282
|
+
}, { desc: "Start a new conversation in this project, without asking where" }),
|
|
1283
|
+
);
|
|
1284
|
+
w.subscriptions.push(
|
|
1285
|
+
await neosh.cmd.register("session.copy.path", async (p) => {
|
|
1286
|
+
// The conversation's directory — which in a worktree is the worktree, and that is the point:
|
|
1287
|
+
// the path you want on the clipboard is the one your shell should cd to.
|
|
1288
|
+
const current = await neosh.session.current().catch(() => null);
|
|
1289
|
+
if (!current) return;
|
|
1290
|
+
await neosh.edit.copy(current.cwd);
|
|
1291
|
+
neosh.notify(`copied ${current.cwd}`);
|
|
1292
|
+
}, { desc: "Copy this conversation's directory to the clipboard" }),
|
|
1293
|
+
);
|
|
1294
|
+
// An Alt chord, which the default-binding rule keeps out of the defaults — on a Mac out of the
|
|
1295
|
+
// box `⌥Y` is `¥`, a key neosh never receives — with the one exception it makes for arrows: bound where
|
|
1296
|
+
// it means something, and never the only way. Chat mode has no Ctrl chord left to give this, and
|
|
1297
|
+
// every terminal-sendable route exists beside it — `yp` in the reader, `y` on any row of this
|
|
1298
|
+
// panel, `^K` and `/copy` by name — so a terminal that sends Alt gets a key in the composer and
|
|
1299
|
+
// one that does not has lost nothing.
|
|
1300
|
+
await neosh.keymap.set("chat", "<A-y>", "session.copy.path", {
|
|
1301
|
+
desc: "Copy this conversation's directory",
|
|
1302
|
+
});
|
|
1303
|
+
w.subscriptions.push(
|
|
1304
|
+
await neosh.cmd.register("session.close", async (args) => {
|
|
1305
|
+
// Through the same gate as the key, so the palette cannot become a way around the question.
|
|
1306
|
+
const id = args[0] ?? (await neosh.session.current().catch(() => null))?.id;
|
|
1307
|
+
if (id) await deleteSession(neosh, id);
|
|
1308
|
+
}, { desc: "Delete a conversation permanently" }),
|
|
1309
|
+
);
|
|
1310
|
+
w.subscriptions.push(
|
|
1311
|
+
await neosh.cmd.register("session.archive", async (args) => {
|
|
1312
|
+
const id = args[0] ?? (await neosh.session.current().catch(() => null))?.id;
|
|
1313
|
+
if (id) await setArchived(neosh, id, true);
|
|
1314
|
+
}, { desc: "Archive a conversation — it keeps everything and can come back" }),
|
|
1315
|
+
);
|
|
1316
|
+
w.subscriptions.push(
|
|
1317
|
+
await neosh.cmd.register("session.unarchive", async (args) => {
|
|
1318
|
+
const id = args[0];
|
|
1319
|
+
if (!id) {
|
|
1320
|
+
neosh.notify("session.unarchive needs a conversation id", "warn");
|
|
1321
|
+
return;
|
|
1322
|
+
}
|
|
1323
|
+
await setArchived(neosh, id, false);
|
|
1324
|
+
}, { desc: "Bring an archived conversation back" }),
|
|
1325
|
+
);
|
|
1326
|
+
w.subscriptions.push(
|
|
1327
|
+
await neosh.cmd.register("project.open", async (args) => {
|
|
1328
|
+
// Without this, a second project only ever appears by way of a worktree — which makes the
|
|
1329
|
+
// whole panel a thread list with extra steps.
|
|
1330
|
+
const path = args[0] ?? (await chooseDirectory(neosh));
|
|
1331
|
+
if (path === null || path.trim() === "") return;
|
|
1332
|
+
try {
|
|
1333
|
+
await neosh.session.create({ cwd: path.trim() });
|
|
1334
|
+
} catch (e) {
|
|
1335
|
+
// Almost always a path that is not there. The host's message names it, which is more
|
|
1336
|
+
// useful than anything this plugin could invent.
|
|
1337
|
+
neosh.notify(String(e), "warn");
|
|
1338
|
+
}
|
|
1339
|
+
}, { desc: "Add a project — start a conversation in another directory" }),
|
|
1340
|
+
);
|
|
1341
|
+
w.subscriptions.push(
|
|
1342
|
+
await neosh.cmd.register("project.remove", async (args, key) => {
|
|
1343
|
+
// The row under the cursor when there is one, so the palette entry does something useful from
|
|
1344
|
+
// inside the panel and a plugin can still name a directory outright.
|
|
1345
|
+
const here = w.panel(key?.view);
|
|
1346
|
+
const cwd = args[0]?.trim() || owningProject(here?.list.value);
|
|
1347
|
+
if (!cwd) {
|
|
1348
|
+
neosh.notify("project.remove needs a directory", "warn");
|
|
1349
|
+
return;
|
|
1350
|
+
}
|
|
1351
|
+
await removeProject(neosh, arrangement, cwd);
|
|
1352
|
+
// Every terminal's, because a project going away is a row missing from all of them.
|
|
1353
|
+
w.each((p) => p.draw());
|
|
1354
|
+
}, { desc: "Take a project off the list" }),
|
|
1355
|
+
);
|
|
1356
|
+
|
|
1357
|
+
await neosh.keymap.set("chat", "<C-b>", "sidebar.toggle", { desc: "Toggle the sidebar" });
|
|
1358
|
+
await neosh.keymap.set("chat", "<C-t>", "sidebar.focus", { desc: "Projects and conversations" });
|
|
1359
|
+
await neosh.keymap.set("chat", "<C-n>", "session.new", { desc: "New conversation" });
|
|
1360
|
+
await neosh.keymap.set("chat", "<C-o>", "project.open", { desc: "Add a project" });
|
|
1361
|
+
|
|
1362
|
+
// Watching a conversation on another machine.
|
|
1363
|
+
//
|
|
1364
|
+
// Not "switching" — there is nothing here to switch to, because the agent belongs to the computer
|
|
1365
|
+
// it was started on and always will. Subscribing is what makes it read like a local one: history
|
|
1366
|
+
// first, then everything as it happens.
|
|
1367
|
+
w.subscriptions.push(
|
|
1368
|
+
await neosh.cmd.register("swarm.open", async (args) => {
|
|
1369
|
+
const [node, session] = args;
|
|
1370
|
+
if (!node || !session) {
|
|
1371
|
+
neosh.notify("swarm.open needs a node and a conversation", "warn");
|
|
1372
|
+
return;
|
|
1373
|
+
}
|
|
1374
|
+
await openRemote(neosh, w.subscriptions, node, session);
|
|
1375
|
+
}, { desc: "Watch a conversation on another computer" }),
|
|
1376
|
+
);
|
|
1377
|
+
w.subscriptions.push(
|
|
1378
|
+
await neosh.cmd.register("swarm.nodes", () => showNodes(neosh), {
|
|
1379
|
+
desc: "The computers in this workspace",
|
|
1380
|
+
}),
|
|
1381
|
+
);
|
|
1382
|
+
w.subscriptions.push(
|
|
1383
|
+
await neosh.cmd.register("swarm.add", () => addComputer(neosh), {
|
|
1384
|
+
desc: "Add a computer by its address",
|
|
1385
|
+
}),
|
|
1386
|
+
);
|
|
1387
|
+
// `^J` rather than a letter, because everything in chat mode has to be a chord — the composer is
|
|
1388
|
+
// the field, and a bare key would be a character you can no longer type.
|
|
1389
|
+
await neosh.keymap.set("chat", "<C-j>", "swarm.nodes", { desc: "Computers" });
|
|
1390
|
+
// Any change over there is a redraw here. Without it the panel is right only as often as its
|
|
1391
|
+
// four-second refresh, which is a long time to watch a spinner that has already stopped.
|
|
1392
|
+
w.subscriptions.push(neosh.swarm.onChange(() => w.each((p) => p.draw())));
|
|
1393
|
+
|
|
1394
|
+
await neosh.hint.set("sessions", { keys: "^T", label: "conversations", priority: 20 });
|
|
1395
|
+
await neosh.hint.set("new", { keys: "^N", label: "new", priority: 21 });
|
|
1396
|
+
}
|
|
1397
|
+
|
|
1398
|
+
/**
|
|
1399
|
+
* Bind the verbs other plugins contributed, and rebind them when the set changes.
|
|
1400
|
+
*
|
|
1401
|
+
* The panel binds these rather than the contributor doing it, and that is deliberate: a key press
|
|
1402
|
+
* carries no arguments, so a plugin that bound `d` itself would have no way to learn which row the
|
|
1403
|
+
* cursor was on except by tracking every move — one race away from acting on the wrong
|
|
1404
|
+
* conversation. Going through here, the command is invoked with the row as arguments and the
|
|
1405
|
+
* contributor never has to know the panel has a cursor at all.
|
|
1406
|
+
*
|
|
1407
|
+
* They are real bindings all the same. `^Z` lists them with the contributed label, `^K` runs the
|
|
1408
|
+
* command, and a user who dislikes the key rebinds it exactly as they would one of ours.
|
|
1409
|
+
*/
|
|
1410
|
+
function installActions(
|
|
1411
|
+
neosh: Neosh,
|
|
1412
|
+
panel: (view?: ViewId) => Panel | null,
|
|
1413
|
+
onChange: () => void,
|
|
1414
|
+
): { actions: () => ActionItem[]; dispose: Disposable } {
|
|
1415
|
+
const scope = { kind: "buf_kind", name: KIND } as const;
|
|
1416
|
+
let current: Array<Contribution & { item: ActionItem }> = [];
|
|
1417
|
+
let bound: Array<{ key: string; command: string }> = [];
|
|
1418
|
+
let registered: Disposable[] = [];
|
|
1419
|
+
|
|
1420
|
+
const sync = async () => {
|
|
1421
|
+
const got = await neosh.ext.list<ActionItem>(POINT_ACTION).catch(() => []);
|
|
1422
|
+
const valid = got.filter((c) =>
|
|
1423
|
+
typeof c.item?.key === "string" && typeof c.item?.command === "string"
|
|
1424
|
+
);
|
|
1425
|
+
|
|
1426
|
+
// Everything from the previous round goes first. An action that was withdrawn must take its key
|
|
1427
|
+
// *and* its command with it, or the panel keeps a binding pointing at a wrapper around a
|
|
1428
|
+
// command whose plugin has gone.
|
|
1429
|
+
for (const b of bound) await neosh.keymap.del("chat", b.key, scope).catch(() => {});
|
|
1430
|
+
for (const d of registered) d.dispose();
|
|
1431
|
+
bound = [];
|
|
1432
|
+
registered = [];
|
|
1433
|
+
const reserved = await reservedKeys(neosh);
|
|
1434
|
+
|
|
1435
|
+
for (const c of valid) {
|
|
1436
|
+
const name = `${NS}.action.${c.plugin}.${c.id}`;
|
|
1437
|
+
const command = await neosh.cmd.register(name, async (_args, key) => {
|
|
1438
|
+
// The row under *this* terminal's cursor. A contributed verb is pressed in one panel, and
|
|
1439
|
+
// there may be three of them open on three different rows.
|
|
1440
|
+
const target = panel(key?.view)?.list.value;
|
|
1441
|
+
if (!applies(c.item.on ?? "any", target)) return;
|
|
1442
|
+
await neosh.cmd.exec(c.item.command, argsFor(target)).catch((e: unknown) => {
|
|
1443
|
+
neosh.notify(String(e), "warn");
|
|
1444
|
+
});
|
|
1445
|
+
onChange();
|
|
1446
|
+
}, { desc: c.item.label }).catch(() => null);
|
|
1447
|
+
if (command) registered.push(command);
|
|
1448
|
+
// A contributed key does not get to take one of ours. `keymap.set` already refuses to let a
|
|
1449
|
+
// *bundled* default overwrite somebody's choice, but here the bundled plugin is the one doing
|
|
1450
|
+
// the binding on a third party's behalf, so that rule points the wrong way and the check is
|
|
1451
|
+
// ours to make. The command stays registered either way — `^K` still runs it, and the
|
|
1452
|
+
// contributor is told which key it did not get.
|
|
1453
|
+
if (reserved.has(c.item.key)) {
|
|
1454
|
+
neosh.log.warn(
|
|
1455
|
+
`${c.plugin} asked for '${c.item.key}' in the sidebar, which is already a panel key`,
|
|
1456
|
+
);
|
|
1457
|
+
continue;
|
|
1458
|
+
}
|
|
1459
|
+
await neosh.keymap.set("chat", c.item.key, name, { scope, desc: c.item.label })
|
|
1460
|
+
.catch(() => {});
|
|
1461
|
+
bound.push({ key: c.item.key, command: name });
|
|
1462
|
+
}
|
|
1463
|
+
current = valid;
|
|
1464
|
+
onChange();
|
|
1465
|
+
};
|
|
1466
|
+
|
|
1467
|
+
void sync();
|
|
1468
|
+
const sub = neosh.ext.onChange((e) => {
|
|
1469
|
+
if (e.point === POINT_ACTION) void sync();
|
|
1470
|
+
});
|
|
1471
|
+
|
|
1472
|
+
return {
|
|
1473
|
+
actions: () => current.map((c) => c.item),
|
|
1474
|
+
dispose: {
|
|
1475
|
+
dispose() {
|
|
1476
|
+
sub.dispose();
|
|
1477
|
+
for (const b of bound) void neosh.keymap.del("chat", b.key, scope).catch(() => {});
|
|
1478
|
+
for (const d of registered) d.dispose();
|
|
1479
|
+
},
|
|
1480
|
+
},
|
|
1481
|
+
};
|
|
1482
|
+
}
|
|
1483
|
+
|
|
1484
|
+
/**
|
|
1485
|
+
* The keys this panel has already spoken for. A contribution asking for one of these is a bug in it.
|
|
1486
|
+
*
|
|
1487
|
+
* Read off the registry rather than kept in a list here: the list this replaced was missing
|
|
1488
|
+
* `gg`, `G`, `^D`, `^U`, `⇥` and every digit — keys the panel binds a few hundred lines up — so a
|
|
1489
|
+
* contributed `G` silently replaced *go to the bottom* with no warning to anybody. A fact about
|
|
1490
|
+
* which keys are bound is a fact the keymap table already has.
|
|
1491
|
+
*/
|
|
1492
|
+
async function reservedKeys(neosh: Neosh): Promise<Set<string>> {
|
|
1493
|
+
const all = await neosh.keymap.list("chat").catch(() => []);
|
|
1494
|
+
return new Set(
|
|
1495
|
+
all
|
|
1496
|
+
.filter((k) =>
|
|
1497
|
+
k.scope.kind === "buf_kind" && k.scope.name === KIND &&
|
|
1498
|
+
!k.command.startsWith(`${NS}.action.`)
|
|
1499
|
+
)
|
|
1500
|
+
.map((k) => k.lhs),
|
|
1501
|
+
);
|
|
1502
|
+
}
|
|
1503
|
+
|
|
1504
|
+
function applies(
|
|
1505
|
+
on: "project" | "session" | "custom" | "any",
|
|
1506
|
+
target: Target | undefined,
|
|
1507
|
+
): boolean {
|
|
1508
|
+
if (on === "any") return target !== undefined;
|
|
1509
|
+
return target?.kind === on;
|
|
1510
|
+
}
|
|
1511
|
+
|
|
1512
|
+
/** The row under the cursor, as arguments a command can act on. */
|
|
1513
|
+
function argsFor(target: Target | undefined): string[] {
|
|
1514
|
+
if (!target) return [];
|
|
1515
|
+
if (target.kind === "session") return ["session", target.cwd, target.id];
|
|
1516
|
+
if (target.kind === "project") return ["project", target.cwd];
|
|
1517
|
+
return [target.kind];
|
|
1518
|
+
}
|
|
1519
|
+
|
|
1520
|
+
/**
|
|
1521
|
+
* Start a conversation, asking where when there is something to ask.
|
|
1522
|
+
*
|
|
1523
|
+
* A conversation belongs to a directory — that is what makes the sidebar a list of projects rather
|
|
1524
|
+
* than a list of threads — and in a git repository "where" has a real answer that is not always
|
|
1525
|
+
* "here". A branch you want to work on without disturbing what is checked out is a *worktree*, and
|
|
1526
|
+
* having to create one, find it, and then open a conversation in it is three steps for one
|
|
1527
|
+
* intention.
|
|
1528
|
+
*
|
|
1529
|
+
* **`base` is which project is being asked about**, and it is the whole reason this takes an
|
|
1530
|
+
* argument: `^N` means the conversation you are in, and `n` in the panel means the row the cursor
|
|
1531
|
+
* is on, which is very often a different repository with different branches. Asking about the
|
|
1532
|
+
* active conversation's checkout from a row pointing somewhere else would offer worktrees that
|
|
1533
|
+
* have nothing to do with what was aimed at.
|
|
1534
|
+
*
|
|
1535
|
+
* Outside a repository the question has one answer, so it is not asked: `^N` stays a single key
|
|
1536
|
+
* everywhere the choice would be theatre. Inside one, `Here` is selected, so `^N ⏎` is what `^N`
|
|
1537
|
+
* always did.
|
|
1538
|
+
*
|
|
1539
|
+
* **The worktrees it offers are the ones on the panel's list**, which is what `arrangement` is for
|
|
1540
|
+
* here. Every tree `git worktree list` knows is not the same set: a scratch branch you finished
|
|
1541
|
+
* with in March is still a checkout on disk long after `X` took it off the list, so this picker
|
|
1542
|
+
* was offering back, under "an existing one", every project you had ever removed — twenty rows of
|
|
1543
|
+
* finished work above the three you are actually in, and choosing one put it straight back in the
|
|
1544
|
+
* sidebar. The list is the workspace's answer to *which places do I work in*, and
|
|
1545
|
+
* this question is asking exactly that. A worktree neosh has never heard of is not on it either,
|
|
1546
|
+
* which is correct rather than a casualty: `Another directory…` lists every tree git knows and is
|
|
1547
|
+
* how a directory joins the list in the first place.
|
|
1548
|
+
*/
|
|
1549
|
+
async function newConversation(
|
|
1550
|
+
neosh: Neosh,
|
|
1551
|
+
arrangement: Arrangement,
|
|
1552
|
+
base?: string,
|
|
1553
|
+
): Promise<void> {
|
|
1554
|
+
const current = await neosh.session.current().catch(() => null);
|
|
1555
|
+
const here = base ?? current?.cwd;
|
|
1556
|
+
// Empty outside a repository, which is also how this knows there is nothing to ask about: a
|
|
1557
|
+
// worktree is a git idea, and a directory that is not a checkout has exactly one answer to
|
|
1558
|
+
// "where does this conversation go".
|
|
1559
|
+
const trees = await neosh.git.worktrees(here ? { cwd: here } : undefined)
|
|
1560
|
+
.catch(() => [] as WorktreeInfo[]);
|
|
1561
|
+
// The git plugin may also be switched off, in which case the rows that lead into it would lead
|
|
1562
|
+
// nowhere. Offering an action that cannot happen is worse than not offering it.
|
|
1563
|
+
const commands = new Set((await neosh.cmd.list().catch(() => [])).map((c) => c.name));
|
|
1564
|
+
const canBranch = trees.length > 0 && commands.has("git.worktree.new.auto");
|
|
1565
|
+
const canInside = trees.length > 0 && commands.has("git.worktree.new.inside");
|
|
1566
|
+
const canName = trees.length > 0 && commands.has("git.worktree.new");
|
|
1567
|
+
// On the list, and not merely on disk. `here` is dropped because you are already in it.
|
|
1568
|
+
const known = new Set(arrangement.all());
|
|
1569
|
+
const others = trees.filter((t) => t.path !== here && known.has(t.path));
|
|
1570
|
+
|
|
1571
|
+
if (!canBranch && !canInside && !canName && others.length === 0) {
|
|
1572
|
+
await neosh.session.create(here ? { cwd: here } : undefined);
|
|
1573
|
+
return;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
type Where =
|
|
1577
|
+
| { kind: "here" }
|
|
1578
|
+
| { kind: "scratch" }
|
|
1579
|
+
| { kind: "inside" }
|
|
1580
|
+
| { kind: "new" }
|
|
1581
|
+
| { kind: "elsewhere" }
|
|
1582
|
+
| { kind: "tree"; path: string; label: string }
|
|
1583
|
+
/** On another computer, in a checkout that machine told us it has. */
|
|
1584
|
+
| { kind: "host"; node: string; cwd: string; label: string };
|
|
1585
|
+
const rows: Array<PickerItem<Where>> = [
|
|
1586
|
+
{
|
|
1587
|
+
label: "Here",
|
|
1588
|
+
detail: here ?? "this project",
|
|
1589
|
+
icon: "●",
|
|
1590
|
+
hl: "Diagnostic.Info",
|
|
1591
|
+
value: { kind: "here" },
|
|
1592
|
+
},
|
|
1593
|
+
];
|
|
1594
|
+
// First, and above the one that asks for a name, because it is the answer more often: a branch
|
|
1595
|
+
// named before the work is a decision made at the worst possible moment, and the whole reason
|
|
1596
|
+
// this row exists is that having to make it is what stops people starting.
|
|
1597
|
+
if (canBranch) {
|
|
1598
|
+
rows.push({
|
|
1599
|
+
label: "In a new worktree",
|
|
1600
|
+
detail: "a clean branch, named for you, nothing to answer",
|
|
1601
|
+
keywords: "branch worktree scratch new fresh",
|
|
1602
|
+
icon: "+",
|
|
1603
|
+
hl: "Diagnostic.Ok",
|
|
1604
|
+
value: { kind: "scratch" },
|
|
1605
|
+
});
|
|
1606
|
+
}
|
|
1607
|
+
if (canInside) {
|
|
1608
|
+
// Where it will land, said in the row: a choice between two places is only a choice if both
|
|
1609
|
+
// rows name theirs. A relative `worktree.root` renames this directory, so it is read rather
|
|
1610
|
+
// than assumed.
|
|
1611
|
+
const configured = ((await neosh.opt.get<string>("worktree.root").catch(() => "")) ?? "")
|
|
1612
|
+
.trim();
|
|
1613
|
+
const dir = configured !== "" && !configured.startsWith("/")
|
|
1614
|
+
? configured.replace(/\/+$/, "")
|
|
1615
|
+
: ".worktrees";
|
|
1616
|
+
rows.push({
|
|
1617
|
+
label: "In a new worktree, in this project",
|
|
1618
|
+
detail: `kept in ${dir}/ — travels with the repository`,
|
|
1619
|
+
keywords: "branch worktree inside project local",
|
|
1620
|
+
icon: "⌂",
|
|
1621
|
+
hl: "Accent",
|
|
1622
|
+
value: { kind: "inside" },
|
|
1623
|
+
});
|
|
1624
|
+
}
|
|
1625
|
+
if (canName) {
|
|
1626
|
+
rows.push({
|
|
1627
|
+
label: "In a new worktree, named…",
|
|
1628
|
+
detail: "a branch of its own, checked out somewhere else",
|
|
1629
|
+
keywords: "branch worktree name",
|
|
1630
|
+
icon: "+",
|
|
1631
|
+
hl: "Sidebar.Dim",
|
|
1632
|
+
value: { kind: "new" },
|
|
1633
|
+
});
|
|
1634
|
+
}
|
|
1635
|
+
for (const t of others) {
|
|
1636
|
+
const label = t.branch ?? t.head ?? t.path;
|
|
1637
|
+
rows.push({
|
|
1638
|
+
label,
|
|
1639
|
+
detail: `worktree · ${t.path}`,
|
|
1640
|
+
keywords: `${t.path} worktree`,
|
|
1641
|
+
icon: "⎇",
|
|
1642
|
+
hl: "Git.Branch",
|
|
1643
|
+
value: { kind: "tree", path: t.path, label },
|
|
1644
|
+
});
|
|
1645
|
+
}
|
|
1646
|
+
// The other computers, and the checkouts each of them offered in its handshake. A machine that
|
|
1647
|
+
// does not accept commands is left out rather than shown and refused — a row that cannot work is
|
|
1648
|
+
// worse than no row.
|
|
1649
|
+
for (const n of await neosh.swarm.nodes().catch(() => [])) {
|
|
1650
|
+
if (!n.up || !n.capabilities.accepts_commands) continue;
|
|
1651
|
+
for (const project of n.capabilities.projects) {
|
|
1652
|
+
rows.push({
|
|
1653
|
+
label: `${project.name}`,
|
|
1654
|
+
detail: `on ${n.info.name} · ${project.cwd}`,
|
|
1655
|
+
keywords: `${n.info.name} ${project.cwd} remote host computer`,
|
|
1656
|
+
icon: "→",
|
|
1657
|
+
hl: "Sidebar.Remote",
|
|
1658
|
+
value: {
|
|
1659
|
+
kind: "host",
|
|
1660
|
+
node: n.info.id,
|
|
1661
|
+
cwd: project.cwd,
|
|
1662
|
+
label: `${project.name} on ${n.info.name}`,
|
|
1663
|
+
},
|
|
1664
|
+
});
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
|
|
1668
|
+
rows.push({
|
|
1669
|
+
label: "Another directory…",
|
|
1670
|
+
detail: "somewhere else entirely",
|
|
1671
|
+
keywords: "project folder",
|
|
1672
|
+
icon: "…",
|
|
1673
|
+
hl: "Sidebar.Dim",
|
|
1674
|
+
value: { kind: "elsewhere" },
|
|
1675
|
+
});
|
|
1676
|
+
|
|
1677
|
+
const chosen = await picker(neosh, rows, { title: "New conversation", width: 76 });
|
|
1678
|
+
if (chosen === null) return;
|
|
1679
|
+
// Every row below that reaches for git names the repository it is about. `^N` from a
|
|
1680
|
+
// conversation and `n` from a row on a different project are the same code path, and the only
|
|
1681
|
+
// thing that tells them apart is this argument.
|
|
1682
|
+
const at = here ? [here] : [];
|
|
1683
|
+
switch (chosen.kind) {
|
|
1684
|
+
case "here":
|
|
1685
|
+
await neosh.session.create(here ? { cwd: here } : undefined);
|
|
1686
|
+
return;
|
|
1687
|
+
case "scratch":
|
|
1688
|
+
await neosh.cmd.exec("git.worktree.new.auto", at)
|
|
1689
|
+
.catch((e: unknown) => neosh.notify(String(e), "warn"));
|
|
1690
|
+
return;
|
|
1691
|
+
case "inside":
|
|
1692
|
+
await neosh.cmd.exec("git.worktree.new.inside", at)
|
|
1693
|
+
.catch((e: unknown) => neosh.notify(String(e), "warn"));
|
|
1694
|
+
return;
|
|
1695
|
+
case "new":
|
|
1696
|
+
// Positionally: branch, path, cwd. The first two are what the command asks for when they are
|
|
1697
|
+
// missing, which is exactly what this row means.
|
|
1698
|
+
await neosh.cmd.exec("git.worktree.new", here ? ["", "", here] : [])
|
|
1699
|
+
.catch((e: unknown) => neosh.notify(String(e), "warn"));
|
|
1700
|
+
return;
|
|
1701
|
+
case "tree":
|
|
1702
|
+
// No message: creating a conversation switches to it, so the transcript is empty, the
|
|
1703
|
+
// composer has the keyboard and the sidebar row is selected. All three say it already.
|
|
1704
|
+
await neosh.session.create({ cwd: chosen.path });
|
|
1705
|
+
return;
|
|
1706
|
+
case "host":
|
|
1707
|
+
// Started *there*. The agent will run on that machine, against that machine's files, which
|
|
1708
|
+
// is the point — this is not a way to work on a remote directory from here.
|
|
1709
|
+
try {
|
|
1710
|
+
await neosh.swarm.command(chosen.node, "", {
|
|
1711
|
+
command: "new_session",
|
|
1712
|
+
cwd: chosen.cwd,
|
|
1713
|
+
title: null,
|
|
1714
|
+
});
|
|
1715
|
+
neosh.notify(`started ${chosen.label}`);
|
|
1716
|
+
} catch (e) {
|
|
1717
|
+
neosh.notify(String(e), "warn");
|
|
1718
|
+
}
|
|
1719
|
+
return;
|
|
1720
|
+
case "elsewhere":
|
|
1721
|
+
await neosh.cmd.exec("project.open").catch(() => {});
|
|
1722
|
+
}
|
|
1723
|
+
}
|
|
1724
|
+
|
|
1725
|
+
async function activateTarget(
|
|
1726
|
+
neosh: Neosh,
|
|
1727
|
+
arrangement: Arrangement,
|
|
1728
|
+
target: Target | undefined,
|
|
1729
|
+
p: Panel,
|
|
1730
|
+
): Promise<void> {
|
|
1731
|
+
if (!target) return;
|
|
1732
|
+
// Somebody else's row. Nothing here knows what it does, which is the point — the contribution
|
|
1733
|
+
// named a command and this invokes it.
|
|
1734
|
+
if (target.kind === "custom") {
|
|
1735
|
+
if (!target.command) return;
|
|
1736
|
+
await neosh.cmd.exec(target.command, target.args).catch((e: unknown) => {
|
|
1737
|
+
neosh.notify(String(e), "warn");
|
|
1738
|
+
});
|
|
1739
|
+
await p.draw();
|
|
1740
|
+
return;
|
|
1741
|
+
}
|
|
1742
|
+
// A conversation on another computer. There is nothing to switch to here — it belongs to that
|
|
1743
|
+
// machine — so opening it means watching it, which is what `swarm.open` does.
|
|
1744
|
+
if (target.kind === "remote") {
|
|
1745
|
+
await neosh.cmd.exec("swarm.open", [target.node, target.session]).catch((e: unknown) => {
|
|
1746
|
+
neosh.notify(String(e), "warn");
|
|
1747
|
+
});
|
|
1748
|
+
return;
|
|
1749
|
+
}
|
|
1750
|
+
if (target.kind === "add") {
|
|
1751
|
+
await p.leave();
|
|
1752
|
+
await neosh.cmd.exec("project.open").catch(() => {});
|
|
1753
|
+
return;
|
|
1754
|
+
}
|
|
1755
|
+
if (target.kind === "project") {
|
|
1756
|
+
// A pinned project with nothing in it yet: `↵` should start something, not fold an empty list.
|
|
1757
|
+
// A repository whose conversations are all in its worktrees is not that — the row has children
|
|
1758
|
+
// to fold, which is what `s.repo_root === target.cwd` finds.
|
|
1759
|
+
const sessions = await neosh.session.list().catch(() => [] as SessionInfo[]);
|
|
1760
|
+
if (!sessions.some((s) => s.cwd === target.cwd || s.repo_root === target.cwd)) {
|
|
1761
|
+
await p.here.session.create({ cwd: target.cwd });
|
|
1762
|
+
await p.leave();
|
|
1763
|
+
return;
|
|
1764
|
+
}
|
|
1765
|
+
await arrangement.toggleFold(target.cwd);
|
|
1766
|
+
await p.draw();
|
|
1767
|
+
return;
|
|
1768
|
+
}
|
|
1769
|
+
try {
|
|
1770
|
+
await p.here.session.switch(target.id);
|
|
1771
|
+
} catch (e) {
|
|
1772
|
+
neosh.notify(String(e), "warn");
|
|
1773
|
+
return;
|
|
1774
|
+
}
|
|
1775
|
+
await p.leave();
|
|
1776
|
+
}
|
|
1777
|
+
|
|
1778
|
+
/** The project a row belongs to — its own, or the one its conversation is in. */
|
|
1779
|
+
function owningProject(target: Target | undefined): string | null {
|
|
1780
|
+
if (!target) return null;
|
|
1781
|
+
return target.kind === "project" || target.kind === "session" ? target.cwd : null;
|
|
1782
|
+
}
|
|
1783
|
+
|
|
1784
|
+
/**
|
|
1785
|
+
* Which directory to add.
|
|
1786
|
+
*
|
|
1787
|
+
* Offers the worktrees of the repository you are in before asking you to type, because that is
|
|
1788
|
+
* where the next project usually is and a path typed from memory is a path typed wrong. The
|
|
1789
|
+
* runtime has no filesystem, so there is no directory browser to offer — the last entry drops
|
|
1790
|
+
* through to a text field, which the host validates.
|
|
1791
|
+
*/
|
|
1792
|
+
async function chooseDirectory(neosh: Neosh): Promise<string | null> {
|
|
1793
|
+
const [worktrees, sessions] = await Promise.all([
|
|
1794
|
+
neosh.git.worktrees().catch(() => []),
|
|
1795
|
+
neosh.session.list().catch(() => [] as SessionInfo[]),
|
|
1796
|
+
]);
|
|
1797
|
+
const open = new Set(sessions.map((s) => s.cwd));
|
|
1798
|
+
const candidates = worktrees.filter((t) => !open.has(t.path));
|
|
1799
|
+
if (candidates.length === 0) return pathPicker(neosh, "Add project");
|
|
1800
|
+
|
|
1801
|
+
const TYPE = "\u0000type";
|
|
1802
|
+
const chosen = await picker(
|
|
1803
|
+
neosh,
|
|
1804
|
+
[
|
|
1805
|
+
...candidates.map((t) => ({
|
|
1806
|
+
label: basename(t.path),
|
|
1807
|
+
detail: [t.branch ?? "", t.path].filter(Boolean).join(" · "),
|
|
1808
|
+
keywords: t.path,
|
|
1809
|
+
value: t.path,
|
|
1810
|
+
})),
|
|
1811
|
+
{ label: "Type a path…", value: TYPE },
|
|
1812
|
+
],
|
|
1813
|
+
{ title: "Add project", width: 76 },
|
|
1814
|
+
);
|
|
1815
|
+
if (chosen === null) return null;
|
|
1816
|
+
return chosen === TYPE ? pathPicker(neosh, "Add project") : chosen;
|
|
1817
|
+
}
|
|
1818
|
+
|
|
1819
|
+
/**
|
|
1820
|
+
* Put a conversation away, or bring it back.
|
|
1821
|
+
*
|
|
1822
|
+
* Asks nothing, on purpose. A confirmation is the price of an irreversible action, and this one is
|
|
1823
|
+
* reversible by one key from the archive — charging for it would teach you to dismiss dialogs,
|
|
1824
|
+
* which is exactly the habit that makes the delete dialog useless.
|
|
1825
|
+
*/
|
|
1826
|
+
async function setArchived(neosh: Neosh, session: string, archived: boolean): Promise<void> {
|
|
1827
|
+
try {
|
|
1828
|
+
await neosh.session.archive(session, archived);
|
|
1829
|
+
neosh.notify(archived ? "archived — `a` in the panel finds it" : "unarchived");
|
|
1830
|
+
} catch (e) {
|
|
1831
|
+
neosh.notify(String(e), "warn");
|
|
1832
|
+
}
|
|
1833
|
+
}
|
|
1834
|
+
|
|
1835
|
+
/**
|
|
1836
|
+
* Delete a conversation, having asked. Answers whether it went.
|
|
1837
|
+
*
|
|
1838
|
+
* The file goes from disk and there is no undo, so this always stops and asks — including for a
|
|
1839
|
+
* conversation with nothing in it yet. It used to skip the question there, on the grounds that a
|
|
1840
|
+
* dialog for something with nothing to lose is friction; what that actually bought was a key whose
|
|
1841
|
+
* behaviour depended on state you cannot see from the row, so the one time it did ask was the one
|
|
1842
|
+
* time your fingers were already through it.
|
|
1843
|
+
*
|
|
1844
|
+
* `ui.confirm_destructive = false` is still the way out, and it is a setting rather than a guess.
|
|
1845
|
+
*/
|
|
1846
|
+
async function deleteSession(neosh: Neosh, session: string): Promise<boolean> {
|
|
1847
|
+
const info = (await neosh.session.list({ includeArchived: true }).catch(() => [] as SessionInfo[]))
|
|
1848
|
+
.find((s) => s.id === session);
|
|
1849
|
+
const count = info?.message_count ?? 0;
|
|
1850
|
+
const detail = [
|
|
1851
|
+
count === 0
|
|
1852
|
+
? "Nothing has been said in it yet."
|
|
1853
|
+
: `${count} ${count === 1 ? "message" : "messages"}, in ${info?.project || basename(info?.cwd ?? "")}.`,
|
|
1854
|
+
info?.archived
|
|
1855
|
+
? "It is archived, so leaving it here costs nothing."
|
|
1856
|
+
: "Archiving keeps every word of it and takes it out of your list.",
|
|
1857
|
+
];
|
|
1858
|
+
const ok = await confirmDestructive(
|
|
1859
|
+
neosh,
|
|
1860
|
+
`Delete "${clip(info?.label ?? "this conversation", 48)}"?`,
|
|
1861
|
+
{ yes: "Delete", no: "Keep", detail },
|
|
1862
|
+
);
|
|
1863
|
+
if (!ok) return false;
|
|
1864
|
+
try {
|
|
1865
|
+
await neosh.session.close(session);
|
|
1866
|
+
return true;
|
|
1867
|
+
} catch (e) {
|
|
1868
|
+
neosh.notify(String(e), "warn");
|
|
1869
|
+
return false;
|
|
1870
|
+
}
|
|
1871
|
+
}
|
|
1872
|
+
|
|
1873
|
+
/**
|
|
1874
|
+
* Take a project off the list.
|
|
1875
|
+
*
|
|
1876
|
+
* Empty, this asks nothing: no file moves, the row goes, and `o` puts the directory back in one
|
|
1877
|
+
* keystroke — a dialog for that is the kind you learn to clear without reading. With conversations
|
|
1878
|
+
* still in it, removing the project is deleting every one of them, so it asks as the delete it is
|
|
1879
|
+
* and says how many and where they would otherwise go.
|
|
1880
|
+
*
|
|
1881
|
+
* The conversations go first and the project only goes if they all did: a project removed anyway
|
|
1882
|
+
* would take a row that still had something in it off the list.
|
|
1883
|
+
*
|
|
1884
|
+
* Removing the *last* project used to be the one case this could not finish: the store refused to
|
|
1885
|
+
* delete the very last conversation in the workspace. It no longer does — what you land in is a
|
|
1886
|
+
* placeholder, which is in no list and so in no project — so `X` here can leave the panel with
|
|
1887
|
+
* nothing but `+ Add project` on it.
|
|
1888
|
+
*/
|
|
1889
|
+
async function removeProject(
|
|
1890
|
+
neosh: Neosh,
|
|
1891
|
+
arrangement: Arrangement,
|
|
1892
|
+
cwd: string,
|
|
1893
|
+
): Promise<void> {
|
|
1894
|
+
const inside = (await neosh.session.list({ includeArchived: true }).catch(() => [] as SessionInfo[]))
|
|
1895
|
+
.filter((s) => s.cwd === cwd);
|
|
1896
|
+
const name = inside[0]?.project || arrangement.name(cwd) || basename(cwd);
|
|
1897
|
+
|
|
1898
|
+
if (inside.length > 0) {
|
|
1899
|
+
const many = inside.length === 1 ? "conversation" : `${inside.length} conversations`;
|
|
1900
|
+
const ok = await confirmDestructive(neosh, `Remove "${clip(name, 40)}" and its ${many}?`, {
|
|
1901
|
+
yes: "Delete",
|
|
1902
|
+
no: "Keep",
|
|
1903
|
+
detail: [
|
|
1904
|
+
atStake(inside),
|
|
1905
|
+
"`x` on a conversation archives it instead, and an archived one is already out of the way.",
|
|
1906
|
+
],
|
|
1907
|
+
});
|
|
1908
|
+
if (!ok) return;
|
|
1909
|
+
for (const s of inside) {
|
|
1910
|
+
try {
|
|
1911
|
+
await neosh.session.close(s.id);
|
|
1912
|
+
} catch (e) {
|
|
1913
|
+
neosh.notify(String(e), "warn");
|
|
1914
|
+
return;
|
|
1915
|
+
}
|
|
1916
|
+
}
|
|
1917
|
+
}
|
|
1918
|
+
|
|
1919
|
+
await arrangement.forget(cwd);
|
|
1920
|
+
neosh.notify(`removed ${short(cwd)} — \`o\` adds it back`);
|
|
1921
|
+
}
|
|
1922
|
+
|
|
1923
|
+
/**
|
|
1924
|
+
* How much is about to go, in a sentence.
|
|
1925
|
+
*
|
|
1926
|
+
* What is at stake is the point of the question, and the number that says it is how many of these
|
|
1927
|
+
* you would still have found in the panel: something you archived a month ago going is a different
|
|
1928
|
+
* size of loss from a conversation you were in this morning.
|
|
1929
|
+
*/
|
|
1930
|
+
function atStake(inside: SessionInfo[]): string {
|
|
1931
|
+
const archived = inside.filter((s) => s.archived).length;
|
|
1932
|
+
if (inside.length === 1) {
|
|
1933
|
+
return archived === 1
|
|
1934
|
+
? "It is archived, and it goes from disk with the project."
|
|
1935
|
+
: "It goes from disk with the project.";
|
|
1936
|
+
}
|
|
1937
|
+
if (archived === inside.length) return `All ${inside.length} are archived, and all of them go from disk.`;
|
|
1938
|
+
if (archived === 0) return `All ${inside.length} go from disk.`;
|
|
1939
|
+
const live = inside.length - archived;
|
|
1940
|
+
return `${live} of them ${live === 1 ? "is" : "are"} still in your list, and all ${inside.length} go from disk.`;
|
|
1941
|
+
}
|
|
1942
|
+
|
|
1943
|
+
/**
|
|
1944
|
+
* The computers in this workspace: yours, theirs, and the ones asking to join.
|
|
1945
|
+
*
|
|
1946
|
+
* A picker rather than a panel section, for the reason the archive is: it is a thing you look at
|
|
1947
|
+
* when you want it, and rows that are only occasionally the answer do not belong in the column you
|
|
1948
|
+
* work in.
|
|
1949
|
+
*
|
|
1950
|
+
* Machines asking to join sit at the top, because they are the only rows that are waiting on you.
|
|
1951
|
+
* Your own identity is at the foot, where it is out of the way but always somewhere you can point
|
|
1952
|
+
* at when the other machine asks who you are.
|
|
1953
|
+
*/
|
|
1954
|
+
async function showNodes(neosh: Neosh): Promise<void> {
|
|
1955
|
+
const me = await neosh.swarm.self().catch(() => null);
|
|
1956
|
+
if (!me) {
|
|
1957
|
+
// On by default, so off means somebody turned it off — or `--clean`, which has nowhere to
|
|
1958
|
+
// keep the key that *is* this machine's identity.
|
|
1959
|
+
neosh.notify(
|
|
1960
|
+
"the swarm is off — remove `enabled = false` from `[swarm]`, or leave --clean",
|
|
1961
|
+
"info",
|
|
1962
|
+
);
|
|
1963
|
+
return;
|
|
1964
|
+
}
|
|
1965
|
+
// What the listener managed, said on this machine's own row: an address a peer could dial, or
|
|
1966
|
+
// why there is not one. Written by the host as a workspace var, because it is a fact about the
|
|
1967
|
+
// workspace and not about whichever panel happened to ask.
|
|
1968
|
+
const listening = await neosh.vars
|
|
1969
|
+
.get<{ addr: string | null; error?: string }>({ scope: "global" }, "swarm.listen")
|
|
1970
|
+
.catch(() => null);
|
|
1971
|
+
|
|
1972
|
+
type Row =
|
|
1973
|
+
| { kind: "node"; node: SwarmNode }
|
|
1974
|
+
| { kind: "stranger"; stranger: SwarmStranger }
|
|
1975
|
+
| { kind: "add" }
|
|
1976
|
+
| { kind: "self" };
|
|
1977
|
+
|
|
1978
|
+
const build = async (): Promise<PickerItem<Row>[]> => {
|
|
1979
|
+
const [nodes, strangers] = await Promise.all([
|
|
1980
|
+
neosh.swarm.nodes().catch(() => []),
|
|
1981
|
+
neosh.swarm.strangers().catch(() => []),
|
|
1982
|
+
]);
|
|
1983
|
+
const rows: PickerItem<Row>[] = [];
|
|
1984
|
+
|
|
1985
|
+
for (const s of strangers) {
|
|
1986
|
+
rows.push({
|
|
1987
|
+
label: `${s.info.name}`,
|
|
1988
|
+
detail: s.dialled
|
|
1989
|
+
? `found at ${s.addr ?? "an address you gave"} · ${fingerprint(s.info.id)} · ↵ to add`
|
|
1990
|
+
: `wants to join · ${fingerprint(s.info.id)} · ↵ to allow`,
|
|
1991
|
+
keywords: `${s.info.id} pending join pair new`,
|
|
1992
|
+
value: { kind: "stranger", stranger: s },
|
|
1993
|
+
});
|
|
1994
|
+
}
|
|
1995
|
+
|
|
1996
|
+
for (const n of nodes) {
|
|
1997
|
+
const running = n.agents.filter((a) => a.state === "running").length;
|
|
1998
|
+
// One sentence per link state, because they are different answers to "why is it not
|
|
1999
|
+
// here": being dialled for the first time, being dialled again, and not being dialled.
|
|
2000
|
+
const state =
|
|
2001
|
+
n.link.state === "up"
|
|
2002
|
+
? `${n.agents.length} ${n.agents.length === 1 ? "conversation" : "conversations"}`
|
|
2003
|
+
: n.link.state === "connecting"
|
|
2004
|
+
? n.link.attempt > 0
|
|
2005
|
+
? `connecting — try ${n.link.attempt}, ${n.reason ?? "no answer"}`
|
|
2006
|
+
: "connecting…"
|
|
2007
|
+
: n.link.state === "retrying"
|
|
2008
|
+
? n.link.attempt > 0
|
|
2009
|
+
? `reconnecting — try ${n.link.attempt}`
|
|
2010
|
+
: "reconnecting…"
|
|
2011
|
+
: `disconnected — ${n.reason ?? "it dials in"} · ^R reconnects`;
|
|
2012
|
+
rows.push({
|
|
2013
|
+
label: n.info.name,
|
|
2014
|
+
detail: [
|
|
2015
|
+
state,
|
|
2016
|
+
running > 0 ? `${running} working` : "",
|
|
2017
|
+
n.up && !n.capabilities.accepts_commands ? "read-only" : "",
|
|
2018
|
+
n.info.os,
|
|
2019
|
+
fingerprint(n.info.id),
|
|
2020
|
+
].filter(Boolean).join(" · "),
|
|
2021
|
+
keywords: `${n.info.os} ${n.info.id} ${n.link.state}`,
|
|
2022
|
+
value: { kind: "node", node: n },
|
|
2023
|
+
});
|
|
2024
|
+
}
|
|
2025
|
+
|
|
2026
|
+
rows.push({
|
|
2027
|
+
label: "+ Add a computer…",
|
|
2028
|
+
detail: "its address on your network, or through Tailscale",
|
|
2029
|
+
keywords: "pair join new machine host",
|
|
2030
|
+
value: { kind: "add" },
|
|
2031
|
+
});
|
|
2032
|
+
rows.push({
|
|
2033
|
+
label: `This computer · ${me.name}`,
|
|
2034
|
+
// The fingerprint is what somebody at the other machine compares against. Shown here so
|
|
2035
|
+
// "what is my id" never means leaving the program to run a command. Beside it, whether
|
|
2036
|
+
// this machine can be dialled at all — the first question when adding it from over there.
|
|
2037
|
+
detail: [
|
|
2038
|
+
fingerprint(me.id),
|
|
2039
|
+
listening?.addr
|
|
2040
|
+
? `listening on ${listening.addr}`
|
|
2041
|
+
: listening?.error
|
|
2042
|
+
? `not listening — ${listening.error}`
|
|
2043
|
+
: "dial-only",
|
|
2044
|
+
"^Y copies the full id",
|
|
2045
|
+
].join(" · "),
|
|
2046
|
+
keywords: `self me ${me.id}`,
|
|
2047
|
+
value: { kind: "self" },
|
|
2048
|
+
});
|
|
2049
|
+
return rows;
|
|
2050
|
+
};
|
|
2051
|
+
|
|
2052
|
+
const items = await build();
|
|
2053
|
+
const refill = async () => {
|
|
2054
|
+
items.splice(0, items.length, ...(await build()));
|
|
2055
|
+
};
|
|
2056
|
+
|
|
2057
|
+
const chosen = await picker(neosh, items, {
|
|
2058
|
+
title: "Computers",
|
|
2059
|
+
width: 84,
|
|
2060
|
+
height: 14,
|
|
2061
|
+
hints: "↵ choose ^R reconnect ^D disconnect ^X remove ^Y my id esc close",
|
|
2062
|
+
// The list keeps up while it is open: a machine connecting, dropping, or moving from
|
|
2063
|
+
// "connecting" to a row of conversations changes under the cursor rather than on reopen.
|
|
2064
|
+
subscribe: (reload) =>
|
|
2065
|
+
neosh.swarm.onChange(() => {
|
|
2066
|
+
void refill().then(reload);
|
|
2067
|
+
}),
|
|
2068
|
+
// Chords, because every bare letter a picker takes is a letter its filter can never contain.
|
|
2069
|
+
ownKeys: ["<C-y>", "<C-x>", "<C-r>", "<C-d>"],
|
|
2070
|
+
async onKey(key, ctx) {
|
|
2071
|
+
if (key.key.code.kind !== "char" || !key.key.mods.ctrl) return;
|
|
2072
|
+
switch (key.key.code.c.toLowerCase()) {
|
|
2073
|
+
case "y":
|
|
2074
|
+
await neosh.edit.copy(me.id);
|
|
2075
|
+
neosh.notify("copied this computer's id");
|
|
2076
|
+
return "handled";
|
|
2077
|
+
case "r": {
|
|
2078
|
+
const row = ctx.item;
|
|
2079
|
+
if (row?.kind !== "node" || row.node.link.state === "up") return "handled";
|
|
2080
|
+
await neosh.swarm.reconnect(row.node.info.id).catch((e) => neosh.notify(String(e), "warn"));
|
|
2081
|
+
neosh.notify(`dialling ${row.node.info.name}…`);
|
|
2082
|
+
await refill();
|
|
2083
|
+
return "reload";
|
|
2084
|
+
}
|
|
2085
|
+
case "d": {
|
|
2086
|
+
const row = ctx.item;
|
|
2087
|
+
if (row?.kind !== "node" || row.node.link.state === "down") return "handled";
|
|
2088
|
+
await neosh.swarm.disconnect(row.node.info.id).catch((e) => neosh.notify(String(e), "warn"));
|
|
2089
|
+
neosh.notify(`disconnected ${row.node.info.name} — ^R takes it back`);
|
|
2090
|
+
await refill();
|
|
2091
|
+
return "reload";
|
|
2092
|
+
}
|
|
2093
|
+
case "x": {
|
|
2094
|
+
const row = ctx.item;
|
|
2095
|
+
if (row?.kind !== "node") return "handled";
|
|
2096
|
+
try {
|
|
2097
|
+
await neosh.swarm.unpair(row.node.info.id);
|
|
2098
|
+
neosh.notify(`removed ${row.node.info.name}`);
|
|
2099
|
+
await refill();
|
|
2100
|
+
return "reload";
|
|
2101
|
+
} catch (e) {
|
|
2102
|
+
// Almost always "that one is in your config file", which names the fix.
|
|
2103
|
+
neosh.notify(String(e), "warn");
|
|
2104
|
+
return "handled";
|
|
2105
|
+
}
|
|
2106
|
+
}
|
|
2107
|
+
default:
|
|
2108
|
+
return;
|
|
2109
|
+
}
|
|
2110
|
+
},
|
|
2111
|
+
});
|
|
2112
|
+
if (chosen === null) return;
|
|
2113
|
+
|
|
2114
|
+
switch (chosen.kind) {
|
|
2115
|
+
case "self":
|
|
2116
|
+
await neosh.edit.copy(me.id);
|
|
2117
|
+
neosh.notify("copied this computer's id");
|
|
2118
|
+
return;
|
|
2119
|
+
case "add":
|
|
2120
|
+
await addComputer(neosh);
|
|
2121
|
+
return;
|
|
2122
|
+
case "stranger": {
|
|
2123
|
+
const s = chosen.stranger;
|
|
2124
|
+
// Asked even though we already know who it is, because knowing who it is *is* the question:
|
|
2125
|
+
// the fingerprint on screen has to be the one the person at the other machine is reading out.
|
|
2126
|
+
const ok = await confirm(neosh, `Add ${s.info.name}?`, {
|
|
2127
|
+
yes: "Add",
|
|
2128
|
+
no: "Not now",
|
|
2129
|
+
detail: [
|
|
2130
|
+
`${s.info.os} · neosh ${s.info.version}`,
|
|
2131
|
+
fingerprint(s.info.id),
|
|
2132
|
+
"Check that fingerprint matches what the other computer shows under `This computer`.",
|
|
2133
|
+
],
|
|
2134
|
+
});
|
|
2135
|
+
if (!ok) return;
|
|
2136
|
+
await neosh.swarm.pair(s.info.id, { name: s.info.name, addr: s.addr ?? undefined });
|
|
2137
|
+
return;
|
|
2138
|
+
}
|
|
2139
|
+
case "node": {
|
|
2140
|
+
const newest = [...chosen.node.agents].sort((a, b) => b.updated_at - a.updated_at)[0];
|
|
2141
|
+
if (!newest) {
|
|
2142
|
+
neosh.notify(`${chosen.node.info.name} has nothing running`, "info");
|
|
2143
|
+
return;
|
|
2144
|
+
}
|
|
2145
|
+
await neosh.cmd.exec("swarm.open", [chosen.node.info.id, newest.session]).catch(() => {});
|
|
2146
|
+
}
|
|
2147
|
+
}
|
|
2148
|
+
}
|
|
2149
|
+
|
|
2150
|
+
// ---------------------------------------------------------------------------
|
|
2151
|
+
// Watching a conversation on another computer
|
|
2152
|
+
// ---------------------------------------------------------------------------
|
|
2153
|
+
|
|
2154
|
+
/** The buffer kind the remote view publishes, so its keys are ordinary bindings. */
|
|
2155
|
+
const VIEW_KIND = "neosh.swarm.view";
|
|
2156
|
+
|
|
2157
|
+
/** At most one open at a time, because there is one screen. */
|
|
2158
|
+
let openView: { close: () => Promise<void>; node: string; session: string } | null = null;
|
|
2159
|
+
|
|
2160
|
+
/**
|
|
2161
|
+
* Watch a conversation on another machine.
|
|
2162
|
+
*
|
|
2163
|
+
* A float rather than the chat pane, and that is a decision rather than a shortcut. The chat pane
|
|
2164
|
+
* is *your* conversation: it is where your composer sends, what `^S` reads, and what the permission
|
|
2165
|
+
* mode belongs to. Putting somebody else's conversation there would mean every one of those
|
|
2166
|
+
* questions has two answers, and the one you get depends on what you last pressed. A window that is
|
|
2167
|
+
* plainly a window onto another machine cannot be confused for the thing you are working in.
|
|
2168
|
+
*
|
|
2169
|
+
* What it is *not* is read-only. `i` steers it, `^C` interrupts it — because "feels like it is on
|
|
2170
|
+
* this computer" is a claim about what you can do, not only about what you can see.
|
|
2171
|
+
*/
|
|
2172
|
+
async function openRemote(
|
|
2173
|
+
neosh: Neosh,
|
|
2174
|
+
subscriptions: PluginContext["subscriptions"],
|
|
2175
|
+
node: string,
|
|
2176
|
+
session: string,
|
|
2177
|
+
): Promise<void> {
|
|
2178
|
+
// Switching from one remote conversation to another drops the first subscription. Otherwise a
|
|
2179
|
+
// morning of looking at machines leaves every one of them streaming every token here.
|
|
2180
|
+
if (openView) await openView.close();
|
|
2181
|
+
|
|
2182
|
+
const who = (await neosh.swarm.nodes().catch(() => []))
|
|
2183
|
+
.find((n) => n.info.id === node);
|
|
2184
|
+
const agent = who?.agents.find((a) => a.session === session);
|
|
2185
|
+
const name = who?.info.name ?? node.slice(0, 8);
|
|
2186
|
+
|
|
2187
|
+
const buf = await neosh.buf.create({
|
|
2188
|
+
name: `[${name}] ${agent?.label ?? "conversation"}`,
|
|
2189
|
+
scratch: true,
|
|
2190
|
+
kind: VIEW_KIND,
|
|
2191
|
+
});
|
|
2192
|
+
const ns = await neosh.ns.create("neosh.swarm.view");
|
|
2193
|
+
const lines: string[] = [
|
|
2194
|
+
` watching ${name}${agent ? ` · ${agent.project_name}` : ""}`,
|
|
2195
|
+
` ${agent?.cwd ?? ""}`,
|
|
2196
|
+
"",
|
|
2197
|
+
" waiting for this machine to say what has happened so far…",
|
|
2198
|
+
];
|
|
2199
|
+
await neosh.buf.setLines(buf, 0, -1, lines);
|
|
2200
|
+
|
|
2201
|
+
const win = await neosh.float.open(buf, {
|
|
2202
|
+
anchor: { kind: "screen" },
|
|
2203
|
+
width: { kind: "max", n: 96 },
|
|
2204
|
+
height: { kind: "max", n: 28 },
|
|
2205
|
+
border: "rounded",
|
|
2206
|
+
title: ` ${name} `,
|
|
2207
|
+
focusable: true,
|
|
2208
|
+
});
|
|
2209
|
+
await neosh.focus.push(win);
|
|
2210
|
+
|
|
2211
|
+
const header = lines.slice(0, 3);
|
|
2212
|
+
let body: string[] = [];
|
|
2213
|
+
/** Whether the last thing appended is an assistant turn still being written into. */
|
|
2214
|
+
let streaming = false;
|
|
2215
|
+
|
|
2216
|
+
const redraw = async () => {
|
|
2217
|
+
const all = [...header, ...body];
|
|
2218
|
+
// One call. This redraws per token, so a repaint the frontend could draw halfway through — text
|
|
2219
|
+
// written, marks not yet — would be a transcript strobing white for the whole of an answer.
|
|
2220
|
+
const drawn: DrawnRow[] = all.map((text) => ({ text, marks: [] }));
|
|
2221
|
+
const mark = (line: number, hl: string, text: string) => {
|
|
2222
|
+
drawn[line]?.marks!.push({ col: 0, opts: { hlGroup: hl, endCol: byteLength(text) } });
|
|
2223
|
+
};
|
|
2224
|
+
|
|
2225
|
+
mark(0, "Title", all[0] ?? "");
|
|
2226
|
+
if (all[1]) mark(1, "Comment", all[1]);
|
|
2227
|
+
for (let i = header.length; i < all.length; i++) {
|
|
2228
|
+
const text = all[i] ?? "";
|
|
2229
|
+
if (text.startsWith(" › ")) {
|
|
2230
|
+
mark(i, "Accent", text);
|
|
2231
|
+
} else if (text.startsWith(" · ")) {
|
|
2232
|
+
mark(i, "Comment", text);
|
|
2233
|
+
}
|
|
2234
|
+
}
|
|
2235
|
+
await neosh.buf.render(buf, ns, 0, -1, drawn);
|
|
2236
|
+
// The newest line, not the oldest: a transcript you have just opened should be showing the end
|
|
2237
|
+
// of the conversation, which is the part that is still happening.
|
|
2238
|
+
await neosh.win.scrollTo(win, Math.max(0, all.length - 24));
|
|
2239
|
+
};
|
|
2240
|
+
|
|
2241
|
+
const sub = neosh.swarm.onStream(async (e) => {
|
|
2242
|
+
if (e.node !== node || e.session !== session) return;
|
|
2243
|
+
const ev = e.event;
|
|
2244
|
+
switch (ev.event) {
|
|
2245
|
+
case "history":
|
|
2246
|
+
body = renderMessages(ev.messages);
|
|
2247
|
+
streaming = false;
|
|
2248
|
+
break;
|
|
2249
|
+
case "turn_started":
|
|
2250
|
+
streaming = false;
|
|
2251
|
+
break;
|
|
2252
|
+
case "token": {
|
|
2253
|
+
if (!streaming) {
|
|
2254
|
+
body.push("");
|
|
2255
|
+
streaming = true;
|
|
2256
|
+
}
|
|
2257
|
+
// Appended to the last row, and split on newlines, because tokens are provider-sized rather
|
|
2258
|
+
// than line-sized and a naive push makes one row per chunk.
|
|
2259
|
+
const last = body.pop() ?? "";
|
|
2260
|
+
const merged = (last + ev.text).split("\n");
|
|
2261
|
+
body.push(...merged);
|
|
2262
|
+
break;
|
|
2263
|
+
}
|
|
2264
|
+
case "thinking":
|
|
2265
|
+
break;
|
|
2266
|
+
case "activity":
|
|
2267
|
+
break;
|
|
2268
|
+
case "turn_ended":
|
|
2269
|
+
streaming = false;
|
|
2270
|
+
body.push("");
|
|
2271
|
+
break;
|
|
2272
|
+
case "blocked":
|
|
2273
|
+
body.push(` · waiting for somebody at ${name}: ${ev.prompt}`);
|
|
2274
|
+
break;
|
|
2275
|
+
}
|
|
2276
|
+
await redraw();
|
|
2277
|
+
});
|
|
2278
|
+
|
|
2279
|
+
const close = async () => {
|
|
2280
|
+
if (openView?.session !== session) return;
|
|
2281
|
+
openView = null;
|
|
2282
|
+
sub.dispose();
|
|
2283
|
+
await neosh.swarm.unsubscribe(node, session).catch(() => {});
|
|
2284
|
+
await neosh.focus.pop().catch(() => {});
|
|
2285
|
+
await neosh.win.close(win).catch(() => {});
|
|
2286
|
+
};
|
|
2287
|
+
openView = { close, node, session };
|
|
2288
|
+
subscriptions.push({ dispose: () => void close() });
|
|
2289
|
+
|
|
2290
|
+
await installViewKeys(neosh, subscriptions, () => openView);
|
|
2291
|
+
try {
|
|
2292
|
+
await neosh.swarm.subscribe(node, session);
|
|
2293
|
+
} catch (e) {
|
|
2294
|
+
neosh.notify(String(e), "warn");
|
|
2295
|
+
await close();
|
|
2296
|
+
}
|
|
2297
|
+
}
|
|
2298
|
+
|
|
2299
|
+
/**
|
|
2300
|
+
* The keys inside a remote view.
|
|
2301
|
+
*
|
|
2302
|
+
* Registered once and bound at buffer-kind scope, so `^Z` lists them and `init.ts` can move them —
|
|
2303
|
+
* the same deal every other panel key gets. Registered lazily rather than at activation because
|
|
2304
|
+
* most workspaces never open one.
|
|
2305
|
+
*/
|
|
2306
|
+
let viewKeysInstalled = false;
|
|
2307
|
+
async function installViewKeys(
|
|
2308
|
+
neosh: Neosh,
|
|
2309
|
+
subscriptions: PluginContext["subscriptions"],
|
|
2310
|
+
current: () => { node: string; session: string; close: () => Promise<void> } | null,
|
|
2311
|
+
): Promise<void> {
|
|
2312
|
+
if (viewKeysInstalled) return;
|
|
2313
|
+
viewKeysInstalled = true;
|
|
2314
|
+
const scope = { kind: "buf_kind", name: VIEW_KIND } as const;
|
|
2315
|
+
|
|
2316
|
+
const bind = async (name: string, key: string, desc: string, fn: () => Promise<void>) => {
|
|
2317
|
+
subscriptions.push(await neosh.cmd.register(name, fn, { desc }));
|
|
2318
|
+
await neosh.keymap.set("chat", key, name, { scope, desc });
|
|
2319
|
+
};
|
|
2320
|
+
|
|
2321
|
+
await bind("swarm.view.close", "<Esc>", "Stop watching", async () => {
|
|
2322
|
+
await current()?.close();
|
|
2323
|
+
});
|
|
2324
|
+
await neosh.keymap.set("chat", "q", "swarm.view.close", { scope });
|
|
2325
|
+
|
|
2326
|
+
await bind("swarm.view.steer", "i", "Say something to this agent", async () => {
|
|
2327
|
+
const view = current();
|
|
2328
|
+
if (!view) return;
|
|
2329
|
+
const text = await prompt(neosh, "Say something", { width: 72 });
|
|
2330
|
+
if (text === null || text.trim() === "") return;
|
|
2331
|
+
try {
|
|
2332
|
+
await neosh.swarm.command(view.node, view.session, { command: "send", text });
|
|
2333
|
+
} catch (e) {
|
|
2334
|
+
// The owner refused — read-only, or the conversation went away. Its words, not ours.
|
|
2335
|
+
neosh.notify(String(e), "warn");
|
|
2336
|
+
}
|
|
2337
|
+
});
|
|
2338
|
+
|
|
2339
|
+
await bind("swarm.view.interrupt", "<C-c>", "Ask this turn to stop", async () => {
|
|
2340
|
+
const view = current();
|
|
2341
|
+
if (!view) return;
|
|
2342
|
+
try {
|
|
2343
|
+
await neosh.swarm.command(view.node, view.session, { command: "interrupt" });
|
|
2344
|
+
neosh.notify("asked it to stop");
|
|
2345
|
+
} catch (e) {
|
|
2346
|
+
neosh.notify(String(e), "warn");
|
|
2347
|
+
}
|
|
2348
|
+
});
|
|
2349
|
+
}
|
|
2350
|
+
|
|
2351
|
+
/** Messages as rows. Deliberately plain: this is a window onto somewhere else, not a transcript. */
|
|
2352
|
+
function renderMessages(messages: Message[]): string[] {
|
|
2353
|
+
const out: string[] = [];
|
|
2354
|
+
for (const m of messages) {
|
|
2355
|
+
for (const block of m.content) {
|
|
2356
|
+
if (block.type === "text") {
|
|
2357
|
+
if (m.role === "user") {
|
|
2358
|
+
out.push(` › ${block.text.split("\n")[0] ?? ""}`);
|
|
2359
|
+
for (const rest of block.text.split("\n").slice(1)) out.push(` ${rest}`);
|
|
2360
|
+
} else {
|
|
2361
|
+
for (const line of block.text.split("\n")) out.push(` ${line}`);
|
|
2362
|
+
}
|
|
2363
|
+
} else if (block.type === "tool_use") {
|
|
2364
|
+
out.push(` · ${block.name}`);
|
|
2365
|
+
}
|
|
2366
|
+
}
|
|
2367
|
+
out.push("");
|
|
2368
|
+
}
|
|
2369
|
+
return out;
|
|
2370
|
+
}
|
|
2371
|
+
|
|
2372
|
+
/**
|
|
2373
|
+
* Add a machine by dialling it.
|
|
2374
|
+
*
|
|
2375
|
+
* The address is all you type. Everything else — its name, its fingerprint, whether it is even a
|
|
2376
|
+
* neosh — is read off the machine itself, because a public key typed from memory is a public key
|
|
2377
|
+
* typed wrong, and because the point of showing a fingerprint is that it came from the far end
|
|
2378
|
+
* rather than from the same person who typed the address.
|
|
2379
|
+
*/
|
|
2380
|
+
async function addComputer(neosh: Neosh): Promise<void> {
|
|
2381
|
+
const addr = await prompt(neosh, "Add a computer — hostname:7717", { width: 60 });
|
|
2382
|
+
if (addr === null || addr.trim() === "") return;
|
|
2383
|
+
// A port is easy to forget and there is only one sensible default.
|
|
2384
|
+
const target = addr.includes(":") ? addr.trim() : `${addr.trim()}:7717`;
|
|
2385
|
+
|
|
2386
|
+
let found;
|
|
2387
|
+
try {
|
|
2388
|
+
// A state that stops being true the moment the far end answers or does not.
|
|
2389
|
+
neosh.progress("swarm.probe", `asking ${target}…`);
|
|
2390
|
+
found = await neosh.swarm.probe(target);
|
|
2391
|
+
} catch (e) {
|
|
2392
|
+
// `String(e)` here would read `NeoshError: not found: …: swarm i/o: Connection refused (os
|
|
2393
|
+
// error 61)`, which names three layers of plumbing before it gets to the part a person can act
|
|
2394
|
+
// on. What went wrong is that nothing answered, and what to check is spelling and whether the
|
|
2395
|
+
// other neosh is running.
|
|
2396
|
+
neosh.notify(
|
|
2397
|
+
`nothing answered at ${target} — is neosh running there, with `
|
|
2398
|
+
+ "`listen` set in its `[swarm]`?",
|
|
2399
|
+
"warn",
|
|
2400
|
+
);
|
|
2401
|
+
neosh.log.warn(`swarm probe of ${target} failed: ${String(e)}`);
|
|
2402
|
+
return;
|
|
2403
|
+
} finally {
|
|
2404
|
+
neosh.done("swarm.probe");
|
|
2405
|
+
}
|
|
2406
|
+
|
|
2407
|
+
const ok = await confirm(neosh, `Add ${found.name}?`, {
|
|
2408
|
+
yes: "Add",
|
|
2409
|
+
no: "Cancel",
|
|
2410
|
+
detail: [
|
|
2411
|
+
`${target} · ${found.os} · neosh ${found.version}`,
|
|
2412
|
+
fingerprint(found.id),
|
|
2413
|
+
"Check that fingerprint matches what that computer shows under `This computer`.",
|
|
2414
|
+
],
|
|
2415
|
+
});
|
|
2416
|
+
if (!ok) return;
|
|
2417
|
+
|
|
2418
|
+
await neosh.swarm.pair(found.id, { name: found.name, addr: target });
|
|
2419
|
+
// Both halves have to happen, and only one of them is ours. Saying so now saves the ten minutes
|
|
2420
|
+
// otherwise spent wondering why the machine is listed and permanently unreachable.
|
|
2421
|
+
neosh.notify(`${found.name} added — now allow this computer over there, with ^J`);
|
|
2422
|
+
}
|
|
2423
|
+
|
|
2424
|
+
/**
|
|
2425
|
+
* A node id, in groups, for reading aloud.
|
|
2426
|
+
*
|
|
2427
|
+
* Sixty-four hex characters is not something a person can compare. Four groups of four from the
|
|
2428
|
+
* front is — and it is the *front* rather than a hash of the whole because that is what both
|
|
2429
|
+
* machines display, so the two are comparable by eye.
|
|
2430
|
+
*/
|
|
2431
|
+
function fingerprint(id: string): string {
|
|
2432
|
+
const head = id.slice(0, 16);
|
|
2433
|
+
return (head.match(/.{1,4}/g) ?? [head]).join(" ");
|
|
2434
|
+
}
|
|
2435
|
+
|
|
2436
|
+
async function renameSession(neosh: Neosh, session: string): Promise<void> {
|
|
2437
|
+
const current = (await neosh.session.list()).find((s) => s.id === session);
|
|
2438
|
+
const next = await prompt(neosh, "Rename conversation", {
|
|
2439
|
+
initial: current?.title ?? "",
|
|
2440
|
+
width: 60,
|
|
2441
|
+
});
|
|
2442
|
+
if (next === null) return;
|
|
2443
|
+
await neosh.session.rename(session, next.trim() === "" ? null : next);
|
|
2444
|
+
}
|
|
2445
|
+
|
|
2446
|
+
// ---------------------------------------------------------------------------
|
|
2447
|
+
// Content
|
|
2448
|
+
// ---------------------------------------------------------------------------
|
|
2449
|
+
|
|
2450
|
+
/**
|
|
2451
|
+
* Projects, as two ordered groups: pinned, then the rest.
|
|
2452
|
+
*
|
|
2453
|
+
* A project is a directory you have worked in — added by hand, or arrived at because a conversation
|
|
2454
|
+
* was started there — and it stays one until you remove it. Every known directory is seeded, empty
|
|
2455
|
+
* or not, which is the whole of the fix for a project vanishing when you cleared out its
|
|
2456
|
+
* conversations: the list is written down (see [`VAR_KNOWN`]) rather than inferred from what
|
|
2457
|
+
* happens to be in it, and `X` on the heading is the only thing that shortens it.
|
|
2458
|
+
*/
|
|
2459
|
+
function group(
|
|
2460
|
+
sessions: SessionInfo[],
|
|
2461
|
+
arrangement: Arrangement,
|
|
2462
|
+
keys: Map<string, string>,
|
|
2463
|
+
): Project[][] {
|
|
2464
|
+
const by = new Map<string, SessionInfo[]>();
|
|
2465
|
+
for (const cwd of arrangement.all()) by.set(cwd, []);
|
|
2466
|
+
for (const s of sessions) {
|
|
2467
|
+
const existing = by.get(s.cwd);
|
|
2468
|
+
if (existing) existing.push(s);
|
|
2469
|
+
else by.set(s.cwd, [s]);
|
|
2470
|
+
}
|
|
2471
|
+
|
|
2472
|
+
// Which checkout each directory is a linked worktree of, learned from its conversations — and
|
|
2473
|
+
// from what was written down the last time one of them said so, because a project stays on the
|
|
2474
|
+
// list after its conversations have gone and an emptied worktree is still a worktree.
|
|
2475
|
+
const rootOf = new Map<string, string>();
|
|
2476
|
+
for (const s of sessions) {
|
|
2477
|
+
if (s.repo_root && s.repo_root !== s.cwd && !rootOf.has(s.cwd)) {
|
|
2478
|
+
rootOf.set(s.cwd, s.repo_root);
|
|
2479
|
+
}
|
|
2480
|
+
}
|
|
2481
|
+
for (const cwd of by.keys()) {
|
|
2482
|
+
const remembered = arrangement.root(cwd);
|
|
2483
|
+
if (remembered && !rootOf.has(cwd)) rootOf.set(cwd, remembered);
|
|
2484
|
+
}
|
|
2485
|
+
|
|
2486
|
+
// `session.list()` is most-recently-used first, so index in it is recency. Anything you have
|
|
2487
|
+
// never dragged sorts by that, after everything you have.
|
|
2488
|
+
const recency = new Map<string, number>();
|
|
2489
|
+
sessions.forEach((s, i) => {
|
|
2490
|
+
if (!recency.has(s.cwd)) recency.set(s.cwd, i);
|
|
2491
|
+
});
|
|
2492
|
+
|
|
2493
|
+
const make = (cwd: string, list: SessionInfo[]): Project => ({
|
|
2494
|
+
cwd,
|
|
2495
|
+
// What the host decided to call it. For a worktree that is the repository and the branch,
|
|
2496
|
+
// rather than whatever the directory happens to be named — a row reading `wt-fe3c0d93`
|
|
2497
|
+
// tells you nothing about which checkout it is, and reads as somebody else's directory having
|
|
2498
|
+
// wandered into your workspace. With nothing in it there is nobody to ask, so the last answer
|
|
2499
|
+
// is kept: emptying a project must not also rename it.
|
|
2500
|
+
name: list[0]?.project || arrangement.name(cwd) || basename(cwd),
|
|
2501
|
+
favorite: arrangement.isFavorite(cwd),
|
|
2502
|
+
sessions: list,
|
|
2503
|
+
key: keys.get(cwd) ?? `dir:${basename(cwd)}`,
|
|
2504
|
+
worktrees: [],
|
|
2505
|
+
});
|
|
2506
|
+
|
|
2507
|
+
const tops = new Map<string, Project>();
|
|
2508
|
+
const linked: Project[] = [];
|
|
2509
|
+
for (const [cwd, list] of by) {
|
|
2510
|
+
const root = rootOf.get(cwd);
|
|
2511
|
+
if (!root) {
|
|
2512
|
+
tops.set(cwd, make(cwd, list));
|
|
2513
|
+
continue;
|
|
2514
|
+
}
|
|
2515
|
+
// Nested under the repository, the repository's name is said by the row above — what is left
|
|
2516
|
+
// to say is which tree this one is. The branch, like the host's own label; the directory only
|
|
2517
|
+
// on a detached head, where it is all there is.
|
|
2518
|
+
linked.push({ ...make(cwd, list), name: list[0]?.branch || basename(cwd) });
|
|
2519
|
+
}
|
|
2520
|
+
for (const child of linked) {
|
|
2521
|
+
const root = rootOf.get(child.cwd)!;
|
|
2522
|
+
// A repository whose every conversation is in a worktree still has a row of its own — the
|
|
2523
|
+
// worktrees have to hang from something, and the checkout is a real place `↵` can start a
|
|
2524
|
+
// conversation in.
|
|
2525
|
+
const parent = tops.get(root) ?? make(root, []);
|
|
2526
|
+
tops.set(root, parent);
|
|
2527
|
+
parent.worktrees.push(child);
|
|
2528
|
+
}
|
|
2529
|
+
|
|
2530
|
+
// A project's recency is its newest conversation *anywhere in it* — a repository whose only
|
|
2531
|
+
// activity is in a worktree should float with that work, not sink because its own checkout is
|
|
2532
|
+
// quiet.
|
|
2533
|
+
const newest = (p: Project): number =>
|
|
2534
|
+
Math.min(
|
|
2535
|
+
recency.get(p.cwd) ?? Number.MAX_SAFE_INTEGER,
|
|
2536
|
+
...p.worktrees.map((t) => recency.get(t.cwd) ?? Number.MAX_SAFE_INTEGER),
|
|
2537
|
+
);
|
|
2538
|
+
|
|
2539
|
+
const sort = (a: Project, b: Project) => {
|
|
2540
|
+
const ra = arrangement.rank(a.cwd);
|
|
2541
|
+
const rb = arrangement.rank(b.cwd);
|
|
2542
|
+
if (ra !== rb) return ra - rb;
|
|
2543
|
+
const fa = newest(a);
|
|
2544
|
+
const fb = newest(b);
|
|
2545
|
+
return fa !== fb ? fa - fb : a.name.localeCompare(b.name);
|
|
2546
|
+
};
|
|
2547
|
+
|
|
2548
|
+
const projects = [...tops.values()];
|
|
2549
|
+
for (const p of projects) p.worktrees.sort(sort);
|
|
2550
|
+
return [
|
|
2551
|
+
projects.filter((p) => p.favorite).sort(sort),
|
|
2552
|
+
projects.filter((p) => !p.favorite).sort(sort),
|
|
2553
|
+
];
|
|
2554
|
+
}
|
|
2555
|
+
|
|
2556
|
+
interface DrawOptions {
|
|
2557
|
+
width: number;
|
|
2558
|
+
ascii: boolean;
|
|
2559
|
+
hints: boolean;
|
|
2560
|
+
focused: boolean;
|
|
2561
|
+
selected: Target | undefined;
|
|
2562
|
+
/** The verbs other plugins put on our rows, for the hint strip. */
|
|
2563
|
+
actions: ActionItem[];
|
|
2564
|
+
/** Which conversations are waiting on an answer from you. See [`VAR_ASKING`]. */
|
|
2565
|
+
asking: Set<string>;
|
|
2566
|
+
/** Marks other plugins put on our rows, by [`targetKey`]. See [`POINT_DECORATION`]. */
|
|
2567
|
+
decorations: Map<string, Decoration>;
|
|
2568
|
+
/** A count typed but not yet spent, drawn at the foot the way Vim draws it. */
|
|
2569
|
+
count: string;
|
|
2570
|
+
/** Conversations on other computers, grouped by the project key they share with ours. */
|
|
2571
|
+
remote: Map<string, SwarmAgent[]>;
|
|
2572
|
+
/** Which other machines have each project, by key. */
|
|
2573
|
+
hosts: Map<string, string[]>;
|
|
2574
|
+
}
|
|
2575
|
+
|
|
2576
|
+
/**
|
|
2577
|
+
* The waiting set, out of whatever was in the var.
|
|
2578
|
+
*
|
|
2579
|
+
* Checked rather than trusted, like every contribution: this is JSON written by a plugin this one
|
|
2580
|
+
* has never heard of, and a panel that throws on it is a panel a third party can take off the screen
|
|
2581
|
+
* by writing a string where a list goes.
|
|
2582
|
+
*/
|
|
2583
|
+
function asked(value: unknown): Set<string> {
|
|
2584
|
+
if (!Array.isArray(value)) return new Set();
|
|
2585
|
+
return new Set(value.filter((v): v is string => typeof v === "string"));
|
|
2586
|
+
}
|
|
2587
|
+
|
|
2588
|
+
async function collect(
|
|
2589
|
+
neosh: Neosh,
|
|
2590
|
+
arrangement: Arrangement,
|
|
2591
|
+
opts: DrawOptions,
|
|
2592
|
+
): Promise<{ rows: ListRow<Target>[]; running: boolean; pinned: number }> {
|
|
2593
|
+
const rows: ListRow<Target>[] = [];
|
|
2594
|
+
|
|
2595
|
+
// Failures absorbed: a panel that renders an exception instead of your conversations is worse
|
|
2596
|
+
// than one showing less.
|
|
2597
|
+
const all = await neosh.session
|
|
2598
|
+
.list({ includeArchived: true })
|
|
2599
|
+
.catch(() => [] as SessionInfo[]);
|
|
2600
|
+
const sessions = all.filter((s) => !s.archived);
|
|
2601
|
+
const running = sessions.some((s) => s.active_turn);
|
|
2602
|
+
const now = Date.now();
|
|
2603
|
+
// One list, favourites first. A separate `FAVORITES` section splits a short list in half and
|
|
2604
|
+
// makes you check two places for the same kind of thing; the star says which is which without
|
|
2605
|
+
// costing a heading, a rule and a blank line.
|
|
2606
|
+
// What the other computers are running. One call; empty and harmless on a single machine.
|
|
2607
|
+
const swarm = await neosh.swarm.agents().catch(() => [] as SwarmAgent[]);
|
|
2608
|
+
const remote = new Map<string, SwarmAgent[]>();
|
|
2609
|
+
const hosts = new Map<string, string[]>();
|
|
2610
|
+
for (const r of swarm) {
|
|
2611
|
+
const list = remote.get(r.agent.project) ?? [];
|
|
2612
|
+
list.push(r);
|
|
2613
|
+
remote.set(r.agent.project, list);
|
|
2614
|
+
const names = hosts.get(r.agent.project) ?? [];
|
|
2615
|
+
if (!names.includes(r.node.name)) names.push(r.node.name);
|
|
2616
|
+
hosts.set(r.agent.project, names.sort());
|
|
2617
|
+
}
|
|
2618
|
+
// A local conversation tells us its project key indirectly: the host stamps the same key on both
|
|
2619
|
+
// sides, so a cwd here and a cwd there meet on the key rather than on the path.
|
|
2620
|
+
const keys = new Map<string, string>();
|
|
2621
|
+
for (const r of swarm) {
|
|
2622
|
+
if (!keys.has(r.agent.cwd)) keys.set(r.agent.cwd, r.agent.project);
|
|
2623
|
+
}
|
|
2624
|
+
// Marks other plugins put on our rows. Read every frame for the reason sections are: a
|
|
2625
|
+
// decorator re-contributes in place when its data changes, and the read is one call.
|
|
2626
|
+
const decorations = mergeDecorations(
|
|
2627
|
+
await neosh.ext.list<DecorationItem>(POINT_DECORATION).catch(() => []),
|
|
2628
|
+
);
|
|
2629
|
+
opts = { ...opts, remote, hosts, decorations };
|
|
2630
|
+
const projects = group(sessions, arrangement, keys).flat();
|
|
2631
|
+
|
|
2632
|
+
// A directory that turned up in the conversation list and we had not seen before. Noted rather
|
|
2633
|
+
// than fetched inline: the draw runs on a tick and must not wait on a round trip per project.
|
|
2634
|
+
void arrangement.note(all.map((s) => s.cwd));
|
|
2635
|
+
// And what the host calls it, and which checkout a tree hangs off, so the row still says both
|
|
2636
|
+
// once the last conversation in it has gone.
|
|
2637
|
+
arrangement.remember([
|
|
2638
|
+
...projects.filter((p) => p.sessions.length > 0),
|
|
2639
|
+
...projects.flatMap((p) =>
|
|
2640
|
+
p.worktrees.filter((t) => t.sessions.length > 0).map((t) => ({ ...t, root: p.cwd }))
|
|
2641
|
+
),
|
|
2642
|
+
]);
|
|
2643
|
+
|
|
2644
|
+
// Rows other plugins own. Read every frame rather than cached, because a contribution is replaced
|
|
2645
|
+
// in place when its author's data changes and re-reading is one call.
|
|
2646
|
+
const sections = await neosh.ext.list<SectionItem>(POINT_SECTION).catch(() => []);
|
|
2647
|
+
const order = placeSections(SLOTS, sections);
|
|
2648
|
+
const section = (c: Contribution & { item: SectionItem }) =>
|
|
2649
|
+
rows.push(...contributedRows<Target>(c, {
|
|
2650
|
+
width: opts.width,
|
|
2651
|
+
custom: (command, args) => ({ kind: "custom", command, args }),
|
|
2652
|
+
}));
|
|
2653
|
+
|
|
2654
|
+
// The blocks this panel draws, each the same shape: so that a section can sit before or after
|
|
2655
|
+
// any of them by name, and the foot is whatever lands after the last one.
|
|
2656
|
+
const blocks: Record<Slot, () => void> = {
|
|
2657
|
+
projects: () => {
|
|
2658
|
+
rows.push(...heading("PROJECTS", opts.width));
|
|
2659
|
+
for (const p of projects) {
|
|
2660
|
+
rows.push(projectRow(p, arrangement, opts, now));
|
|
2661
|
+
if (arrangement.isFolded(p.cwd)) continue;
|
|
2662
|
+
for (const s of p.sessions) rows.push(sessionRow(s, now, opts));
|
|
2663
|
+
// Its worktrees, inside it. Each is a project row one level down — its own fold, its own
|
|
2664
|
+
// rank, `n` makes another conversation in it — because a worktree of a repository is not a
|
|
2665
|
+
// neighbour of the repository, and the column should say so.
|
|
2666
|
+
for (const t of p.worktrees) {
|
|
2667
|
+
rows.push(worktreeRow(t, arrangement, opts, now));
|
|
2668
|
+
if (arrangement.isFolded(t.cwd)) continue;
|
|
2669
|
+
for (const s of t.sessions) rows.push(sessionRow(s, now, opts, 1));
|
|
2670
|
+
}
|
|
2671
|
+
// The same project, being worked on elsewhere. Under the same heading rather than in a
|
|
2672
|
+
// section of their own: they are not a different kind of thing, they are the same work on
|
|
2673
|
+
// a different computer, and a separate `REMOTE` block would make you check two places for
|
|
2674
|
+
// one project.
|
|
2675
|
+
for (const r of remote.get(p.key) ?? []) rows.push(remoteRow(r, opts, now));
|
|
2676
|
+
if (
|
|
2677
|
+
p.sessions.length === 0 && p.worktrees.length === 0 &&
|
|
2678
|
+
(remote.get(p.key) ?? []).length === 0
|
|
2679
|
+
) {
|
|
2680
|
+
rows.push({ text: " nothing here yet", hl: "Sidebar.Dim", inert: true });
|
|
2681
|
+
}
|
|
2682
|
+
}
|
|
2683
|
+
|
|
2684
|
+
// Projects that exist only on other machines. Without these, a repository you have not
|
|
2685
|
+
// cloned here is invisible — and "which computers is this on" cannot answer "not this one".
|
|
2686
|
+
for (const [key, list] of remote) {
|
|
2687
|
+
if (projects.some((p) => p.key === key)) continue;
|
|
2688
|
+
const first = list[0];
|
|
2689
|
+
if (!first) continue;
|
|
2690
|
+
rows.push({
|
|
2691
|
+
text: ` ${opts.ascii ? "~" : "▹"} ${clip(first.agent.project_name, opts.width - 10)}`,
|
|
2692
|
+
hl: "Sidebar.Remote",
|
|
2693
|
+
right: { text: `${clip((hosts.get(key) ?? []).join(" "), 12)} `, hl: "Sidebar.Remote" },
|
|
2694
|
+
inert: true,
|
|
2695
|
+
});
|
|
2696
|
+
for (const r of list) rows.push(remoteRow(r, opts, now));
|
|
2697
|
+
}
|
|
2698
|
+
},
|
|
2699
|
+
add: () => {
|
|
2700
|
+
rows.push(blank());
|
|
2701
|
+
rows.push({
|
|
2702
|
+
text: " + Add project",
|
|
2703
|
+
hl: "Accent",
|
|
2704
|
+
value: { kind: "add" },
|
|
2705
|
+
});
|
|
2706
|
+
},
|
|
2707
|
+
// No `archived` block any more: what you have put away is the archive plugin's panel, and its
|
|
2708
|
+
// row in this column arrives as a `sidebar.section` contribution like any third party's.
|
|
2709
|
+
};
|
|
2710
|
+
|
|
2711
|
+
// Everything after the last of our own blocks is the panel's foot: rows somebody contributed
|
|
2712
|
+
// below the list, and the key strip. Counted so the list can hold them against the bottom edge —
|
|
2713
|
+
// a plan gauge that sits under the last project is in a different place every time a project is
|
|
2714
|
+
// added or folded, which is the one thing a status strip must not be.
|
|
2715
|
+
let body = 0;
|
|
2716
|
+
for (const entry of order) {
|
|
2717
|
+
if (typeof entry === "string") {
|
|
2718
|
+
blocks[entry]();
|
|
2719
|
+
body = rows.length;
|
|
2720
|
+
} else {
|
|
2721
|
+
section(entry);
|
|
2722
|
+
}
|
|
2723
|
+
}
|
|
2724
|
+
|
|
2725
|
+
if (opts.hints) rows.push(...hints(opts));
|
|
2726
|
+
|
|
2727
|
+
return { rows, running, pinned: rows.length - body };
|
|
2728
|
+
}
|
|
2729
|
+
|
|
2730
|
+
/** A section heading, with a rule under it — what turns a column of text into sections. */
|
|
2731
|
+
function heading(text: string, width: number, hint?: string): ListRow<Target>[] {
|
|
2732
|
+
return [
|
|
2733
|
+
{
|
|
2734
|
+
text: ` ${text}`,
|
|
2735
|
+
hl: "Sidebar.Heading",
|
|
2736
|
+
// Dim, and on the heading rather than beside the title: it is an answer to "and then what",
|
|
2737
|
+
// which is a question you ask after reading the section, not while finding it.
|
|
2738
|
+
right: hint ? { text: `${hint} `, hl: "Sidebar.Dim" } : undefined,
|
|
2739
|
+
inert: true,
|
|
2740
|
+
},
|
|
2741
|
+
{ text: "─".repeat(Math.max(1, width)), hl: "Separator", inert: true },
|
|
2742
|
+
];
|
|
2743
|
+
}
|
|
2744
|
+
|
|
2745
|
+
function blank(): ListRow<Target> {
|
|
2746
|
+
return { text: "", inert: true };
|
|
2747
|
+
}
|
|
2748
|
+
|
|
2749
|
+
function projectRow(
|
|
2750
|
+
p: Project,
|
|
2751
|
+
arrangement: Arrangement,
|
|
2752
|
+
opts: DrawOptions,
|
|
2753
|
+
now: number,
|
|
2754
|
+
): ListRow<Target> {
|
|
2755
|
+
const folded = arrangement.isFolded(p.cwd);
|
|
2756
|
+
const arrow = opts.ascii ? (folded ? ">" : "v") : folded ? "▸" : "▾";
|
|
2757
|
+
// "Inside it" includes its worktrees: a repository whose only running turn is in a scratch tree
|
|
2758
|
+
// is still a repository where something is happening, and the folded count has to count what
|
|
2759
|
+
// folding hid.
|
|
2760
|
+
const within = [...p.sessions, ...p.worktrees.flatMap((t) => t.sessions)];
|
|
2761
|
+
const busy = within.find((s) => s.active_turn);
|
|
2762
|
+
const here = p.sessions.some((s) => s.is_active);
|
|
2763
|
+
// Something in here has stopped and is waiting on an answer. It is still a turn in flight, so
|
|
2764
|
+
// `busy` finds it too — this is what decides which of the two the row says. Over `within` for the
|
|
2765
|
+
// reason the count is: a question asked in a scratch tree of this repository is a question in
|
|
2766
|
+
// this repository, and folding is what hid the row that would otherwise say so.
|
|
2767
|
+
const waiting = within.some((s) => opts.asking.has(s.id));
|
|
2768
|
+
|
|
2769
|
+
// The count is the useful thing when a project is folded, and the elapsed time is the useful
|
|
2770
|
+
// thing when something inside it is working. Never both — there is one column.
|
|
2771
|
+
const right = busy
|
|
2772
|
+
? { text: `${turnFor(busy, now)} `, hl: waiting ? "Status.Pending" : "Status.Working" }
|
|
2773
|
+
: within.length > 0
|
|
2774
|
+
? { text: `${within.length} `, hl: "Sidebar.Dim" }
|
|
2775
|
+
: { text: "" };
|
|
2776
|
+
|
|
2777
|
+
// The star sits directly after the name, and costs nothing on a project that has not got one.
|
|
2778
|
+
// It used to be a fixed column *before* the fold arrow, which meant every project name in the
|
|
2779
|
+
// narrowest panel in the workspace paid two columns, permanently, for a mark on three or four
|
|
2780
|
+
// rows — and the right-hand edge is not the answer either: that column already belongs to the
|
|
2781
|
+
// count and the elapsed time, which is a number you read against the rows above and below it.
|
|
2782
|
+
// Attached to the name it is a property of the thing it is beside, which is what it is. It keeps
|
|
2783
|
+
// its own highlight because the world has already decided what colour a favourite is — a grey
|
|
2784
|
+
// star is a star you have to decode.
|
|
2785
|
+
//
|
|
2786
|
+
// A star rather than a heart, and the reason is font fallback: `♥` is U+2665, which Unicode
|
|
2787
|
+
// classifies as an emoji even though its default presentation is text. A terminal that has a
|
|
2788
|
+
// colour-emoji font installed routes it there, and what comes back is somebody else's artwork
|
|
2789
|
+
// at somebody else's weight — commonly an outline, which is what a filled glyph is not. Every
|
|
2790
|
+
// heart codepoint has that problem. `★` is U+2605, `Emoji=No`, so no terminal has any reason to
|
|
2791
|
+
// leave the font it is drawing the rest of the row in.
|
|
2792
|
+
const star = p.favorite ? (opts.ascii ? " *" : " ★") : "";
|
|
2793
|
+
|
|
2794
|
+
// Which other computers have this project. The whole reason a project key is a normalised git
|
|
2795
|
+
// remote rather than a path: on two machines the path is different and this is the same.
|
|
2796
|
+
const elsewhere = opts.hosts.get(p.key) ?? [];
|
|
2797
|
+
|
|
2798
|
+
// What is finished and unseen inside a project you have folded shut. Only when folded, because
|
|
2799
|
+
// folding is the thing that hid it: with the project open the conversation says so on its own
|
|
2800
|
+
// row, and saying it twice on two adjacent lines is how a panel teaches you to stop reading it.
|
|
2801
|
+
//
|
|
2802
|
+
// It sits after the name rather than in the right-hand column: that column is already spoken for
|
|
2803
|
+
// by the elapsed time of whatever is running, and a project can perfectly well have one turn
|
|
2804
|
+
// still going and another that finished an hour ago.
|
|
2805
|
+
// A question in there outranks it, and for the same reason it does on the conversation's own row:
|
|
2806
|
+
// one of them is news that will keep, and the other is a turn that has stopped until you answer.
|
|
2807
|
+
// Only one mark, because there is one column and two would be a puzzle rather than a summary.
|
|
2808
|
+
// A third thing the fold can be hiding, and it sits between the other two: a question stops the
|
|
2809
|
+
// workspace until you answer, a killed turn lost work, and an unread answer is waiting patiently.
|
|
2810
|
+
const cut = folded && !waiting
|
|
2811
|
+
? within.filter((s) => !s.active_turn && s.interrupted).length
|
|
2812
|
+
: 0;
|
|
2813
|
+
const unseen = folded && !waiting && cut === 0
|
|
2814
|
+
? within.filter((s) => !s.active_turn && s.unread).length
|
|
2815
|
+
: 0;
|
|
2816
|
+
// Fourth and last rung: a folded project hiding a conversation that is still running something.
|
|
2817
|
+
// Below all three, because a question stops the workspace, a killed turn lost work, an unread
|
|
2818
|
+
// answer is waiting — and this is none of those. Counted over the whole group, conversations
|
|
2819
|
+
// included, because folded is exactly the state where the rows that know are the ones you
|
|
2820
|
+
// cannot see.
|
|
2821
|
+
const busyBg = folded && !waiting && cut === 0 && unseen === 0
|
|
2822
|
+
? within.filter((s) => !s.active_turn && (s.background?.length ?? 0) > 0).length
|
|
2823
|
+
: 0;
|
|
2824
|
+
// Whichever rung is being reported, drawn the same way and told apart by the glyph and the
|
|
2825
|
+
// colour. One column, one mark — two would be a puzzle rather than a summary.
|
|
2826
|
+
const [count, dot, dotHl] = cut > 0
|
|
2827
|
+
? [cut, opts.ascii ? "x" : "✗", "Diagnostic.Error"]
|
|
2828
|
+
: unseen > 0
|
|
2829
|
+
? [unseen, opts.ascii ? "!" : "●", "Status.Unread"]
|
|
2830
|
+
// Hollow where the other two are solid: the difference between something here for you and
|
|
2831
|
+
// something here still happening.
|
|
2832
|
+
: [busyBg, opts.ascii ? "o" : "○", "Status.Monitoring"];
|
|
2833
|
+
const mark = folded && waiting
|
|
2834
|
+
? " ?"
|
|
2835
|
+
: count === 0
|
|
2836
|
+
? ""
|
|
2837
|
+
: count === 1
|
|
2838
|
+
? ` ${dot}`
|
|
2839
|
+
: ` ${dot}${count}`;
|
|
2840
|
+
const markHl = folded && waiting ? "Status.Pending" : dotHl;
|
|
2841
|
+
|
|
2842
|
+
// The star's two columns come off the name that is about to carry it, so a favourite and the
|
|
2843
|
+
// project under it still end in the same place — clipping the name is what a panel this narrow
|
|
2844
|
+
// does, and letting the mark run two columns past everything else is not.
|
|
2845
|
+
const target: Target = { kind: "project", cwd: p.cwd };
|
|
2846
|
+
const name = clip(
|
|
2847
|
+
p.name,
|
|
2848
|
+
Math.max(
|
|
2849
|
+
6,
|
|
2850
|
+
opts.width - 8 - byteLength(mark) - byteLength(star) - (elsewhere.length ? 8 : 0) -
|
|
2851
|
+
badgeColumns(opts.decorations.get(targetKey(target) ?? "")),
|
|
2852
|
+
),
|
|
2853
|
+
);
|
|
2854
|
+
const spans: Array<{ from: number; to: number; hl: string }> = [];
|
|
2855
|
+
if (star !== "") {
|
|
2856
|
+
const from = byteLength(` ${arrow} ${name}`);
|
|
2857
|
+
spans.push({ from, to: from + byteLength(star), hl: "Sidebar.Favorite" });
|
|
2858
|
+
}
|
|
2859
|
+
if (mark !== "") {
|
|
2860
|
+
const from = byteLength(` ${arrow} ${name}${star}`);
|
|
2861
|
+
spans.push({ from, to: from + byteLength(mark), hl: markHl });
|
|
2862
|
+
}
|
|
2863
|
+
return decorateRow({
|
|
2864
|
+
text: ` ${arrow} ${name}${star}${mark}`,
|
|
2865
|
+
// A project's name is a directory name, and directory names are long. Clipped it is the same
|
|
2866
|
+
// eight characters as the three others you have open beside it, which is the panel failing at
|
|
2867
|
+
// the one thing it is for. The star and the unread dot are left off deliberately: they
|
|
2868
|
+
// survived the clip and are already on the row.
|
|
2869
|
+
full: ` ${arrow} ${p.name}`,
|
|
2870
|
+
indent: 3,
|
|
2871
|
+
// `here` is this panel's opinion; everything else is a decorator's to colour.
|
|
2872
|
+
hl: here ? "Directory" : opts.decorations.get(targetKey(target) ?? "")?.hl ?? "Sidebar.Dim",
|
|
2873
|
+
spans: spans.length > 0 ? spans : undefined,
|
|
2874
|
+
right: elsewhere.length > 0
|
|
2875
|
+
// The machines take the column the count would have used. A project that is in two places is
|
|
2876
|
+
// a more useful thing to know than how many conversations are in it here.
|
|
2877
|
+
? { text: `${clip(elsewhere.join(" "), 14)} `, hl: "Sidebar.Remote" }
|
|
2878
|
+
: right,
|
|
2879
|
+
value: target,
|
|
2880
|
+
}, opts.decorations.get(targetKey(target) ?? ""), Boolean(busy) || elsewhere.length > 0);
|
|
2881
|
+
}
|
|
2882
|
+
|
|
2883
|
+
/**
|
|
2884
|
+
* A worktree, one level inside the repository it is a tree of.
|
|
2885
|
+
*
|
|
2886
|
+
* The same kind of row as a project — same `Target`, so folding, `n`, `f` and `J`/`K` need no
|
|
2887
|
+
* second code path — drawn at the indent of the conversations beside it, because that is the
|
|
2888
|
+
* claim the nesting makes: this belongs to the row above. No star column; pinning is the
|
|
2889
|
+
* repository's, and a second ragged column of stars is what the alignment here pays for.
|
|
2890
|
+
*/
|
|
2891
|
+
function worktreeRow(
|
|
2892
|
+
p: Project,
|
|
2893
|
+
arrangement: Arrangement,
|
|
2894
|
+
opts: DrawOptions,
|
|
2895
|
+
now: number,
|
|
2896
|
+
): ListRow<Target> {
|
|
2897
|
+
const folded = arrangement.isFolded(p.cwd);
|
|
2898
|
+
const arrow = opts.ascii ? (folded ? ">" : "v") : folded ? "▸" : "▾";
|
|
2899
|
+
const busy = p.sessions.find((s) => s.active_turn);
|
|
2900
|
+
const here = p.sessions.some((s) => s.is_active);
|
|
2901
|
+
const right = busy
|
|
2902
|
+
? { text: `${turnFor(busy, now)} `, hl: "Status.Working" }
|
|
2903
|
+
: p.sessions.length > 0
|
|
2904
|
+
? { text: `${p.sessions.length} `, hl: "Sidebar.Dim" }
|
|
2905
|
+
: { text: "" };
|
|
2906
|
+
const cut = folded ? p.sessions.filter((s) => !s.active_turn && s.interrupted).length : 0;
|
|
2907
|
+
const unseen = folded && cut === 0
|
|
2908
|
+
? p.sessions.filter((s) => !s.active_turn && s.unread).length
|
|
2909
|
+
: 0;
|
|
2910
|
+
const mark = cut > 0
|
|
2911
|
+
? cut === 1
|
|
2912
|
+
? opts.ascii ? " x" : " ✗"
|
|
2913
|
+
: opts.ascii ? ` x${cut}` : ` ✗${cut}`
|
|
2914
|
+
: unseen === 0 ? "" : unseen === 1
|
|
2915
|
+
? opts.ascii ? " !" : " ●"
|
|
2916
|
+
: opts.ascii ? ` !${unseen}` : ` ●${unseen}`;
|
|
2917
|
+
// The branch glyph, in the branch colour — what says "this row is a checkout" at a glance, so
|
|
2918
|
+
// the name can be just the branch. No ASCII stand-in earns its column, so ASCII goes without.
|
|
2919
|
+
const glyph = opts.ascii ? "" : "⎇ ";
|
|
2920
|
+
// One step in from its repository's arrow, and the step is two columns — the same one a
|
|
2921
|
+
// conversation takes from the project it is in. Three, when the star was on the left, made the
|
|
2922
|
+
// nesting read as two levels where there is one.
|
|
2923
|
+
const pad = " ";
|
|
2924
|
+
const target: Target = { kind: "project", cwd: p.cwd };
|
|
2925
|
+
const name = clip(
|
|
2926
|
+
p.name,
|
|
2927
|
+
Math.max(6, opts.width - 8 - byteLength(glyph) - byteLength(mark) - badgeColumns(opts.decorations.get(targetKey(target) ?? ""))),
|
|
2928
|
+
);
|
|
2929
|
+
const spans: Array<{ from: number; to: number; hl: string }> = [];
|
|
2930
|
+
if (glyph !== "") {
|
|
2931
|
+
const at = byteLength(`${pad}${arrow} `);
|
|
2932
|
+
spans.push({ from: at, to: at + byteLength(glyph), hl: "Git.Branch" });
|
|
2933
|
+
}
|
|
2934
|
+
if (mark !== "") {
|
|
2935
|
+
const at = byteLength(`${pad}${arrow} ${glyph}${name}`);
|
|
2936
|
+
spans.push({
|
|
2937
|
+
from: at,
|
|
2938
|
+
to: at + byteLength(mark),
|
|
2939
|
+
hl: cut > 0 ? "Diagnostic.Error" : "Status.Unread",
|
|
2940
|
+
});
|
|
2941
|
+
}
|
|
2942
|
+
return decorateRow({
|
|
2943
|
+
text: `${pad}${arrow} ${glyph}${name}${mark}`,
|
|
2944
|
+
// A branch name is as long as somebody made it, and this row is two columns narrower than a
|
|
2945
|
+
// project's. The unread mark is left off the unfolded form: it survived the clip and is
|
|
2946
|
+
// already on the row.
|
|
2947
|
+
full: `${pad}${arrow} ${glyph}${p.name}`,
|
|
2948
|
+
indent: byteLength(`${pad}${arrow} `),
|
|
2949
|
+
hl: here ? "Directory" : opts.decorations.get(targetKey(target) ?? "")?.hl ?? "Sidebar.Dim",
|
|
2950
|
+
spans: spans.length > 0 ? spans : undefined,
|
|
2951
|
+
right,
|
|
2952
|
+
value: target,
|
|
2953
|
+
}, opts.decorations.get(targetKey(target) ?? ""), Boolean(busy));
|
|
2954
|
+
}
|
|
2955
|
+
|
|
2956
|
+
/**
|
|
2957
|
+
* A conversation on another computer.
|
|
2958
|
+
*
|
|
2959
|
+
* Indented with its project's own, because that is the claim: it is the same project, being worked
|
|
2960
|
+
* on somewhere else. The host name is what makes it honest — it reads as one list and says, per
|
|
2961
|
+
* row, which machine the work is actually happening on.
|
|
2962
|
+
*/
|
|
2963
|
+
function remoteRow(r: SwarmAgent, opts: DrawOptions, now: number): ListRow<Target> {
|
|
2964
|
+
const working = r.agent.state === "running";
|
|
2965
|
+
const glyph = working ? (opts.ascii ? "*" : "◍") : opts.ascii ? "." : "·";
|
|
2966
|
+
const host = clip(r.node.name, 12);
|
|
2967
|
+
const width = Math.max(8, opts.width - 8 - host.length);
|
|
2968
|
+
return {
|
|
2969
|
+
text: ` ${glyph} ${clip(r.agent.label, width)}`,
|
|
2970
|
+
full: ` ${glyph} ${r.agent.label}`,
|
|
2971
|
+
indent: 5,
|
|
2972
|
+
hl: working ? "Status.Monitoring" : "Sidebar.Remote",
|
|
2973
|
+
right: { text: `${host} `, hl: "Sidebar.Remote" },
|
|
2974
|
+
value: {
|
|
2975
|
+
kind: "remote",
|
|
2976
|
+
node: r.node.id,
|
|
2977
|
+
session: r.agent.session,
|
|
2978
|
+
cwd: r.agent.cwd,
|
|
2979
|
+
host: r.node.name,
|
|
2980
|
+
},
|
|
2981
|
+
};
|
|
2982
|
+
}
|
|
2983
|
+
|
|
2984
|
+
function sessionRow(
|
|
2985
|
+
s: SessionInfo,
|
|
2986
|
+
now: number,
|
|
2987
|
+
opts: DrawOptions,
|
|
2988
|
+
depth = 0,
|
|
2989
|
+
): ListRow<Target> {
|
|
2990
|
+
// The agent has stopped and is waiting on you. First, because it is the only state here that a
|
|
2991
|
+
// turn being in flight does not already describe: the turn *is* in flight — blocked on the
|
|
2992
|
+
// question — so without this the row is a spinner, indistinguishable from one that is thinking,
|
|
2993
|
+
// and the way you find it is by opening conversations until one of them asks you something.
|
|
2994
|
+
const asking = opts.asking.has(s.id);
|
|
2995
|
+
// Working is the only state that moves, and only where the work is. An idle row's status is
|
|
2996
|
+
// deliberately static: twenty animating rows carry no more information than one and cost twenty
|
|
2997
|
+
// times the attention.
|
|
2998
|
+
const working = !asking && Boolean(s.active_turn);
|
|
2999
|
+
// Finished while you were somewhere else. The one row in this column that is asking for
|
|
3000
|
+
// something, so it is the one row that gets the attention colour — and it does not move, because
|
|
3001
|
+
// it is not going to stop being true on its own and a mark that pulses forever is a mark you
|
|
3002
|
+
// learn to look past. It goes away by being opened.
|
|
3003
|
+
const unread = !working && !asking && !s.interrupted && s.unread;
|
|
3004
|
+
// The turn that was running here never ended, because the workspace it was running in stopped:
|
|
3005
|
+
// the machine was shut down, the process was killed. It outranks `unread` — a turn that finished
|
|
3006
|
+
// while you were away and a turn that was killed are both news, and only one of them lost work —
|
|
3007
|
+
// and it does not move, because nothing is happening here: it already happened.
|
|
3008
|
+
const interrupted = !working && !asking && s.interrupted;
|
|
3009
|
+
// Something the agent started and stopped waiting for — a shell it put in the background, a
|
|
3010
|
+
// sub-agent it let go of. Last in the order on purpose: every state above it is a block or news
|
|
3011
|
+
// — and a killed turn is both — while this is neither. It is the answer to "it said it was done,
|
|
3012
|
+
// is it?", which is only a question once nothing louder is true.
|
|
3013
|
+
const running = !working && !asking && !interrupted && !unread &&
|
|
3014
|
+
(s.background?.length ?? 0) > 0;
|
|
3015
|
+
const glyph = asking
|
|
3016
|
+
// A question mark, in both alphabets. Every other glyph here has an ASCII understudy because
|
|
3017
|
+
// the Unicode one is prettier; this one is already the character that means what it means, and
|
|
3018
|
+
// a shape people have to learn would be worse in either.
|
|
3019
|
+
? "?"
|
|
3020
|
+
: working
|
|
3021
|
+
? s.is_active
|
|
3022
|
+
? spinnerFrame()
|
|
3023
|
+
: opts.ascii ? "*" : "◍"
|
|
3024
|
+
// The same mark a tool call that failed wears, and for the same reason: this did not finish.
|
|
3025
|
+
// A conversation is the largest thing in the workspace that can fail to finish.
|
|
3026
|
+
: interrupted
|
|
3027
|
+
? opts.ascii ? "x" : "✗"
|
|
3028
|
+
: unread
|
|
3029
|
+
? opts.ascii ? "!" : "●"
|
|
3030
|
+
: running
|
|
3031
|
+
// Hollow where unread is solid: the same size of mark, and the difference between them
|
|
3032
|
+
// is the difference between "there is something here for you" and "there is something
|
|
3033
|
+
// here still happening".
|
|
3034
|
+
? opts.ascii ? "o" : "○"
|
|
3035
|
+
: s.is_active
|
|
3036
|
+
? opts.ascii ? ">" : "▸"
|
|
3037
|
+
: " ";
|
|
3038
|
+
// The clock, and it keeps running while the question sits there: one asked four minutes ago and
|
|
3039
|
+
// one asked while you were reading this row are not the same news. Off the turn rather than off
|
|
3040
|
+
// `working`, because a question can be raised by a plugin with no turn behind it at all — and
|
|
3041
|
+
// then the honest number is when the conversation last moved, not a turn that never started.
|
|
3042
|
+
const right = s.active_turn
|
|
3043
|
+
? turnFor(s, now)
|
|
3044
|
+
: s.updated_at > 0 ? ago(now / 1000 - s.updated_at) : "";
|
|
3045
|
+
// Two more columns per level: a conversation inside a worktree sits inside the worktree's row
|
|
3046
|
+
// the way the worktree sits inside its repository's.
|
|
3047
|
+
const pad = " " + " ".repeat(depth);
|
|
3048
|
+
const target: Target = { kind: "session", id: s.id, cwd: s.cwd };
|
|
3049
|
+
// What is left for the title, counted rather than guessed at. The indent, the glyph and the
|
|
3050
|
+
// space after it come off the front; the age column and the space keeping it off the panel edge
|
|
3051
|
+
// come off the end, plus one column of air so a title cannot run into a timestamp. The old
|
|
3052
|
+
// number was a constant two columns larger than the prefix it stood for, which is two characters
|
|
3053
|
+
// of every title in the panel spent on nothing.
|
|
3054
|
+
const room = Math.max(
|
|
3055
|
+
8,
|
|
3056
|
+
opts.width - pad.length - 2 - (right === "" ? 0 : right.length + 2) -
|
|
3057
|
+
badgeColumns(opts.decorations.get(targetKey(target) ?? "")),
|
|
3058
|
+
);
|
|
3059
|
+
return decorateRow({
|
|
3060
|
+
text: `${pad}${glyph} ${clip(s.label, room)}`,
|
|
3061
|
+
// The title is the only thing telling two conversations in one project apart, and a generated
|
|
3062
|
+
// title is a sentence rather than a word — so the column cuts it at about the point where it
|
|
3063
|
+
// was going to say which of them this is.
|
|
3064
|
+
full: `${pad}${glyph} ${s.label}`,
|
|
3065
|
+
// The conversation you are in is not a row you are considering, it is where you are — so it
|
|
3066
|
+
// says its whole name whether or not the cursor is on it. Everything else in this column is
|
|
3067
|
+
// scannable because it is one line each; the current row is the one place that trade is wrong,
|
|
3068
|
+
// and there is only ever one of it.
|
|
3069
|
+
expand: s.is_active,
|
|
3070
|
+
indent: pad.length + 2,
|
|
3071
|
+
hl: asking
|
|
3072
|
+
// The palette's group for waiting on something outside the program — which here is a person,
|
|
3073
|
+
// and the reason it is the one row in this column that moves. `Status.Unread` deliberately
|
|
3074
|
+
// does not: that is news, and news does not stop being true if you ignore it. This is a
|
|
3075
|
+
// *block*, it ends the moment you answer, and until then nothing in the workspace goes on.
|
|
3076
|
+
? "Status.Pending"
|
|
3077
|
+
: working
|
|
3078
|
+
? s.is_active && pulseBright() ? "Status.Working" : "Status.Monitoring"
|
|
3079
|
+
// Red, and the palette's red for something that went wrong rather than a status colour of
|
|
3080
|
+
// its own: work was lost here. The whole row again, for the reason the unread mark takes
|
|
3081
|
+
// the whole row.
|
|
3082
|
+
: interrupted
|
|
3083
|
+
? "Diagnostic.Error"
|
|
3084
|
+
: unread
|
|
3085
|
+
// The whole row, not only the dot: a single coloured character five columns in is findable
|
|
3086
|
+
// if you already know it is there, which is the one thing you cannot assume about the
|
|
3087
|
+
// conversation you have forgotten you started.
|
|
3088
|
+
? "Status.Unread"
|
|
3089
|
+
: running
|
|
3090
|
+
// The colour the panel already uses for work happening somewhere you are not, dimmed
|
|
3091
|
+
// and — the whole point — still. Motion here would be a promise that you have to do
|
|
3092
|
+
// something, and you do not: it finishes whether or not you look.
|
|
3093
|
+
? "Status.Monitoring"
|
|
3094
|
+
: s.is_active
|
|
3095
|
+
? "Accent"
|
|
3096
|
+
: undefined,
|
|
3097
|
+
// A trailing space so the age does not sit flush against the panel's edge rule.
|
|
3098
|
+
right: {
|
|
3099
|
+
text: right === "" ? "" : `${right} `,
|
|
3100
|
+
hl: asking
|
|
3101
|
+
? "Status.Pending"
|
|
3102
|
+
: working
|
|
3103
|
+
? "Status.Working"
|
|
3104
|
+
: interrupted
|
|
3105
|
+
? "Diagnostic.Error"
|
|
3106
|
+
: unread
|
|
3107
|
+
? "Status.Unread"
|
|
3108
|
+
: running ? "Status.Monitoring" : "Sidebar.Dim",
|
|
3109
|
+
},
|
|
3110
|
+
value: target,
|
|
3111
|
+
}, opts.decorations.get(targetKey(target) ?? ""), Boolean(s.active_turn));
|
|
3112
|
+
}
|
|
3113
|
+
|
|
3114
|
+
/**
|
|
3115
|
+
* How long the running turn has been running.
|
|
3116
|
+
*
|
|
3117
|
+
* A conversation that says it is working without saying for how long is indistinguishable from one
|
|
3118
|
+
* that is wedged, which is the moment you reach for `^C` and lose the answer. The host stamps the
|
|
3119
|
+
* start, so this survives the panel being closed and reopened mid-turn — a timer counted in here
|
|
3120
|
+
* would restart at zero and quietly lie.
|
|
3121
|
+
*/
|
|
3122
|
+
function turnFor(s: SessionInfo, now: number): string {
|
|
3123
|
+
const started = s.turn_started_at;
|
|
3124
|
+
if (!started || started <= 0) return "…";
|
|
3125
|
+
return elapsed(Math.max(0, now - started * 1000));
|
|
3126
|
+
}
|
|
3127
|
+
|
|
3128
|
+
/**
|
|
3129
|
+
* The keys for whatever the cursor is on.
|
|
3130
|
+
*
|
|
3131
|
+
* Contextual rather than a fixed cheat sheet: `x` closes a conversation and means nothing on a
|
|
3132
|
+
* project, and a list of verbs that do not all apply is a list you learn to distrust. Everything
|
|
3133
|
+
* here is also reachable from `?`, which is the escape hatch when the panel is too narrow.
|
|
3134
|
+
*/
|
|
3135
|
+
function hints(opts: DrawOptions): ListRow<Target>[] {
|
|
3136
|
+
const kind = opts.selected?.kind;
|
|
3137
|
+
const lines = !opts.focused
|
|
3138
|
+
// `^O` is not here and `+ Add project` is a row you can see: a key strip has two lines, and the
|
|
3139
|
+
// verb with a row of its own is the one that can afford to give up its place on them.
|
|
3140
|
+
? ["^T projects ^N new ^F archive", "^K palette ^B hide ^Z keys"]
|
|
3141
|
+
: kind === "custom"
|
|
3142
|
+
? ["↵ open it <> width", "esc back ? keys"]
|
|
3143
|
+
: kind === "project"
|
|
3144
|
+
// `X` is on the strip because a list you cannot shorten is a list that grows forever, and a
|
|
3145
|
+
// project that outlives its conversations — which is the point of it — has to have a way off.
|
|
3146
|
+
? ["↵ fold f ★ JK move", "n new y path X remove ? keys"]
|
|
3147
|
+
: kind === "session"
|
|
3148
|
+
? ["↵ open r rename x archive", "X delete y path ? keys"]
|
|
3149
|
+
: ["↵ add project", "esc back ? keys"];
|
|
3150
|
+
|
|
3151
|
+
// Contributed verbs get their own line rather than being squeezed onto ours, because ours are
|
|
3152
|
+
// laid out in columns that a third party's label of unknown length would break — and because a
|
|
3153
|
+
// key nobody can see is a key nobody presses, which is the whole argument for this strip.
|
|
3154
|
+
const mine = opts.focused ? contributedHint(opts) : "";
|
|
3155
|
+
|
|
3156
|
+
return [
|
|
3157
|
+
blank(),
|
|
3158
|
+
{ text: "─".repeat(Math.max(1, opts.width)), hl: "Separator", inert: true },
|
|
3159
|
+
// Half a motion, at the foot, where Vim puts it: without it a count is a keypress with no
|
|
3160
|
+
// effect until the next one, which is indistinguishable from a key that does nothing. On the
|
|
3161
|
+
// first key line rather than on the rule above it — the rule is a row of full width, and a
|
|
3162
|
+
// flush-right virtual text has nowhere to sit on a row that is already full.
|
|
3163
|
+
...lines.map((text, i) => ({
|
|
3164
|
+
text: ` ${text}`,
|
|
3165
|
+
hl: "Sidebar.Dim",
|
|
3166
|
+
right: i === 0 && opts.count !== "" ? { text: `${opts.count} `, hl: "Accent" } : undefined,
|
|
3167
|
+
inert: true,
|
|
3168
|
+
})),
|
|
3169
|
+
...(mine === "" ? [] : [{ text: ` ${mine}`, hl: "Sidebar.Dim", inert: true }]),
|
|
3170
|
+
];
|
|
3171
|
+
}
|
|
3172
|
+
|
|
3173
|
+
/** The contributed verbs that apply to the row under the cursor, clipped to the column. */
|
|
3174
|
+
function contributedHint(opts: DrawOptions): string {
|
|
3175
|
+
const applicable = opts.actions.filter((a) => applies(a.on ?? "any", opts.selected));
|
|
3176
|
+
if (applicable.length === 0) return "";
|
|
3177
|
+
// The verbs about *this row* first, then the ones about any of them.
|
|
3178
|
+
//
|
|
3179
|
+
// One line, in a column that is 34 wide by default, and a third plugin's verb is what takes it
|
|
3180
|
+
// past the edge. Which one gets clipped is therefore a decision this makes rather than one the
|
|
3181
|
+
// order plugins happened to load in makes for it — and a key that only applies to the row you are
|
|
3182
|
+
// standing on is the one that has to survive: a verb about every row is a verb you will see again
|
|
3183
|
+
// the moment you move.
|
|
3184
|
+
const ranked = [
|
|
3185
|
+
...applicable.filter((a) => (a.on ?? "any") !== "any"),
|
|
3186
|
+
...applicable.filter((a) => (a.on ?? "any") === "any"),
|
|
3187
|
+
];
|
|
3188
|
+
return clip(
|
|
3189
|
+
ranked.map((a) => `${a.key} ${a.label}`).join(" "),
|
|
3190
|
+
Math.max(4, opts.width - 2),
|
|
3191
|
+
);
|
|
3192
|
+
}
|
|
3193
|
+
|
|
3194
|
+
/**
|
|
3195
|
+
* The always-visible numbers: how long the current turn has run, how full the window is, and what
|
|
3196
|
+
* it has cost.
|
|
3197
|
+
*
|
|
3198
|
+
* These live in the footer rather than the panel because the panel can be hidden and these are the
|
|
3199
|
+
* things you want while you are typing. They are also the reason the sidebar no longer carries a
|
|
3200
|
+
* usage block — one home per number.
|
|
3201
|
+
*/
|
|
3202
|
+
async function installFooter(neosh: Neosh, subscriptions: PluginContext["subscriptions"]) {
|
|
3203
|
+
const refresh = async () => {
|
|
3204
|
+
const [current, selection] = await Promise.all([
|
|
3205
|
+
neosh.session.current().catch(() => null),
|
|
3206
|
+
neosh.agent.selection().catch(() => null),
|
|
3207
|
+
]);
|
|
3208
|
+
if (!current) return;
|
|
3209
|
+
|
|
3210
|
+
if (current.active_turn) {
|
|
3211
|
+
await neosh.status.set("turn", {
|
|
3212
|
+
text: `${spinnerFrame()} ${turnFor(current, Date.now())}`,
|
|
3213
|
+
// The group that sweeps. Nothing here drives the animation — the frontend does, at its own
|
|
3214
|
+
// rate, which is why this can be set once per second and still look continuous.
|
|
3215
|
+
hl: "Status.Streaming",
|
|
3216
|
+
align: "right",
|
|
3217
|
+
priority: 5,
|
|
3218
|
+
});
|
|
3219
|
+
} else {
|
|
3220
|
+
await neosh.status.clear("turn");
|
|
3221
|
+
}
|
|
3222
|
+
|
|
3223
|
+
const model = selection
|
|
3224
|
+
? (await neosh.agent.listModels(selection.instance).catch(() => []))
|
|
3225
|
+
.find((e) => e.model.id === selection.model)?.model
|
|
3226
|
+
: undefined;
|
|
3227
|
+
const u = current.usage;
|
|
3228
|
+
// The context meter is *not* here. It was, and so was one in the usage plugin, and for as long
|
|
3229
|
+
// as no model in the catalogue reported a window only one of them ever drew — so two gauges of
|
|
3230
|
+
// the same thing, disagreeing about what "used" means, sat in the same footer the moment one
|
|
3231
|
+
// did. The usage plugin owns it: it is named for the job and it reads the context the last
|
|
3232
|
+
// request actually carried, rather than adding up totals that count a cached prompt twice.
|
|
3233
|
+
if (u.input_tokens + u.output_tokens === 0) {
|
|
3234
|
+
await neosh.status.clear("cost");
|
|
3235
|
+
return;
|
|
3236
|
+
}
|
|
3237
|
+
|
|
3238
|
+
const p = model?.pricing;
|
|
3239
|
+
if (!p) return;
|
|
3240
|
+
// Cache reads are billed at their own rate, which is the whole reason to count them separately:
|
|
3241
|
+
// a session that looks expensive by token count is often cheap because most of it was a hit.
|
|
3242
|
+
const cost =
|
|
3243
|
+
(u.input_tokens * p.input_per_mtok +
|
|
3244
|
+
u.output_tokens * p.output_per_mtok +
|
|
3245
|
+
u.cache_read_tokens * p.cache_read_per_mtok +
|
|
3246
|
+
u.cache_write_tokens * p.cache_write_per_mtok) /
|
|
3247
|
+
1_000_000;
|
|
3248
|
+
await neosh.status.set("cost", {
|
|
3249
|
+
text: money(cost),
|
|
3250
|
+
hl: "Comment",
|
|
3251
|
+
align: "right",
|
|
3252
|
+
priority: 12,
|
|
3253
|
+
});
|
|
3254
|
+
};
|
|
3255
|
+
|
|
3256
|
+
await refresh();
|
|
3257
|
+
subscriptions.push(neosh.agent.onTurnStart(() => void refresh()));
|
|
3258
|
+
subscriptions.push(neosh.agent.onTurnEnd(() => void refresh()));
|
|
3259
|
+
subscriptions.push(neosh.session.onChange(() => void refresh()));
|
|
3260
|
+
// The cost depends on the model's prices, so a switch changes it without a turn happening.
|
|
3261
|
+
subscriptions.push(neosh.agent.onSelectionChange(() => void refresh()));
|
|
3262
|
+
// The clock only ticks while something is running, so this costs nothing at rest.
|
|
3263
|
+
subscriptions.push(onTick(() => void refresh()));
|
|
3264
|
+
}
|
|
3265
|
+
|
|
3266
|
+
function basename(cwd: string): string {
|
|
3267
|
+
return cwd.split("/").filter(Boolean).pop() ?? cwd;
|
|
3268
|
+
}
|
|
3269
|
+
|
|
3270
|
+
function short(cwd: string): string {
|
|
3271
|
+
return basename(cwd);
|
|
3272
|
+
}
|
|
3273
|
+
|
|
3274
|
+
function clip(s: string, n: number): string {
|
|
3275
|
+
const chars = Array.from(s);
|
|
3276
|
+
return chars.length <= n ? s : `${chars.slice(0, Math.max(1, n - 1)).join("")}…`;
|
|
3277
|
+
}
|
|
3278
|
+
|
|
3279
|
+
function ago(seconds: number): string {
|
|
3280
|
+
if (!Number.isFinite(seconds) || seconds < 0) return "";
|
|
3281
|
+
if (seconds < 60) return "now";
|
|
3282
|
+
return elapsed(seconds * 1000).replace(/ \d+s$/, "").replace(/ \d+m$/, "");
|
|
3283
|
+
}
|