@neosh/archive 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.
Files changed (3) hide show
  1. package/main.ts +1340 -0
  2. package/package.json +21 -0
  3. package/plugin.toml +4 -0
package/main.ts ADDED
@@ -0,0 +1,1340 @@
1
+ /**
2
+ * The archive: what you have finished with, and how you finally get rid of it.
3
+ *
4
+ * The workspace has two verbs — `x` puts a conversation away, `X` destroys it — and the archive
5
+ * was taken out of the sidebar, because a list of things you are deliberately done with
6
+ * is the one part of that column that is never the answer. Both decisions then said the same thing
7
+ * about the other end: nothing sweeps it, an archive is a place things accumulate, and deleting
8
+ * somebody's history on a timer is a move this workspace has never been willing to make.
9
+ *
10
+ * That is still true and this plugin does not change it. What it changes is the half those
11
+ * decisions left as a note: **emptying it by hand was one conversation at a time**. A picker with `^X` on a
12
+ * row is a fine way to throw away the thing you just regretted and a terrible way to deal with two
13
+ * hundred of them, which is what an archive of a workspace you have used for a year actually is.
14
+ *
15
+ * So the archive is a panel rather than a picker, and everything a panel is owed it has:
16
+ *
17
+ * - **Every key is an ordinary binding** on the buffer kind `neosh.archive`, pointed at a command
18
+ * with a name. `^Z` lists them, `^K` runs them, one line in `init.ts` moves any of them. There is
19
+ * no private switch on `KeyContext` here — the picker's `ownKeys`/`onKey` pair, which is what
20
+ * this replaced, is exactly the shape the surface rule exists to stop.
21
+ * - **`archive.action` is a contribution point.** Say a key, a label and a command, and it is a
22
+ * verb on these rows — invoked with the marked conversations, or with the row under the cursor
23
+ * when nothing is marked, so a plugin never has to track the cursor itself.
24
+ * - **The rows are a list you move in**: `j`/`k` take a count, `^D`/`^U` are half of
25
+ * the panel's real height, `12G` is a row, and a half-typed count is on screen.
26
+ *
27
+ * And it can be marked. `<Space>` ticks a row, `a` ticks everything showing, and every verb here —
28
+ * put back, delete, copy — acts on the ticked set or, when nothing is ticked, on the row under the
29
+ * cursor. `^X` empties the archive outright: everything *currently listed*, which is the filter's
30
+ * whole point, so narrowing to one project and pressing it deletes that project's finished
31
+ * conversations and nothing else. It asks, it says how many and how much is in them, and the
32
+ * question is charged for the one reason that matters — it cannot be undone.
33
+ *
34
+ * Two things here are about conversations you have never seen. `RESTORE_LIMIT` means a workspace
35
+ * loads the most recent few hundred conversations and leaves the rest on disk, in no list at all —
36
+ * so an archive built on `session.list` would have reported a number that was not the number and
37
+ * emptied itself down to a directory that was still full. It reads `session.stored` as well, which
38
+ * is the directory, and the host brings one in from disk the moment any verb names it.
39
+ *
40
+ * Nothing here deletes on a timer. `archive.auto_days` will *archive* an idle conversation, because
41
+ * archiving is reversible and free and that is the test that matters; `archive.retention_days` only
42
+ * ever says how much is old, and `archive.sweep` is the key you press when you agree. A reminder is
43
+ * not a policy.
44
+ *
45
+ * It is a plugin. `plugins.disabled = ["archive"]` turns it off, and an archive of your own that
46
+ * loads afterwards and binds `^F` wins.
47
+ */
48
+
49
+ import { byteLength } from "@neosh/api";
50
+ import type {
51
+ BufferId,
52
+ Disposable,
53
+ Message,
54
+ Neosh,
55
+ PluginContext,
56
+ SessionInfo,
57
+ WindowId,
58
+ } from "@neosh/api";
59
+ import { confirmDestructive, CursoredList, type ListRow, prompt } from "@neosh/api/ui";
60
+
61
+ const NS = "neosh.archive";
62
+ /** What this panel's buffer says it is. Everything a third party binds or finds hangs off this. */
63
+ const KIND = "neosh.archive";
64
+
65
+ /**
66
+ * A verb on these rows, contributed by somebody else.
67
+ *
68
+ * The same shape `sidebar.action` takes, and for the same reason: a key press carries no arguments
69
+ * of its own, so the command is invoked with the conversations it is about — every marked one, or
70
+ * the row under the cursor when nothing is marked. A plugin that had to track the cursor separately
71
+ * would be one race away from deleting the wrong thing.
72
+ */
73
+ const POINT_ACTION = "archive.action";
74
+
75
+ interface ActionItem {
76
+ /** Key notation, as `keymap.set` takes it: `d`, `<C-y>`, `gd`. */
77
+ key: string;
78
+ /** What it does, for the hint strip and for `^Z`. */
79
+ label: string;
80
+ command: string;
81
+ }
82
+
83
+ /** What a row points at, so the cursor survives the list being rebuilt underneath it. */
84
+ type Target = { kind: "session"; id: string };
85
+
86
+ /** How the list is ordered. The default is when you put it away, which is how you remember it. */
87
+ type Sort = "archived" | "used" | "name" | "project" | "size";
88
+ /** What the headings are, if there are any. */
89
+ type Group = "none" | "project" | "age";
90
+
91
+ const SORTS: Sort[] = ["archived", "used", "name", "project", "size"];
92
+ const GROUPS: Group[] = ["project", "age", "none"];
93
+
94
+ const SORT_LABEL: Record<Sort, string> = {
95
+ archived: "put away",
96
+ used: "last used",
97
+ name: "name",
98
+ project: "project",
99
+ size: "size",
100
+ };
101
+ const GROUP_LABEL: Record<Group, string> = {
102
+ none: "flat",
103
+ project: "by project",
104
+ age: "by age",
105
+ };
106
+
107
+ export async function activate({ neosh, subscriptions }: PluginContext) {
108
+ await declareOptions(neosh);
109
+
110
+ const panel = new Panel(neosh);
111
+ subscriptions.push({ dispose: () => void panel.close() });
112
+
113
+ await registerCommands(neosh, subscriptions, panel);
114
+ await bindKeys(neosh, subscriptions, panel);
115
+ await contributeToSidebar(neosh, subscriptions);
116
+
117
+ // Housekeeping, once the workspace has settled rather than in the middle of starting it: the
118
+ // first thing a new terminal does is draw a transcript, and a notice that lands before there is
119
+ // anywhere to put it is a notice nobody sees. Then daily, because both of the questions it asks
120
+ // are about days.
121
+ //
122
+ // Not held in `subscriptions` for its own sake — the runtime cancels a plugin's timers when it
123
+ // unloads — but the count row it refreshes is, and that is what the sidebar reads.
124
+ neosh.timer.after(4000, () => void housekeeping(neosh));
125
+ subscriptions.push(neosh.timer.every(6 * 60 * 60 * 1000, () => void housekeeping(neosh)));
126
+
127
+ // The count in the sidebar follows the conversations rather than whoever happened to change one.
128
+ //
129
+ // Both halves are needed. `session.onChange` is *switching*, and archiving something you are not
130
+ // looking at does not switch anything — so the door would appear a redraw late, or not at all.
131
+ // The poll is the same period the panel beside it already redraws on, and it costs one `list`
132
+ // call: the expensive half of the count, what is on disk and not loaded, cannot change without
133
+ // this process being told. Nothing is contributed unless the number moved, because a contribution
134
+ // is a broadcast and re-announcing an unchanged row would redraw every panel reading this point.
135
+ const period = (await neosh.opt.get<number>("sidebar.refresh_ms").catch(() => 4000)) ?? 4000;
136
+ subscriptions.push(neosh.session.onChange(() => void refreshSidebarRow(neosh)));
137
+ subscriptions.push(neosh.timer.every(period, () => void refreshSidebarRow(neosh)));
138
+ subscriptions.push(neosh.opt.onChange((e) => {
139
+ if (e.name === "archive.sidebar") void refreshSidebarRow(neosh);
140
+ }));
141
+ await refreshSidebarRow(neosh);
142
+ }
143
+
144
+ async function declareOptions(neosh: Neosh): Promise<void> {
145
+ await neosh.opt.declare({
146
+ name: "archive.sort",
147
+ type: { type: "enum", values: SORTS },
148
+ default: "archived",
149
+ description:
150
+ "How the archive is ordered: when you put it away, when you last used it, its name, its project, or how much is in it. `s` in the panel cycles it.",
151
+ });
152
+ await neosh.opt.declare({
153
+ name: "archive.group",
154
+ type: { type: "enum", values: GROUPS },
155
+ default: "project",
156
+ description:
157
+ "What the headings in the archive are: the project each conversation came from, how long ago it was put away, or nothing at all. `S` in the panel cycles it.",
158
+ });
159
+ await neosh.opt.declare({
160
+ name: "archive.width",
161
+ type: { type: "int", min: 40, max: 200 },
162
+ default: 92,
163
+ description: "How wide the archive panel is, at most. It is clamped to the terminal.",
164
+ });
165
+ await neosh.opt.declare({
166
+ name: "archive.height",
167
+ type: { type: "int", min: 8, max: 60 },
168
+ default: 20,
169
+ description: "How many rows of archive to show at once, at most.",
170
+ });
171
+ await neosh.opt.declare({
172
+ name: "archive.sidebar",
173
+ type: { type: "bool" },
174
+ default: false,
175
+ description:
176
+ "Put a row in the project panel saying how many conversations are archived. Off: the archive is a popup you open with `^F`, and a permanent line about what you have finished with is a line that column cannot spare.",
177
+ });
178
+ await neosh.opt.declare({
179
+ name: "archive.auto_days",
180
+ type: { type: "int", min: 0, max: 3650 },
181
+ default: 0,
182
+ description:
183
+ "Archive a conversation nothing has happened in for this many days. `0` is off. Archiving keeps every message and `^F` puts one back, which is why this is allowed to happen on its own — deleting on a timer is not.",
184
+ });
185
+ await neosh.opt.declare({
186
+ name: "archive.retention_days",
187
+ type: { type: "int", min: 0, max: 3650 },
188
+ default: 0,
189
+ description:
190
+ "How old an archived conversation has to be before neosh will mention it. `0` is off. Nothing is ever deleted by this — it is what `archive.sweep` and the reminder count.",
191
+ });
192
+ await neosh.opt.declare({
193
+ name: "archive.remind",
194
+ type: { type: "bool" },
195
+ default: true,
196
+ description:
197
+ "Say so, once a day, when there are archived conversations older than `archive.retention_days`. Off leaves the number to the panel.",
198
+ });
199
+ }
200
+
201
+ // ---------------------------------------------------------------------------
202
+ // What is in the archive
203
+ // ---------------------------------------------------------------------------
204
+
205
+ /** An archived conversation, and whether this workspace is actually holding it. */
206
+ interface Entry {
207
+ info: SessionInfo;
208
+ /**
209
+ * In the store, rather than only on disk.
210
+ *
211
+ * Drawn nowhere and used for nothing but the count in the header. Every verb works either way —
212
+ * the host brings a conversation in from its file the moment one names it — and a row that said
213
+ * which side of a cap it happened to fall on would be an implementation detail with a glyph.
214
+ */
215
+ loaded: boolean;
216
+ }
217
+
218
+ /**
219
+ * Archived conversations this workspace has never loaded, as of the last full scan.
220
+ *
221
+ * The count in the sidebar follows every conversation change, and reading the *directory* on every
222
+ * one of them would be a parse of every file you own each time a turn ends. So the expensive half
223
+ * of the answer is remembered and the cheap half is asked again: the store is authoritative about
224
+ * everything it holds, and what it does not hold cannot change without this process being told.
225
+ */
226
+ let cold = 0;
227
+
228
+ /**
229
+ * Everything archived, from both places it can be.
230
+ *
231
+ * The store is a window onto the sessions directory and not the whole of it: past `RESTORE_LIMIT`
232
+ * a conversation is a file this workspace has never read. `session.stored` is that directory. Where
233
+ * the two disagree the store wins, because it is the one with the unsaved half of today in it.
234
+ */
235
+ async function collect(neosh: Neosh): Promise<Entry[]> {
236
+ const [live, disk] = await Promise.all([
237
+ neosh.session.list({ includeArchived: true }).catch(() => [] as SessionInfo[]),
238
+ neosh.session.stored().catch(() => [] as SessionInfo[]),
239
+ ]);
240
+ const loaded = new Set(live.map((s) => s.id));
241
+ const byId = new Map<string, SessionInfo>();
242
+ for (const s of disk) byId.set(s.id, s);
243
+ for (const s of live) byId.set(s.id, s);
244
+ const all = [...byId.values()]
245
+ .filter((s) => s.archived)
246
+ .map((info) => ({ info, loaded: loaded.has(info.id) }));
247
+ cold = all.filter((e) => !e.loaded).length;
248
+ return all;
249
+ }
250
+
251
+ /** How many are archived, without reading the disk. See {@link cold}. */
252
+ async function archivedCount(neosh: Neosh): Promise<number> {
253
+ const live = await neosh.session
254
+ .list({ includeArchived: true })
255
+ .catch(() => [] as SessionInfo[]);
256
+ return live.filter((s) => s.archived).length + cold;
257
+ }
258
+
259
+ /** Seconds since the epoch, the unit every timestamp on the wire is in. */
260
+ function now(): number {
261
+ return Math.floor(Date.now() / 1000);
262
+ }
263
+
264
+ /** When a conversation was put away, falling back to when it was last touched. */
265
+ function putAway(s: SessionInfo): number {
266
+ return s.archived_at ?? s.updated_at;
267
+ }
268
+
269
+ function matches(e: Entry, filter: string): boolean {
270
+ if (filter === "") return true;
271
+ const hay = `${e.info.label} ${e.info.project} ${e.info.cwd}`.toLowerCase();
272
+ // Every word, in any order: `neosh login` finds the login conversation in the neosh checkout
273
+ // without caring which of the two you remembered first.
274
+ return filter.toLowerCase().split(/\s+/).filter((w) => w !== "").every((w) => hay.includes(w));
275
+ }
276
+
277
+ function sorted(entries: Entry[], by: Sort): Entry[] {
278
+ const out = [...entries];
279
+ switch (by) {
280
+ case "used":
281
+ out.sort((a, b) => b.info.updated_at - a.info.updated_at);
282
+ break;
283
+ case "name":
284
+ out.sort((a, b) => a.info.label.localeCompare(b.info.label));
285
+ break;
286
+ case "project":
287
+ out.sort((a, b) =>
288
+ projectOf(a.info).localeCompare(projectOf(b.info)) || putAway(b.info) - putAway(a.info)
289
+ );
290
+ break;
291
+ case "size":
292
+ out.sort((a, b) => b.info.message_count - a.info.message_count);
293
+ break;
294
+ // Most recently put away first: the one you want back is usually the one you last regretted.
295
+ default:
296
+ out.sort((a, b) => putAway(b.info) - putAway(a.info));
297
+ }
298
+ return out;
299
+ }
300
+
301
+ function projectOf(s: SessionInfo): string {
302
+ return s.project || basename(s.cwd) || s.cwd;
303
+ }
304
+
305
+ /**
306
+ * Which heading a conversation goes under.
307
+ *
308
+ * The age buckets are deliberately coarse. The question a heading answers here is "is this from
309
+ * this week or from another era", and a heading per day turns a list of forty into a list of
310
+ * seventy.
311
+ */
312
+ function headingOf(e: Entry, group: Group, at: number): string | null {
313
+ if (group === "none") return null;
314
+ if (group === "project") return projectOf(e.info);
315
+ const days = (at - putAway(e.info)) / 86400;
316
+ if (days < 1) return "Today";
317
+ if (days < 7) return "This week";
318
+ if (days < 31) return "This month";
319
+ if (days < 366) return "This year";
320
+ return "Older";
321
+ }
322
+
323
+ /**
324
+ * The rows under their headings, each heading appearing exactly once.
325
+ *
326
+ * The order of the *groups* is the order the sort put their first member in, which is what makes
327
+ * grouping and sorting compose instead of fighting: ordered by when you put things away and grouped
328
+ * by project, the project you last finished something in is at the top, and each project's own rows
329
+ * are still newest first. Doing it a row at a time — start a new heading whenever this row's differs
330
+ * from the last one's — is the version that looks right on a sorted-by-project list and prints
331
+ * `neosh`, `website`, `neosh`, `scratch`, `neosh` on every other one.
332
+ */
333
+ function grouped(
334
+ shown: Entry[],
335
+ group: Group,
336
+ at: number,
337
+ ): Array<{ heading: string | null; entries: Entry[] }> {
338
+ if (group === "none") return [{ heading: null, entries: shown }];
339
+ const order: Array<{ heading: string; entries: Entry[] }> = [];
340
+ const index = new Map<string, { heading: string; entries: Entry[] }>();
341
+ for (const e of shown) {
342
+ const heading = headingOf(e, group, at) ?? "";
343
+ let bucket = index.get(heading);
344
+ if (bucket === undefined) {
345
+ bucket = { heading, entries: [] };
346
+ index.set(heading, bucket);
347
+ order.push(bucket);
348
+ }
349
+ bucket.entries.push(e);
350
+ }
351
+ return order;
352
+ }
353
+
354
+ // ---------------------------------------------------------------------------
355
+ // The panel
356
+ // ---------------------------------------------------------------------------
357
+
358
+ /**
359
+ * The window, the rows in it, and what is ticked.
360
+ *
361
+ * A class rather than a closure over ten `let`s because every command registered below needs to
362
+ * reach the same one, and a panel that is opened and closed as often as `^F` is pressed is a thing
363
+ * whose lifetime is worth naming.
364
+ */
365
+ class Panel {
366
+ private win: WindowId | null = null;
367
+ private buf: BufferId | null = null;
368
+ private list: CursoredList<Target> | null = null;
369
+ private entries: Entry[] = [];
370
+ private shown: Entry[] = [];
371
+ /** Ticked conversations, by id. Kept across a refresh; dropped when the panel closes. */
372
+ readonly marks = new Set<string>();
373
+ filter = "";
374
+ /** Typed before a motion and consumed by it. On screen while it is half typed. */
375
+ readonly count = { pending: "" };
376
+ private width = 92;
377
+ private height = 20;
378
+ private ascii = false;
379
+ private drawing = false;
380
+ private again = false;
381
+ private actions: ActionItem[] = [];
382
+
383
+ constructor(private readonly neosh: Neosh) {}
384
+
385
+ isOpen(): boolean {
386
+ return this.win !== null;
387
+ }
388
+
389
+ /** The row under the cursor, whether or not it is ticked. */
390
+ cursorEntry(): Entry | null {
391
+ const id = this.list?.value?.id;
392
+ return this.shown.find((e) => e.info.id === id) ?? null;
393
+ }
394
+
395
+ /**
396
+ * What a verb is about: everything ticked, or the row under the cursor.
397
+ *
398
+ * One rule for every verb in the panel, including contributed ones. The alternative — some keys
399
+ * take the marks and some take the cursor — is a set of keys you have to remember the arity of,
400
+ * which is the thing marks were supposed to remove.
401
+ */
402
+ selection(): Entry[] {
403
+ if (this.marks.size > 0) {
404
+ const marked = this.shown.filter((e) => this.marks.has(e.info.id));
405
+ // Ticked rows the filter is currently hiding still count: you ticked them, and a filter is a
406
+ // way of finding things rather than a way of un-ticking them.
407
+ const hidden = this.entries.filter(
408
+ (e) => this.marks.has(e.info.id) && !this.shown.includes(e),
409
+ );
410
+ return [...marked, ...hidden];
411
+ }
412
+ const one = this.cursorEntry();
413
+ return one ? [one] : [];
414
+ }
415
+
416
+ /** Everything the panel is currently listing — what `^X` means by "the archive". */
417
+ listed(): Entry[] {
418
+ return [...this.shown];
419
+ }
420
+
421
+ filtered(): boolean {
422
+ return this.filter !== "";
423
+ }
424
+
425
+ /**
426
+ * How many rows the panel needs: its chrome, its headings, its rows, and one to unfold into.
427
+ *
428
+ * The last one is the row under the cursor becoming two lines when its title did not fit, which
429
+ * is a line the panel has to have somewhere to put or every long title scrolls the header away.
430
+ */
431
+ private fits(group: Group): number {
432
+ const at = now();
433
+ const headings = group === "none"
434
+ ? 0
435
+ : new Set(this.entries.map((e) => headingOf(e, group, at))).size;
436
+ // header, blank, headings, rows, blank, two lines of keys, one to unfold into.
437
+ const needed = 2 + headings + this.entries.length + 3 + 1;
438
+ return Math.max(10, Math.min(this.height + 8, needed));
439
+ }
440
+
441
+ async open(): Promise<void> {
442
+ if (this.win !== null) {
443
+ await this.close();
444
+ return;
445
+ }
446
+ const [width, height, ascii] = await Promise.all([
447
+ this.neosh.opt.get<number>("archive.width"),
448
+ this.neosh.opt.get<number>("archive.height"),
449
+ this.neosh.opt.get<boolean>("ui.ascii_only"),
450
+ ]);
451
+ this.width = width ?? 92;
452
+ this.height = height ?? 20;
453
+ this.ascii = ascii ?? false;
454
+
455
+ const group = (await this.neosh.opt.get<Group>("archive.group")) ?? "project";
456
+ this.entries = await collect(this.neosh);
457
+ if (this.entries.length === 0) {
458
+ this.neosh.notify("nothing is archived — `x` on a conversation puts it here", "info");
459
+ return;
460
+ }
461
+
462
+ const buf = await this.neosh.buf.create({ name: "[archive]", scratch: true, kind: KIND });
463
+ const ns = await this.neosh.ns.create(NS);
464
+ this.buf = buf;
465
+ // The width the list measures continuation lines against is the *content* width: an extent
466
+ // measures content and the frontend draws the border around it, so nothing here subtracts two.
467
+ this.list = new CursoredList<Target>(this.neosh, buf, ns, { width: () => this.width });
468
+
469
+ const win = await this.neosh.float.open(buf, {
470
+ anchor: { kind: "screen" },
471
+ // Fixed rather than `max`, which measures the *content*: a panel whose width was the length
472
+ // of its longest title would be a different width every time you filtered it, and the column
473
+ // its right-hand counts line up in would move with it.
474
+ width: { kind: "fixed", n: this.width },
475
+ // Sized to what there is to show, and never taller than `archive.height` allows.
476
+ //
477
+ // A float cannot be resized without being reopened, and reopening gives the window a new id
478
+ // and drops the keyboard — so the height is decided once, here, from the rows that exist.
479
+ // Asking for more than the content needs is not free: the foot is pinned to the bottom edge,
480
+ // so the buffer is padded out to the window's height, and a buffer one line longer than the
481
+ // window it is drawn in scrolls — which on a panel means the header quietly leaving the top
482
+ // of it on the first keypress. Filtering only ever shortens the list, and `pinned` is what
483
+ // holds the strip down when it does.
484
+ height: { kind: "max", n: this.fits(group) },
485
+ border: "rounded",
486
+ title: " Archived ",
487
+ focusable: true,
488
+ // Not on blur: a confirmation opens over this and takes the keyboard, and a panel that shut
489
+ // itself the moment it was asked a question would answer it into an empty screen.
490
+ closeOnBlur: false,
491
+ // Nothing else reaches the keyboard while this is up. It is a panel you are in the middle of
492
+ // using, and `^N` over it opening a conversation behind it is exactly what modality exists to stop.
493
+ // `^Q` and `^R` still resolve — see `ui.modal_escape_keys` — so this can never be a terminal
494
+ // somebody has to kill, and `^F` closes it from inside because a modal that borrows a global
495
+ // key to open itself owes a binding to shut itself.
496
+ modal: true,
497
+ z: 200,
498
+ });
499
+ this.win = win;
500
+ await this.neosh.focus.push(win);
501
+ this.actions = (await this.neosh.ext.list<ActionItem>(POINT_ACTION).catch(() => []))
502
+ .map((c) => c.item)
503
+ .filter((a): a is ActionItem => !!a?.key && !!a.command);
504
+ await this.draw();
505
+ }
506
+
507
+ async close(): Promise<void> {
508
+ const win = this.win;
509
+ if (win === null) return;
510
+ this.win = null;
511
+ this.marks.clear();
512
+ this.filter = "";
513
+ this.count.pending = "";
514
+ await this.neosh.focus.pop().catch(() => {});
515
+ await this.neosh.win.close(win).catch(() => {});
516
+ this.buf = null;
517
+ this.list = null;
518
+ }
519
+
520
+ /** Re-read what is archived. Called after anything that changed it. */
521
+ async refresh(): Promise<void> {
522
+ this.entries = await collect(this.neosh);
523
+ // A tick on a conversation that is no longer archived is a tick on nothing, and leaving it
524
+ // would mean the next verb quietly acted on a row that is not on screen.
525
+ const live = new Set(this.entries.map((e) => e.info.id));
526
+ for (const id of [...this.marks]) if (!live.has(id)) this.marks.delete(id);
527
+ await refreshSidebarRow(this.neosh);
528
+ if (this.entries.length === 0) {
529
+ await this.close();
530
+ return;
531
+ }
532
+ await this.draw();
533
+ }
534
+
535
+ /** How many rows of panel there are, for the two keys that mean "a screen". */
536
+ async rows(): Promise<number> {
537
+ if (this.win === null) return this.height;
538
+ const view = await this.neosh.win.viewport(this.win).catch(() => null);
539
+ // Less the header and the pinned foot, so `^D` is half of the list rather than half of the box.
540
+ return Math.max(2, (view?.height ?? this.height) - 4);
541
+ }
542
+
543
+ move(delta: number, opts: { wrap?: boolean } = {}): void {
544
+ this.list?.move(delta, opts);
545
+ }
546
+
547
+ toEnd(which: "first" | "last"): void {
548
+ this.list?.toEnd(which);
549
+ }
550
+
551
+ nth(n: number): void {
552
+ this.list?.nth(n);
553
+ }
554
+
555
+ async draw(): Promise<void> {
556
+ if (this.win === null || this.list === null) return;
557
+ if (this.drawing) {
558
+ this.again = true;
559
+ return;
560
+ }
561
+ this.drawing = true;
562
+ try {
563
+ do {
564
+ this.again = false;
565
+ const [sort, group, view] = await Promise.all([
566
+ this.neosh.opt.get<Sort>("archive.sort"),
567
+ this.neosh.opt.get<Group>("archive.group"),
568
+ this.neosh.win.viewport(this.win).catch(() => null),
569
+ ]);
570
+ // What the panel actually got, which on a terminal narrower than `archive.width` is not
571
+ // what it asked for. Clipping to the number we wanted rather than the number we have is how
572
+ // a title runs into the border on a small window.
573
+ if (view && view.width > 0) this.width = view.width;
574
+ const at = now();
575
+ this.shown = sorted(this.entries.filter((e) => matches(e, this.filter)), sort ?? "archived");
576
+ const built = this.build(this.shown, group ?? "project", at, sort ?? "archived");
577
+ this.list.setRows(built, (a, b) => a.id === b.id);
578
+ await this.list.render({ showCursor: true, win: this.win, pinned: 3 });
579
+ } while (this.again);
580
+ } finally {
581
+ this.drawing = false;
582
+ }
583
+ }
584
+
585
+ /** The header, the headings, the conversations, and the keys. */
586
+ private build(shown: Entry[], group: Group, at: number, sort: Sort): ListRow<Target>[] {
587
+ const rows: ListRow<Target>[] = [];
588
+ const glyph = this.ascii
589
+ ? { tick: "*", blank: " ", rule: "-" }
590
+ : { tick: "✓", blank: " ", rule: "┈" };
591
+
592
+ rows.push(this.header(shown, sort, group));
593
+ rows.push({ text: "", inert: true });
594
+
595
+ for (const bucket of grouped(shown, group, at)) {
596
+ if (bucket.heading !== null) {
597
+ rows.push({
598
+ text: ` ${clip(bucket.heading, this.width - 3)}`,
599
+ hl: "Sidebar.Heading",
600
+ inert: true,
601
+ });
602
+ }
603
+ for (const e of bucket.entries) rows.push(this.row(e, at, glyph));
604
+ }
605
+
606
+ if (shown.length === 0) {
607
+ rows.push({
608
+ text: ` nothing archived matches "${clip(this.filter, 40)}"`,
609
+ hl: "Sidebar.Dim",
610
+ inert: true,
611
+ });
612
+ }
613
+
614
+ rows.push({ text: "", inert: true });
615
+ for (const line of this.hints()) {
616
+ rows.push({ text: ` ${clip(line, this.width - 2)}`, hl: "Sidebar.Dim", inert: true });
617
+ }
618
+ return rows;
619
+ }
620
+
621
+ private header(shown: Entry[], sort: Sort, group: Group): ListRow<Target> {
622
+ const total = this.entries.length;
623
+ const parts = [`${total} archived`];
624
+ if (this.filter !== "") parts.push(`${shown.length} matching "${clip(this.filter, 24)}"`);
625
+ if (this.marks.size > 0) parts.push(`${this.marks.size} marked`);
626
+ // Said once, and only when it is true: past the restore cap a conversation is a file this
627
+ // workspace has never read, and a count that quietly differed from the sidebar's would be the
628
+ // kind of discrepancy people spend an afternoon on.
629
+ const cold = this.entries.filter((e) => !e.loaded).length;
630
+ if (cold > 0) parts.push(`${cold} on disk only`);
631
+ const right = this.count.pending !== ""
632
+ ? `${this.count.pending}`
633
+ : `${SORT_LABEL[sort]} · ${GROUP_LABEL[group]} `;
634
+ return {
635
+ // Measured against what is on the right rather than a number that was true when it was
636
+ // written: the right-hand text is drawn as virtual text and pushes nothing, so a header
637
+ // clipped to a guess is a header that runs underneath it.
638
+ text: ` ${clip(parts.join(" · "), Math.max(12, this.width - right.length - 3))}`,
639
+ hl: "Sidebar.Heading",
640
+ right: { text: right, hl: "Sidebar.Dim" },
641
+ inert: true,
642
+ };
643
+ }
644
+
645
+ private row(
646
+ e: Entry,
647
+ at: number,
648
+ glyph: { tick: string; blank: string; rule: string },
649
+ ): ListRow<Target> {
650
+ const marked = this.marks.has(e.info.id);
651
+ const count = e.info.message_count;
652
+ const when = ago(at - putAway(e.info));
653
+ const right = `${count} ${count === 1 ? "msg" : "msgs"}${when === "" ? "" : ` · ${when}`} `;
654
+ const prefix = ` ${marked ? glyph.tick : glyph.blank} `;
655
+ // The room a label has is what is left after the marker and the right-hand column, which is
656
+ // drawn as virtual text and therefore does not push anything: measured, not guessed.
657
+ const room = Math.max(8, this.width - byteLength(prefix) - right.length - 2);
658
+ const full = `${prefix}${e.info.label}`;
659
+ return {
660
+ text: `${prefix}${clip(e.info.label, room)}`,
661
+ // The rest of a clipped title, while the cursor is on it. A list of conversations whose
662
+ // titles all end in `…` is a list you cannot tell two rows of apart.
663
+ full,
664
+ indent: byteLength(prefix),
665
+ right: { text: right, hl: "Sidebar.Dim" },
666
+ spans: marked
667
+ ? [{ from: 1, to: 1 + byteLength(glyph.tick), hl: "Status.Ok" }]
668
+ : undefined,
669
+ value: { kind: "session", id: e.info.id },
670
+ };
671
+ }
672
+
673
+ /**
674
+ * The keys, at the foot, changing with what is ticked.
675
+ *
676
+ * Written out rather than inferred from the keymap because these are the panel's own bindings and
677
+ * it knows them — but the *verbs* on the second row are read off the contributions, so a plugin
678
+ * that adds a key to this panel gets it advertised rather than having to document it elsewhere.
679
+ */
680
+ private hints(): string[] {
681
+ const many = this.marks.size > 0;
682
+ const what = many ? `${this.marks.size} marked` : "this one";
683
+ const contributed = this.actions.map((a) => `${a.key} ${a.label}`).join(" ");
684
+ return [
685
+ `↵ open u put back (${what}) X delete (${what}) space mark a all`,
686
+ `/ filter s sort S group e copy ^X empty${
687
+ this.filtered() ? " what is shown" : ""
688
+ } esc close${contributed === "" ? "" : ` ${contributed}`}`,
689
+ ];
690
+ }
691
+ }
692
+
693
+ // ---------------------------------------------------------------------------
694
+ // Verbs
695
+ // ---------------------------------------------------------------------------
696
+
697
+ async function registerCommands(
698
+ neosh: Neosh,
699
+ subscriptions: Disposable[],
700
+ panel: Panel,
701
+ ): Promise<void> {
702
+ const keep = async (name: string, desc: string, fn: () => Promise<void> | void) => {
703
+ subscriptions.push(await neosh.cmd.register(name, () => fn(), { desc }));
704
+ };
705
+
706
+ await keep("archive.open", "What you have archived", () => panel.open());
707
+ // The name the sidebar used to answer to, kept because anybody's `init.ts` may be pointed at it.
708
+ await keep("session.archived", "Browse what you have archived", () => panel.open());
709
+ await keep(
710
+ "archive.sweep",
711
+ "Delete everything archived longer ago than `archive.retention_days`",
712
+ () => sweep(neosh),
713
+ );
714
+ await keep(
715
+ "archive.tidy",
716
+ "Archive conversations idle longer than `archive.auto_days`",
717
+ async () => {
718
+ await autoArchive(neosh, true);
719
+ },
720
+ );
721
+ }
722
+
723
+ /**
724
+ * Register one verb, and bind it inside this panel.
725
+ *
726
+ * Every key here goes through this, which is the point: `^Z` lists them, `^K` runs them, and
727
+ * `keymap.set("chat", "d", "…", { scope: { kind: "buf_kind", name: "neosh.archive" } })` from
728
+ * anybody's `init.ts` replaces one. The scope is the buffer *kind* rather than the window, because
729
+ * the window is made and destroyed every time the panel is opened and a binding on it would go with
730
+ * it.
731
+ */
732
+ async function bindKeys(
733
+ neosh: Neosh,
734
+ subscriptions: Disposable[],
735
+ panel: Panel,
736
+ ): Promise<void> {
737
+ const scope = { kind: "buf_kind", name: KIND } as const;
738
+
739
+ const verb = async (
740
+ name: string,
741
+ keys: string | string[] | null,
742
+ desc: string,
743
+ fn: () => Promise<void> | void,
744
+ opts: { redraw?: boolean } = {},
745
+ ): Promise<void> => {
746
+ subscriptions.push(await neosh.cmd.register(name, async () => {
747
+ await fn();
748
+ // A count belongs to the motion typed straight after it. Anything else ends it — otherwise a
749
+ // `5` you thought better of sits there and turns the next `j` into five.
750
+ panel.count.pending = "";
751
+ if (opts.redraw !== false) await panel.draw();
752
+ }, { desc }));
753
+ for (const key of keys === null ? [] : typeof keys === "string" ? [keys] : keys) {
754
+ await neosh.keymap.set("chat", key, name, { scope, desc });
755
+ }
756
+ };
757
+
758
+ /** Take the count that was typed, if one was, and forget it. */
759
+ const take = (fallback = 1): number => {
760
+ const n = Number.parseInt(panel.count.pending, 10);
761
+ panel.count.pending = "";
762
+ return Number.isFinite(n) && n > 0 ? Math.min(n, 999) : fallback;
763
+ };
764
+ const typed = (): number | null => {
765
+ const n = Number.parseInt(panel.count.pending, 10);
766
+ panel.count.pending = "";
767
+ return Number.isFinite(n) && n > 0 ? n : null;
768
+ };
769
+
770
+ // ---- moving ----
771
+ await verb(`${NS}.down`, ["j", "<Down>", "<C-n>"], "Next row", () => panel.move(take()));
772
+ await verb(`${NS}.up`, ["k", "<Up>", "<C-p>"], "Previous row", () => panel.move(-take()));
773
+
774
+ const by = (fraction: number, sign: 1 | -1) => async (): Promise<void> => {
775
+ const rows = await panel.rows();
776
+ const step = Math.max(1, Math.floor(rows * fraction)) * take();
777
+ // No wrapping on a page step: `^D` at the foot means there is no more of it, and a cursor that
778
+ // reappears at the top has thrown away the place you were reading from.
779
+ panel.move(sign * step, { wrap: false });
780
+ };
781
+ await verb(`${NS}.half.down`, "<C-d>", "Half a screen down", by(0.5, 1));
782
+ await verb(`${NS}.half.up`, "<C-u>", "Half a screen up", by(0.5, -1));
783
+ await verb(`${NS}.page.down`, "<PageDown>", "A screen down", by(1, 1));
784
+ await verb(`${NS}.page.up`, "<PageUp>", "A screen up", by(1, -1));
785
+
786
+ await verb(`${NS}.top`, "gg", "The first row, or the n-th with a count", () => {
787
+ const n = typed();
788
+ if (n === null) panel.toEnd("first");
789
+ else panel.nth(n);
790
+ });
791
+ await verb(`${NS}.bottom`, "G", "The last row, or the n-th with a count", () => {
792
+ const n = typed();
793
+ if (n === null) panel.toEnd("last");
794
+ else panel.nth(n);
795
+ });
796
+
797
+ // One command for all ten digits, reading which it was off the key that ran it — so `^Z` lists it
798
+ // once and `init.ts` can move it, rather than ten near-identical verbs.
799
+ subscriptions.push(await neosh.cmd.register(`${NS}.count`, async (_args, key) => {
800
+ const code = key?.key.code;
801
+ if (code?.kind !== "char") return;
802
+ if (panel.count.pending === "" && code.c === "0") return;
803
+ if (panel.count.pending.length < 3) panel.count.pending += code.c;
804
+ await panel.draw();
805
+ }, { desc: "Begin a count for the next motion" }));
806
+ for (const digit of "0123456789") {
807
+ await neosh.keymap.set("chat", digit, `${NS}.count`, {
808
+ scope,
809
+ desc: "Count for the next motion",
810
+ });
811
+ }
812
+
813
+ // ---- marking ----
814
+ await verb(`${NS}.mark`, ["<Space>", "<Tab>"], "Tick or untick this conversation", () => {
815
+ // The row under the cursor, never the selection: ticking is about one row, and a `<Space>` that
816
+ // toggled the first of ten already-marked rows would be a key that undid somebody else's work.
817
+ const id = panel.cursorEntry()?.info.id ?? null;
818
+ if (id === null) return;
819
+ if (panel.marks.has(id)) panel.marks.delete(id);
820
+ else panel.marks.add(id);
821
+ // Down a row afterwards, so ticking a run of them is one key repeated rather than two
822
+ // alternating. `<Space>` at the foot of the list stays there rather than wrapping to the top.
823
+ panel.move(1, { wrap: false });
824
+ });
825
+ await verb(`${NS}.mark.all`, "a", "Tick everything showing, or untick it", () => {
826
+ const shown = panel.listed();
827
+ const all = shown.length > 0 && shown.every((e) => panel.marks.has(e.info.id));
828
+ if (all) for (const e of shown) panel.marks.delete(e.info.id);
829
+ else for (const e of shown) panel.marks.add(e.info.id);
830
+ });
831
+
832
+ // ---- what to do with them ----
833
+ await verb(`${NS}.open`, "<CR>", "Put this back and go to it", async () => {
834
+ // `↵` restores *and* opens, because opening something you put away is a statement that you want
835
+ // it back: leaving it archived while you work in it would mean the list you look at does not
836
+ // contain the conversation you are in.
837
+ const one = panel.cursorEntry();
838
+ if (!one) return;
839
+ try {
840
+ await neosh.session.archive(one.info.id, false);
841
+ await neosh.session.switch(one.info.id);
842
+ await panel.close();
843
+ await refreshSidebarRow(neosh);
844
+ neosh.notify(`restored "${clip(one.info.label, 40)}"`);
845
+ } catch (e) {
846
+ neosh.notify(String(e), "warn");
847
+ }
848
+ }, { redraw: false });
849
+
850
+ // `u`, and deliberately not `^U` as well. The picker had `^U` for this because a bare
851
+ // letter there is a letter the filter can never contain; in a panel whose filter is a prompt the
852
+ // letter is free, and `^U` has a job — half a screen — that every list in the
853
+ // workspace gives it. Binding both would have quietly taken the motion away, in the one panel most likely
854
+ // to be longer than a screen.
855
+ await verb(`${NS}.restore`, "u", "Put these back, without going there", async () => {
856
+ // Tidying and travelling are different intentions, and being thrown into a conversation per row
857
+ // you restore is not tidying.
858
+ const chosen = panel.selection();
859
+ if (chosen.length === 0) return;
860
+ let done = 0;
861
+ for (const e of chosen) {
862
+ try {
863
+ await neosh.session.archive(e.info.id, false);
864
+ done += 1;
865
+ } catch (err) {
866
+ neosh.notify(String(err), "warn");
867
+ break;
868
+ }
869
+ }
870
+ panel.marks.clear();
871
+ if (done > 0) {
872
+ neosh.notify(done === 1 ? "put back" : `put ${done} conversations back`);
873
+ }
874
+ await panel.refresh();
875
+ }, { redraw: false });
876
+
877
+ await verb(`${NS}.delete`, "X", "Delete these, for good", async () => {
878
+ const chosen = panel.selection();
879
+ if (chosen.length === 0) return;
880
+ if (!(await confirmDelete(neosh, chosen, "delete"))) return;
881
+ await destroy(neosh, chosen);
882
+ panel.marks.clear();
883
+ await panel.refresh();
884
+ }, { redraw: false });
885
+
886
+ await verb(`${NS}.empty`, "<C-x>", "Empty the archive", async () => {
887
+ const chosen = panel.listed();
888
+ if (chosen.length === 0) return;
889
+ if (!(await confirmDelete(neosh, chosen, panel.filtered() ? "empty-filtered" : "empty"))) return;
890
+ await destroy(neosh, chosen);
891
+ panel.marks.clear();
892
+ await panel.refresh();
893
+ }, { redraw: false });
894
+
895
+ await verb(`${NS}.copy`, "e", "Copy these conversations as markdown", async () => {
896
+ const chosen = panel.selection();
897
+ if (chosen.length === 0) return;
898
+ await copyTranscripts(neosh, chosen);
899
+ }, { redraw: false });
900
+
901
+ await verb(`${NS}.copy.path`, "y", "Copy this conversation's directory", async () => {
902
+ const one = panel.cursorEntry();
903
+ if (!one) return;
904
+ await neosh.edit.copy(one.info.cwd);
905
+ neosh.notify(`copied ${one.info.cwd}`);
906
+ }, { redraw: false });
907
+
908
+ // ---- how the list is shown ----
909
+ //
910
+ // A prompt rather than typing straight into the panel. Every letter here is a verb — `a`, `e`,
911
+ // `u`, `y` — and a filter that took them would be a filter that could not contain them; shadowing
912
+ // twenty-six bindings for the duration of a search is the kind of mode that goes wrong once and
913
+ // then goes wrong every time.
914
+ await verb(`${NS}.filter`, "/", "Filter the list", async () => {
915
+ const typedIn = await prompt(neosh, "Filter the archive", { initial: panel.filter, width: 56 });
916
+ if (typedIn === null) return;
917
+ panel.filter = typedIn.trim();
918
+ });
919
+
920
+ await verb(`${NS}.sort`, "s", "The next ordering along", async () => {
921
+ const at = SORTS.indexOf((await neosh.opt.get<Sort>("archive.sort")) ?? "archived");
922
+ await neosh.opt.set("archive.sort", SORTS[(at + 1) % SORTS.length]!);
923
+ });
924
+ await verb(`${NS}.group`, "S", "The next grouping along", async () => {
925
+ const at = GROUPS.indexOf((await neosh.opt.get<Group>("archive.group")) ?? "project");
926
+ await neosh.opt.set("archive.group", GROUPS[(at + 1) % GROUPS.length]!);
927
+ });
928
+
929
+ await verb(`${NS}.help`, "?", "The keys for this panel", async () => {
930
+ await neosh.cmd.exec("help.keys").catch(() => {});
931
+ }, { redraw: false });
932
+
933
+ // ---- leaving ----
934
+ //
935
+ // One thing per press, the way `<Esc>` works in the transcript: what you have ticked, then what
936
+ // you have filtered to, then the panel. A key that threw all three away at once would mean a
937
+ // mistyped filter cost you a selection you had spent a minute making.
938
+ await verb(`${NS}.escape`, "<Esc>", "Drop the marks, the filter, then close", async () => {
939
+ if (panel.marks.size > 0) {
940
+ panel.marks.clear();
941
+ return;
942
+ }
943
+ if (panel.filter !== "") {
944
+ panel.filter = "";
945
+ return;
946
+ }
947
+ await panel.close();
948
+ });
949
+ await verb(`${NS}.close`, ["q", "<C-c>", "<C-f>"], "Close the archive", () => panel.close(), {
950
+ redraw: false,
951
+ });
952
+
953
+ // Where the keys nothing claimed go: nowhere. Not a handler — a sink. The float is modal, so an
954
+ // unbound key is swallowed rather than reaching the composer; this is what stops one that a
955
+ // *nearer* scope would otherwise leak.
956
+ subscriptions.push(await neosh.cmd.register(`${NS}.key`, () => {}, {
957
+ desc: "Swallow an unbound key in the archive",
958
+ }));
959
+
960
+ // The way in, from anywhere. `^F` has always been the archive's key and it toggles, because a key
961
+ // that opens a modal and cannot shut it is a key you have to remember a second one for.
962
+ await neosh.keymap.set("chat", "<C-f>", "archive.open", {
963
+ desc: "Archived conversations",
964
+ });
965
+
966
+ await bindContributed(neosh, subscriptions, panel, scope);
967
+ }
968
+
969
+ /**
970
+ * Keys somebody else put on these rows.
971
+ *
972
+ * Bound here rather than by the contributor, so the command is invoked with the conversations it is
973
+ * about and a plugin never has to track this panel's cursor. Re-read when the contributions change,
974
+ * because a plugin that loads after this panel has drawn would otherwise have a key nobody sees
975
+ * until the next restart.
976
+ */
977
+ async function bindContributed(
978
+ neosh: Neosh,
979
+ subscriptions: Disposable[],
980
+ panel: Panel,
981
+ scope: { kind: "buf_kind"; name: string },
982
+ ): Promise<void> {
983
+ /** The keys and commands from the last pass, so a re-read replaces rather than accumulates. */
984
+ let bound: Array<{ key: string; cmd: Disposable }> = [];
985
+
986
+ const apply = async () => {
987
+ const items = (await neosh.ext.list<ActionItem>(POINT_ACTION).catch(() => []))
988
+ .map((c) => c.item)
989
+ .filter((a): a is ActionItem => !!a?.key && !!a.command);
990
+ for (const previous of bound) {
991
+ await neosh.keymap.del("chat", previous.key, scope).catch(() => {});
992
+ previous.cmd.dispose();
993
+ }
994
+ bound = [];
995
+ for (const action of items) {
996
+ const name = `${NS}.custom.${action.command}`;
997
+ const cmd = await neosh.cmd.register(name, async () => {
998
+ // The conversations it is about, as arguments. A contributed verb never has to find this
999
+ // panel's cursor, which is the whole reason the key is bound here rather than by the
1000
+ // plugin that wanted it.
1001
+ const ids = panel.selection().map((e) => e.info.id);
1002
+ await neosh.cmd.exec(action.command, ids).catch((e) => neosh.notify(String(e), "warn"));
1003
+ await panel.refresh();
1004
+ }, { desc: action.label });
1005
+ await neosh.keymap.set("chat", action.key, name, { scope, desc: action.label });
1006
+ bound.push({ key: action.key, cmd });
1007
+ }
1008
+ };
1009
+
1010
+ await apply();
1011
+ subscriptions.push(neosh.ext.onChange((e) => {
1012
+ if (e.point === POINT_ACTION) void apply();
1013
+ }));
1014
+ subscriptions.push({
1015
+ dispose: () => {
1016
+ for (const previous of bound) previous.cmd.dispose();
1017
+ },
1018
+ });
1019
+ }
1020
+
1021
+ // ---------------------------------------------------------------------------
1022
+ // Deleting, and being asked about it
1023
+ // ---------------------------------------------------------------------------
1024
+
1025
+ /**
1026
+ * The question, with what is at stake in it.
1027
+ *
1028
+ * The rule: everything irreversible asks, unconditionally, and the value of the question is
1029
+ * that it is always there. So it says how many, how much is in them and where they came from —
1030
+ * "Are you sure?" is a speed bump you learn to clear without reading, and a number is not.
1031
+ */
1032
+ async function confirmDelete(
1033
+ neosh: Neosh,
1034
+ chosen: Entry[],
1035
+ shape: "delete" | "empty" | "empty-filtered",
1036
+ ): Promise<boolean> {
1037
+ const n = chosen.length;
1038
+ const messages = chosen.reduce((sum, e) => sum + e.info.message_count, 0);
1039
+ const projects = new Set(chosen.map((e) => projectOf(e.info))).size;
1040
+ const oldest = chosen.reduce((old, e) => Math.min(old, putAway(e.info)), Number.MAX_SAFE_INTEGER);
1041
+ const span = ago(now() - oldest);
1042
+
1043
+ const question = shape === "delete"
1044
+ ? (n === 1
1045
+ ? `Delete "${clip(chosen[0]!.info.label, 44)}"?`
1046
+ : `Delete ${n} archived conversations?`)
1047
+ : shape === "empty-filtered"
1048
+ ? `Delete all ${n} archived conversations shown?`
1049
+ : `Empty the archive — all ${n} conversations?`;
1050
+
1051
+ const detail = [
1052
+ `${messages} ${messages === 1 ? "message" : "messages"} in ${
1053
+ n === 1 ? "it" : "them"
1054
+ }, from ${projects} ${projects === 1 ? "project" : "projects"}${
1055
+ span === "" ? "" : `, going back ${span}`
1056
+ }.`,
1057
+ n === 1
1058
+ ? "It goes from disk, and there is no undo."
1059
+ : "They go from disk, and there is no undo.",
1060
+ "`u` puts one back into your list instead, and costs nothing.",
1061
+ ];
1062
+
1063
+ return confirmDestructive(neosh, question, { yes: "Delete", no: "Keep", detail });
1064
+ }
1065
+
1066
+ /** Delete them, saying what went and stopping at the first thing that would not. */
1067
+ async function destroy(neosh: Neosh, chosen: Entry[]): Promise<number> {
1068
+ let done = 0;
1069
+ for (const e of chosen) {
1070
+ try {
1071
+ await neosh.session.close(e.info.id);
1072
+ done += 1;
1073
+ } catch (err) {
1074
+ // Named, because "12 of 40" with no reason is a state nobody can act on. The commonest cause
1075
+ // is a conversation that stopped being archived while the panel was open.
1076
+ neosh.notify(`stopped after ${done}: ${String(err)}`, "warn");
1077
+ return done;
1078
+ }
1079
+ }
1080
+ if (done > 0) neosh.notify(done === 1 ? "deleted" : `deleted ${done} conversations`);
1081
+ return done;
1082
+ }
1083
+
1084
+ // ---------------------------------------------------------------------------
1085
+ // Getting a piece of it out
1086
+ // ---------------------------------------------------------------------------
1087
+
1088
+ /**
1089
+ * Copy what was said, as markdown.
1090
+ *
1091
+ * The clipboard rather than a file, and the reason is the same one that makes `Clipboard` an OSC
1092
+ * escape: the workspace may be on another machine entirely, so a path it writes to is a path on the
1093
+ * wrong computer. This is the transcript reader's `ya` aimed at conversations you are about to
1094
+ * throw away — which is the last moment anybody would want it.
1095
+ */
1096
+ async function copyTranscripts(neosh: Neosh, chosen: Entry[]): Promise<void> {
1097
+ const parts: string[] = [];
1098
+ for (const e of chosen) {
1099
+ const messages = await neosh.session.messages(e.info.id).catch(() => [] as Message[]);
1100
+ parts.push(render(e.info, messages));
1101
+ }
1102
+ const text = parts.join("\n\n---\n\n");
1103
+ if (text.trim() === "") {
1104
+ neosh.notify("nothing was said in that", "info");
1105
+ return;
1106
+ }
1107
+ await neosh.edit.copy(text);
1108
+ const n = chosen.length;
1109
+ neosh.notify(n === 1 ? "copied the conversation" : `copied ${n} conversations`);
1110
+ }
1111
+
1112
+ function render(info: SessionInfo, messages: Message[]): string {
1113
+ const out = [`# ${info.label}`, "", `_${info.project || info.cwd} · ${info.message_count} messages_`, ""];
1114
+ for (const m of messages) {
1115
+ const text = m.content
1116
+ .map((b) => {
1117
+ switch (b.type) {
1118
+ case "text":
1119
+ return b.text;
1120
+ // Left out on purpose: reasoning is the model's working, a tool result is often thousands
1121
+ // of lines of one, and what somebody wants out of a conversation is what was said in it.
1122
+ case "tool_use":
1123
+ return `> ran \`${b.name}\``;
1124
+ default:
1125
+ return "";
1126
+ }
1127
+ })
1128
+ .filter((t) => t !== "")
1129
+ .join("\n\n");
1130
+ if (text === "") continue;
1131
+ out.push(`## ${m.role === "user" ? "You" : m.role === "assistant" ? "Agent" : m.role}`, "", text, "");
1132
+ }
1133
+ return out.join("\n");
1134
+ }
1135
+
1136
+ // ---------------------------------------------------------------------------
1137
+ // Housekeeping
1138
+ // ---------------------------------------------------------------------------
1139
+
1140
+ /**
1141
+ * What the workspace does about the archive on its own, which is: archive, and mention.
1142
+ *
1143
+ * `archive.auto_days` puts an idle conversation away. That is allowed to happen without being asked
1144
+ * because archiving is reversible and free — it is the *test* for whether a dialog is owed, applied
1145
+ * from the other side — and the alert says how many so it is never something you find out about by
1146
+ * noticing a row has gone.
1147
+ *
1148
+ * `archive.retention_days` deletes nothing, ever. It says how much of the archive is old and points
1149
+ * at the key that empties it. The workspace refuses to delete history on a timer, and a
1150
+ * setting that quietly reversed that would be the program deciding something that is not its to
1151
+ * decide. `archive.sweep` is the same number with a person behind it.
1152
+ */
1153
+ async function housekeeping(neosh: Neosh): Promise<void> {
1154
+ // The reminder waits for a pass that did nothing else. A notice is a *reply* and does not stack,
1155
+ // so two sent a moment apart is one notice: `archived 2 conversations idle for 7+ days` arrived
1156
+ // and was immediately painted over by `1 is older than 30 days`, which is the less useful of the
1157
+ // two by a distance — one is something that just happened to your list, the other is a standing
1158
+ // fact that will still be true in six hours when this runs again.
1159
+ const moved = await autoArchive(neosh, false);
1160
+ if (moved === 0) await remind(neosh);
1161
+ await refreshSidebarRow(neosh);
1162
+ }
1163
+
1164
+ async function autoArchive(neosh: Neosh, spoken: boolean): Promise<number> {
1165
+ const days = (await neosh.opt.get<number>("archive.auto_days").catch(() => 0)) ?? 0;
1166
+ if (days <= 0) {
1167
+ if (spoken) neosh.notify("`archive.auto_days` is off", "info");
1168
+ return 0;
1169
+ }
1170
+ const cutoff = now() - days * 86400;
1171
+ const sessions = await neosh.session.list().catch(() => [] as SessionInfo[]);
1172
+ const stale = sessions.filter(
1173
+ (s) =>
1174
+ !s.archived &&
1175
+ !s.is_active &&
1176
+ // Never one that is working, and never one that is waiting on you: idleness is measured in
1177
+ // when something last happened, and a turn in flight is something happening now.
1178
+ !s.active_turn &&
1179
+ !s.unread &&
1180
+ s.message_count > 0 &&
1181
+ s.updated_at < cutoff,
1182
+ );
1183
+ if (stale.length === 0) {
1184
+ if (spoken) neosh.notify(`nothing has been idle for ${days} days`, "info");
1185
+ return 0;
1186
+ }
1187
+ let done = 0;
1188
+ for (const s of stale) {
1189
+ try {
1190
+ await neosh.session.archive(s.id, true);
1191
+ done += 1;
1192
+ } catch {
1193
+ // One that refuses is one the store had a reason to keep — the last open conversation, most
1194
+ // likely. Not worth a warning per conversation on a housekeeping pass.
1195
+ }
1196
+ }
1197
+ if (done > 0) {
1198
+ neosh.notify(
1199
+ `archived ${done} ${done === 1 ? "conversation" : "conversations"} idle for ${days}+ days — \`^F\` has them`,
1200
+ );
1201
+ }
1202
+ return done;
1203
+ }
1204
+
1205
+ async function remind(neosh: Neosh): Promise<void> {
1206
+ const [days, on] = await Promise.all([
1207
+ neosh.opt.get<number>("archive.retention_days").catch(() => 0),
1208
+ neosh.opt.get<boolean>("archive.remind").catch(() => true),
1209
+ ]);
1210
+ if ((days ?? 0) <= 0 || on === false) return;
1211
+ const old = (await collect(neosh)).filter((e) => putAway(e.info) < now() - (days ?? 0) * 86400);
1212
+ if (old.length === 0) return;
1213
+ neosh.notify(
1214
+ `${old.length} archived ${old.length === 1 ? "conversation is" : "conversations are"} older than ${days} days — \`^F\`, then \`^X\``,
1215
+ "info",
1216
+ );
1217
+ }
1218
+
1219
+ /** The by-hand version of the thing this plugin will not do on a timer. */
1220
+ async function sweep(neosh: Neosh): Promise<void> {
1221
+ const days = (await neosh.opt.get<number>("archive.retention_days").catch(() => 0)) ?? 0;
1222
+ if (days <= 0) {
1223
+ neosh.notify("set `archive.retention_days` first — this deletes what is older than it", "warn");
1224
+ return;
1225
+ }
1226
+ const old = (await collect(neosh)).filter((e) => putAway(e.info) < now() - days * 86400);
1227
+ if (old.length === 0) {
1228
+ neosh.notify(`nothing has been archived longer than ${days} days`, "info");
1229
+ return;
1230
+ }
1231
+ if (!(await confirmDelete(neosh, old, "empty-filtered"))) return;
1232
+ await destroy(neosh, old);
1233
+ announced = null;
1234
+ await refreshSidebarRow(neosh);
1235
+ }
1236
+
1237
+ // ---------------------------------------------------------------------------
1238
+ // The door in the sidebar
1239
+ // ---------------------------------------------------------------------------
1240
+
1241
+ const POINT_SECTION = "sidebar.section";
1242
+ const POINT_SIDEBAR_ACTION = "sidebar.action";
1243
+
1244
+ /**
1245
+ * `a` on every row of the project panel — and, only if you ask for it, a row saying how many.
1246
+ *
1247
+ * The row is **off**. It used to be a door rather than a drawer left open, and it is still the
1248
+ * smallest possible version of one: a single dim line, and only while there is something behind it.
1249
+ * But the column it sits in is your conversations, the thing it announces is by definition the
1250
+ * thing you are finished with, and a permanent line about what you are *not* doing is a line the
1251
+ * panel cannot spare — which is the same argument that took the section out, applied to
1252
+ * what it left behind.
1253
+ *
1254
+ * What replaces it is what the archive was always reached by: `^F` from anywhere, `a` from the
1255
+ * panel — advertised in the panel's key strip, so it is not a key you have to have been told about
1256
+ * — and `^K`. `archive.sidebar = true` puts the row back for anybody who wants the count on screen.
1257
+ *
1258
+ * Both are contributed rather than drawn by the sidebar, which is the surface claim tested on
1259
+ * ourselves: the panel that owns the archive is not the panel that says there is one. Turn this
1260
+ * plugin off and both go with it, rather than leaving a row pointing at a command that has gone.
1261
+ */
1262
+ async function contributeToSidebar(neosh: Neosh, subscriptions: Disposable[]): Promise<void> {
1263
+ await neosh.ext.contribute(POINT_SIDEBAR_ACTION, "browse", {
1264
+ key: "a",
1265
+ label: "archive",
1266
+ command: "archive.open",
1267
+ on: "any",
1268
+ });
1269
+ subscriptions.push({
1270
+ dispose: () => void neosh.ext.remove(POINT_SIDEBAR_ACTION, "browse").catch(() => {}),
1271
+ });
1272
+ subscriptions.push({
1273
+ dispose: () => void neosh.ext.remove(POINT_SECTION, "archive").catch(() => {}),
1274
+ });
1275
+ }
1276
+
1277
+ /**
1278
+ * How many there are, in the sidebar — when it was asked for, and nothing at all when there are
1279
+ * none.
1280
+ *
1281
+ * Off unless `archive.sidebar` says otherwise; see {@link contributeToSidebar}. `null` rather than
1282
+ * `0` for "nothing announced yet", so turning the option off at runtime withdraws the row instead
1283
+ * of matching a remembered count and returning early.
1284
+ */
1285
+ let announced: number | null = null;
1286
+
1287
+ async function refreshSidebarRow(neosh: Neosh): Promise<void> {
1288
+ const [ascii, wanted] = await Promise.all([
1289
+ neosh.opt.get<boolean>("ui.ascii_only").catch(() => false),
1290
+ neosh.opt.get<boolean>("archive.sidebar").catch(() => false),
1291
+ ]);
1292
+ const count = wanted === true ? await archivedCount(neosh) : 0;
1293
+ if (count === announced) return;
1294
+ announced = count;
1295
+ if (count === 0) {
1296
+ await neosh.ext.remove(POINT_SECTION, "archive").catch(() => {});
1297
+ return;
1298
+ }
1299
+ await neosh.ext.contribute(POINT_SECTION, "archive", {
1300
+ at: "below",
1301
+ rows: [{
1302
+ text: ` ${ascii ? "-" : "┈"} Archived`,
1303
+ hl: "Sidebar.Dim",
1304
+ right: { text: `${count} ^F `, hl: "Sidebar.Dim" },
1305
+ command: "archive.open",
1306
+ }],
1307
+ }, { priority: 20 });
1308
+ }
1309
+
1310
+ // ---------------------------------------------------------------------------
1311
+ // Small things
1312
+ // ---------------------------------------------------------------------------
1313
+
1314
+ function basename(path: string): string {
1315
+ const at = path.replace(/\/+$/, "").lastIndexOf("/");
1316
+ return at < 0 ? path : path.slice(at + 1);
1317
+ }
1318
+
1319
+ /** Clip to `n` characters, with an ellipsis where something was taken off. */
1320
+ function clip(text: string, n: number): string {
1321
+ const chars = Array.from(text);
1322
+ if (chars.length <= n) return text;
1323
+ return `${chars.slice(0, Math.max(1, n - 1)).join("")}…`;
1324
+ }
1325
+
1326
+ /** How long ago, in one short word. Empty for anything in the last minute. */
1327
+ function ago(seconds: number): string {
1328
+ if (!Number.isFinite(seconds) || seconds < 60) return "";
1329
+ const minutes = Math.floor(seconds / 60);
1330
+ if (minutes < 60) return `${minutes}m`;
1331
+ const hours = Math.floor(minutes / 60);
1332
+ if (hours < 24) return `${hours}h`;
1333
+ const days = Math.floor(hours / 24);
1334
+ if (days < 7) return `${days}d`;
1335
+ const weeks = Math.floor(days / 7);
1336
+ if (weeks < 9) return `${weeks}w`;
1337
+ const months = Math.floor(days / 30);
1338
+ if (months < 12) return `${months}mo`;
1339
+ return `${Math.floor(days / 365)}y`;
1340
+ }
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@neosh/archive",
3
+ "version": "0.1.0",
4
+ "description": "What you have finished with: a panel to find it in, and the verbs that empty it.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "neosh",
9
+ "neosh-plugin"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/neoswarm/neosh.git",
14
+ "directory": "plugins/builtin/archive"
15
+ },
16
+ "files": [
17
+ "*.ts",
18
+ "plugin.toml",
19
+ "!._*"
20
+ ]
21
+ }
package/plugin.toml ADDED
@@ -0,0 +1,4 @@
1
+ name = "archive"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "What you have finished with: a panel to find it in, and the verbs that empty it."