@neosh/api 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 (182) hide show
  1. package/package.json +31 -0
  2. package/src/generated/AccountKind.ts +9 -0
  3. package/src/generated/Activity.ts +85 -0
  4. package/src/generated/AgentCommand.ts +18 -0
  5. package/src/generated/AgentState.ts +6 -0
  6. package/src/generated/AgentSummary.ts +46 -0
  7. package/src/generated/Anchor.ts +12 -0
  8. package/src/generated/Animation.ts +40 -0
  9. package/src/generated/ApiCall.ts +520 -0
  10. package/src/generated/ApiError.ts +15 -0
  11. package/src/generated/ApiOk.ts +110 -0
  12. package/src/generated/ApiResponse.ts +8 -0
  13. package/src/generated/AscpMessage.ts +73 -0
  14. package/src/generated/AttachmentInfo.ts +25 -0
  15. package/src/generated/Attrs.ts +10 -0
  16. package/src/generated/AuthRef.ts +14 -0
  17. package/src/generated/BackgroundTask.ts +23 -0
  18. package/src/generated/BlockStartKind.ts +8 -0
  19. package/src/generated/BorderStyle.ts +3 -0
  20. package/src/generated/BranchInfo.ts +19 -0
  21. package/src/generated/Brand.ts +31 -0
  22. package/src/generated/BufferId.ts +6 -0
  23. package/src/generated/BuildId.ts +24 -0
  24. package/src/generated/Capability.ts +10 -0
  25. package/src/generated/ClientMessage.ts +67 -0
  26. package/src/generated/Color.ts +10 -0
  27. package/src/generated/CommandEntry.ts +13 -0
  28. package/src/generated/CommitInfo.ts +10 -0
  29. package/src/generated/ContentBlock.ts +39 -0
  30. package/src/generated/Contribution.ts +16 -0
  31. package/src/generated/CostBasis.ts +6 -0
  32. package/src/generated/CredentialInfo.ts +30 -0
  33. package/src/generated/CredentialSource.ts +18 -0
  34. package/src/generated/CursorMotion.ts +21 -0
  35. package/src/generated/CursorShape.ts +6 -0
  36. package/src/generated/DetachReason.ts +5 -0
  37. package/src/generated/DiffTarget.ts +10 -0
  38. package/src/generated/Dock.ts +9 -0
  39. package/src/generated/DriverCommand.ts +20 -0
  40. package/src/generated/DriverKind.ts +8 -0
  41. package/src/generated/Extent.ts +20 -0
  42. package/src/generated/ExtmarkId.ts +6 -0
  43. package/src/generated/ExtmarkInfo.ts +43 -0
  44. package/src/generated/ExtmarkOpts.ts +33 -0
  45. package/src/generated/ExtmarkRender.ts +44 -0
  46. package/src/generated/FileChange.ts +18 -0
  47. package/src/generated/FileState.ts +15 -0
  48. package/src/generated/FloatConfig.ts +63 -0
  49. package/src/generated/FrameSet.ts +9 -0
  50. package/src/generated/Gravity.ts +11 -0
  51. package/src/generated/HighlightDef.ts +13 -0
  52. package/src/generated/HighlightEntry.ts +14 -0
  53. package/src/generated/HighlightSpec.ts +19 -0
  54. package/src/generated/Hint.ts +18 -0
  55. package/src/generated/HlTarget.ts +10 -0
  56. package/src/generated/HookInvocation.ts +14 -0
  57. package/src/generated/HookName.ts +17 -0
  58. package/src/generated/HookOutcome.ts +14 -0
  59. package/src/generated/HookPayload.ts +95 -0
  60. package/src/generated/InputEvent.ts +31 -0
  61. package/src/generated/InstanceConfig.ts +26 -0
  62. package/src/generated/InstanceId.ts +6 -0
  63. package/src/generated/KeyCode.ts +20 -0
  64. package/src/generated/KeyContext.ts +24 -0
  65. package/src/generated/KeyMods.ts +8 -0
  66. package/src/generated/KeyPress.ts +5 -0
  67. package/src/generated/KeymapEntry.ts +11 -0
  68. package/src/generated/KeymapScope.ts +20 -0
  69. package/src/generated/LineDraw.ts +15 -0
  70. package/src/generated/LineRender.ts +9 -0
  71. package/src/generated/LinkState.ts +16 -0
  72. package/src/generated/MarkDraw.ts +40 -0
  73. package/src/generated/Message.ts +5 -0
  74. package/src/generated/MessageLevel.ts +3 -0
  75. package/src/generated/Mode.ts +6 -0
  76. package/src/generated/ModelCapabilities.ts +14 -0
  77. package/src/generated/ModelEntry.ts +13 -0
  78. package/src/generated/ModelId.ts +6 -0
  79. package/src/generated/ModelInfo.ts +30 -0
  80. package/src/generated/ModelSelection.ts +15 -0
  81. package/src/generated/ModelTier.ts +14 -0
  82. package/src/generated/NamespaceId.ts +6 -0
  83. package/src/generated/NodeCapabilities.ts +30 -0
  84. package/src/generated/NodeId.ts +11 -0
  85. package/src/generated/NodeInfo.ts +24 -0
  86. package/src/generated/NoticeKind.ts +11 -0
  87. package/src/generated/Offset.ts +7 -0
  88. package/src/generated/OnDelete.ts +7 -0
  89. package/src/generated/OptionChoice.ts +8 -0
  90. package/src/generated/OptionEntry.ts +23 -0
  91. package/src/generated/OptionSelection.ts +4 -0
  92. package/src/generated/OptionSpec.ts +17 -0
  93. package/src/generated/OptionType.ts +16 -0
  94. package/src/generated/OptionValue.ts +13 -0
  95. package/src/generated/PermissionDecision.ts +6 -0
  96. package/src/generated/PermissionMode.ts +3 -0
  97. package/src/generated/PermissionOption.ts +21 -0
  98. package/src/generated/PermissionOptionKind.ts +13 -0
  99. package/src/generated/PlanState.ts +6 -0
  100. package/src/generated/PlanStep.ts +7 -0
  101. package/src/generated/PluginActivation.ts +10 -0
  102. package/src/generated/PluginEvent.ts +77 -0
  103. package/src/generated/PluginId.ts +6 -0
  104. package/src/generated/PluginInbound.ts +10 -0
  105. package/src/generated/PluginInfo.ts +19 -0
  106. package/src/generated/PluginManifest.ts +54 -0
  107. package/src/generated/PluginOutbound.ts +11 -0
  108. package/src/generated/PluginPermission.ts +18 -0
  109. package/src/generated/PluginProvides.ts +19 -0
  110. package/src/generated/PluginRequest.ts +11 -0
  111. package/src/generated/PluginResponse.ts +10 -0
  112. package/src/generated/PointInfo.ts +16 -0
  113. package/src/generated/Pricing.ts +12 -0
  114. package/src/generated/ProjectKey.ts +21 -0
  115. package/src/generated/ProviderEmit.ts +11 -0
  116. package/src/generated/ProviderEvent.ts +32 -0
  117. package/src/generated/ProviderOptionDescriptor.ts +38 -0
  118. package/src/generated/ProviderOptionValue.ts +3 -0
  119. package/src/generated/QuestionAnswer.ts +24 -0
  120. package/src/generated/QuestionOption.ts +17 -0
  121. package/src/generated/QuotaCredits.ts +23 -0
  122. package/src/generated/QuotaSample.ts +19 -0
  123. package/src/generated/QuotaSeverity.ts +11 -0
  124. package/src/generated/QuotaSnapshot.ts +34 -0
  125. package/src/generated/QuotaSource.ts +6 -0
  126. package/src/generated/QuotaWindow.ts +40 -0
  127. package/src/generated/Rect.ts +7 -0
  128. package/src/generated/Refusal.ts +10 -0
  129. package/src/generated/RemoteProject.ts +19 -0
  130. package/src/generated/RepoInfo.ts +23 -0
  131. package/src/generated/RepoStatus.ts +5 -0
  132. package/src/generated/RequestId.ts +6 -0
  133. package/src/generated/Role.ts +3 -0
  134. package/src/generated/RunningTurn.ts +12 -0
  135. package/src/generated/SelectShape.ts +6 -0
  136. package/src/generated/ServerMessage.ts +15 -0
  137. package/src/generated/SessionId.ts +6 -0
  138. package/src/generated/SessionInfo.ts +171 -0
  139. package/src/generated/StatusAlign.ts +3 -0
  140. package/src/generated/StatusSegment.ts +49 -0
  141. package/src/generated/StopReason.ts +16 -0
  142. package/src/generated/StreamEvent.ts +26 -0
  143. package/src/generated/StreamId.ts +6 -0
  144. package/src/generated/SurfaceCell.ts +22 -0
  145. package/src/generated/SurfaceId.ts +6 -0
  146. package/src/generated/SwarmAgent.ts +11 -0
  147. package/src/generated/SwarmNode.ts +24 -0
  148. package/src/generated/SwarmStranger.ts +21 -0
  149. package/src/generated/TaskId.ts +11 -0
  150. package/src/generated/TaskStatus.ts +11 -0
  151. package/src/generated/TextEdit.ts +19 -0
  152. package/src/generated/ToolCall.ts +13 -0
  153. package/src/generated/ToolCallId.ts +6 -0
  154. package/src/generated/ToolDef.ts +12 -0
  155. package/src/generated/ToolInvocation.ts +11 -0
  156. package/src/generated/ToolResult.ts +3 -0
  157. package/src/generated/ToolSource.ts +12 -0
  158. package/src/generated/TurnId.ts +6 -0
  159. package/src/generated/TurnRequest.ts +56 -0
  160. package/src/generated/UiEvent.ts +80 -0
  161. package/src/generated/Usage.ts +15 -0
  162. package/src/generated/UsageBucket.ts +32 -0
  163. package/src/generated/UsageHistory.ts +23 -0
  164. package/src/generated/UsageResolution.ts +6 -0
  165. package/src/generated/UsageScanSource.ts +24 -0
  166. package/src/generated/UsageScanStatus.ts +3 -0
  167. package/src/generated/UserQuestion.ts +34 -0
  168. package/src/generated/VarScope.ts +18 -0
  169. package/src/generated/ViewId.ts +16 -0
  170. package/src/generated/ViewInfo.ts +23 -0
  171. package/src/generated/Viewport.ts +18 -0
  172. package/src/generated/VirtChunk.ts +6 -0
  173. package/src/generated/VirtTextPos.ts +6 -0
  174. package/src/generated/WindowId.ts +6 -0
  175. package/src/generated/WindowInfo.ts +22 -0
  176. package/src/generated/WindowLayout.ts +28 -0
  177. package/src/generated/WorkspaceStatus.ts +28 -0
  178. package/src/generated/WorktreeInfo.ts +16 -0
  179. package/src/globals.d.ts +39 -0
  180. package/src/index.ts +2961 -0
  181. package/src/ui.ts +3279 -0
  182. package/tsconfig.json +17 -0
package/src/ui.ts ADDED
@@ -0,0 +1,3279 @@
1
+ /**
2
+ * `@neosh/api/ui` — widgets built on the public API.
3
+ *
4
+ * Nothing here is privileged. Every line uses the same surface a third-party plugin has, which is
5
+ * the point: if a picker could not be written this way, the API would be missing something. The
6
+ * model switcher, the branch picker and the command palette are all this file plus a list.
7
+ *
8
+ * Import as:
9
+ *
10
+ * ```ts
11
+ * import { picker, confirm, prompt } from "@neosh/api/ui";
12
+ * ```
13
+ */
14
+
15
+ import { byteLength, byteOffsets, clipToWidth, padToWidth, width } from "@neosh/api";
16
+ import type {
17
+ BufferId,
18
+ Contribution,
19
+ Disposable,
20
+ DrawnMark,
21
+ DrawnRow,
22
+ FileChange,
23
+ FileState,
24
+ FloatOptions,
25
+ KeyContext,
26
+ MarkOptions,
27
+ Neosh,
28
+ WindowId,
29
+ } from "@neosh/api";
30
+
31
+ /**
32
+ * The buffer kinds the shared widgets publish.
33
+ *
34
+ * A picker used to have no kind, which by the surface rule made it the one panel in the
35
+ * workspace you could only replace and never extend. With one, `keymap.set` at `buf_kind` scope
36
+ * binds inside every picker at once, `win.ofKind` finds the open one, and `win.setHighlights`
37
+ * restyles them all.
38
+ */
39
+ export const KIND_PICKER = "neosh.picker";
40
+ export const KIND_CONFIRM = "neosh.confirm";
41
+ export const KIND_PROMPT = "neosh.prompt";
42
+
43
+ // ---------------------------------------------------------------------------
44
+ // Fuzzy matching
45
+ // ---------------------------------------------------------------------------
46
+
47
+ /** Where a query matched, so the caller can highlight it. */
48
+ export interface Match {
49
+ score: number;
50
+ /**
51
+ * Indices of matched **code points**, ascending — not byte offsets and not UTF-16 indices.
52
+ *
53
+ * Code points because that is the unit a user perceives as "a character"; convert with
54
+ * `byteOffsets` before handing them to `ns.mark`, which speaks bytes.
55
+ */
56
+ positions: number[];
57
+ }
58
+
59
+ /**
60
+ * Subsequence match with a bias toward starts of words and runs of adjacent characters.
61
+ *
62
+ * Deliberately simple and deliberately *stable*: a picker that reorders under your fingers as you
63
+ * type one more character is worse than one that ranks imperfectly.
64
+ */
65
+ export function fuzzy(candidate: string, query: string): Match | null {
66
+ if (query === "") return { score: 0, positions: [] };
67
+
68
+ // Code points, not UTF-16 units: indexing a string directly splits an emoji in half and reports
69
+ // a position no later stage can use.
70
+ const hay = Array.from(candidate.toLowerCase());
71
+ const needle = Array.from(query.toLowerCase());
72
+ const positions: number[] = [];
73
+ let score = 0;
74
+ let from = 0;
75
+ let previous = -2;
76
+
77
+ for (const ch of needle) {
78
+ if (ch === " ") continue; // spaces separate terms rather than needing to match
79
+ const at = hay.indexOf(ch, from);
80
+ if (at < 0) return null;
81
+ positions.push(at);
82
+ // Adjacent characters are worth much more than scattered ones, and a match at a word boundary
83
+ // more still — "gs" should find "git status" ahead of "goals".
84
+ if (at === previous + 1) score += 8;
85
+ if (at === 0 || /[\s/_.\-]/.test(hay[at - 1] ?? "")) score += 6;
86
+ score += 1;
87
+ previous = at;
88
+ from = at + 1;
89
+ }
90
+ // Shorter candidates win ties: an exact short name should beat a long one that contains it.
91
+ score -= Math.floor(hay.length / 32);
92
+ return { score, positions };
93
+ }
94
+
95
+ // ---------------------------------------------------------------------------
96
+ // Picker
97
+ // ---------------------------------------------------------------------------
98
+
99
+ // ---------------------------------------------------------------------------
100
+ // Widget keys
101
+ // ---------------------------------------------------------------------------
102
+
103
+ /**
104
+ * What a key means to a widget.
105
+ *
106
+ * Named actions rather than keys, so a binding is a setting: `ui.keys.next` is a space-separated
107
+ * list in Neovim notation, and adding `<C-j>` to it is one line of config rather than a fork of
108
+ * this file.
109
+ */
110
+ export type WidgetAction =
111
+ | "next"
112
+ | "prev"
113
+ | "page_down"
114
+ | "page_up"
115
+ | "first"
116
+ | "last"
117
+ | "accept"
118
+ | "dismiss"
119
+ | "complete"
120
+ | "clear"
121
+ | "delete_word"
122
+ /** Move to the next pane of a two-pane widget — the provider rail and the list beside it. */
123
+ | "pane_next"
124
+ | "pane_prev";
125
+
126
+ const ACTIONS: WidgetAction[] = [
127
+ "next", "prev", "page_down", "page_up", "first", "last",
128
+ "accept", "dismiss", "complete", "clear", "delete_word",
129
+ "pane_next", "pane_prev",
130
+ ];
131
+
132
+ /** One parsed key: a code kind, the character if it is one, and the modifiers that must match. */
133
+ interface KeySpec {
134
+ kind: string;
135
+ c?: string;
136
+ ctrl: boolean;
137
+ alt: boolean;
138
+ shift: boolean;
139
+ /** The notation it came from, so it can be bound window-scoped as well as matched. */
140
+ lhs: string;
141
+ }
142
+
143
+ /**
144
+ * Parse one key in Neovim notation.
145
+ *
146
+ * The same notation `keymap.set` takes, so what a user writes in `ui.keys.next` looks like every
147
+ * other binding they have written. Unparseable entries are dropped rather than thrown: a typo in a
148
+ * setting should cost you that key, not the picker.
149
+ */
150
+ function parseKey(spec: string): KeySpec | null {
151
+ const trimmed = spec.trim();
152
+ if (trimmed === "") return null;
153
+ if (!trimmed.startsWith("<") || !trimmed.endsWith(">")) {
154
+ return trimmed.length === 1
155
+ ? { kind: "char", c: trimmed, ctrl: false, alt: false, shift: false, lhs: trimmed }
156
+ : null;
157
+ }
158
+ let inner = trimmed.slice(1, -1);
159
+ const out: KeySpec = { kind: "", ctrl: false, alt: false, shift: false, lhs: trimmed };
160
+ for (;;) {
161
+ const m = /^([CSAMD])-/i.exec(inner);
162
+ if (!m) break;
163
+ const which = m[1]!.toUpperCase();
164
+ if (which === "C") out.ctrl = true;
165
+ else if (which === "S") out.shift = true;
166
+ else if (which === "A" || which === "M") out.alt = true;
167
+ inner = inner.slice(2);
168
+ }
169
+ const named: Record<string, string> = {
170
+ cr: "enter", enter: "enter", tab: "tab", bs: "backspace", del: "delete", esc: "esc",
171
+ up: "up", down: "down", left: "left", right: "right", home: "home", end: "end",
172
+ pageup: "page_up", pagedown: "page_down", space: "char", lt: "char", insert: "insert",
173
+ };
174
+ const lower = inner.toLowerCase();
175
+ if (lower in named) {
176
+ out.kind = named[lower]!;
177
+ if (lower === "space") out.c = " ";
178
+ if (lower === "lt") out.c = "<";
179
+ // `<S-Tab>` is not tab with a modifier — a terminal sends a wholly different sequence, which
180
+ // arrives as its own code. Written the way everyone writes it, matched the way it arrives.
181
+ if (out.kind === "tab" && out.shift) {
182
+ out.kind = "back_tab";
183
+ out.shift = false;
184
+ }
185
+ return out;
186
+ }
187
+ if (inner.length === 1) {
188
+ out.kind = "char";
189
+ out.c = inner;
190
+ return out;
191
+ }
192
+ return null;
193
+ }
194
+
195
+ function matches(spec: KeySpec, key: KeyContext["key"]): boolean {
196
+ if (key.code.kind !== spec.kind) return false;
197
+ if (spec.kind === "char") {
198
+ const c = key.code.kind === "char" ? key.code.c : "";
199
+ // Case-insensitively for control chords, because a terminal reports `<C-N>` and `<C-n>`
200
+ // differently depending on whether shift happened to be down.
201
+ if (spec.ctrl ? c.toLowerCase() !== (spec.c ?? "").toLowerCase() : c !== spec.c) return false;
202
+ }
203
+ if (key.mods.ctrl !== spec.ctrl || key.mods.alt !== spec.alt) return false;
204
+ // Shift is only required when asked for: a terminal sets it for capital letters and for nothing
205
+ // else consistently, so demanding its absence would break `<C-N>`.
206
+ return !spec.shift || key.mods.shift;
207
+ }
208
+
209
+ let bindings: Map<WidgetAction, KeySpec[]> | null = null;
210
+
211
+ /**
212
+ * Read the key settings once per session, and again whenever one changes.
213
+ *
214
+ * Cached because a picker asks this on every keystroke and an option read is a round trip to the
215
+ * host. Invalidated by the change event rather than by a timer, so a rebinding takes effect on the
216
+ * next key rather than the next minute.
217
+ */
218
+ async function widgetKeys(neosh: Neosh): Promise<Map<WidgetAction, KeySpec[]>> {
219
+ if (bindings) return bindings;
220
+ const built = new Map<WidgetAction, KeySpec[]>();
221
+ await Promise.all(
222
+ ACTIONS.map(async (action) => {
223
+ const raw = await neosh.opt.get<string>(`ui.keys.${action}`).catch(() => "");
224
+ const specs = (raw ?? "")
225
+ .split(/\s+/)
226
+ .map(parseKey)
227
+ .filter((k): k is KeySpec => k !== null);
228
+ built.set(action, specs);
229
+ }),
230
+ );
231
+ bindings = built;
232
+ return built;
233
+ }
234
+
235
+ let watching = false;
236
+
237
+ /**
238
+ * Start following `ui.keys.*` changes. Idempotent; called by every widget before it opens.
239
+ *
240
+ * The listener is never disposed on purpose — it belongs to the module, not to the widget that
241
+ * happened to open first, and disposing it when that widget closed would leave the cache stale for
242
+ * everyone else.
243
+ */
244
+ function watchKeys(neosh: Neosh): void {
245
+ if (watching) return;
246
+ watching = true;
247
+ neosh.opt.onChange((e) => {
248
+ if (e.name.startsWith("ui.keys.")) bindings = null;
249
+ });
250
+ }
251
+
252
+ /**
253
+ * How to write the key bound to an action, the way a person would press it.
254
+ *
255
+ * Read from the setting rather than hard-coded, so a rebinding shows up in the affordance too —
256
+ * the whole point of putting the key on screen is that it is the key that works.
257
+ */
258
+ function keyLabel(keys: Map<WidgetAction, KeySpec[]>, action: WidgetAction): string {
259
+ const spec = keys.get(action)?.[0];
260
+ if (!spec) return "";
261
+ const pretty: Record<string, string> = {
262
+ "<Tab>": "⇥",
263
+ "<S-Tab>": "⇧⇥",
264
+ "<Left>": "←",
265
+ "<Right>": "→",
266
+ "<Up>": "↑",
267
+ "<Down>": "↓",
268
+ "<CR>": "↵",
269
+ "<Esc>": "esc",
270
+ };
271
+ // `^N`, not `^n`: nobody presses shift to send it, and the capital is how every terminal
272
+ // program has written a chord since curses. The first key of `ui.keys.*` is the one that
273
+ // reaches this — which is why the defaults lead with the chord and keep the arrow behind it.
274
+ return (
275
+ pretty[spec.lhs] ??
276
+ spec.lhs.replace(/^<C-(.)>$/, (_, c: string) => `^${c.toUpperCase()}`).replace(/^<|>$/g, "")
277
+ );
278
+ }
279
+
280
+ /**
281
+ * Which action a key press means, or `null` if it is ordinary input.
282
+ *
283
+ * `only` is the set of actions the asking widget can actually carry out, and it is not an
284
+ * optimisation. Two actions share `<Tab>` on purpose — `complete` in a field with suggestions
285
+ * under it, `pane_next` in a widget with two panes — on the reasoning that no widget has both. It
286
+ * does not have both, but it does *resolve* both: without this filter the first one in `ACTIONS`
287
+ * wins for everybody, and `<Tab>` in the model picker resolved to a completion the picker has no
288
+ * case for, so it silently did nothing at all and the second pane had no way in.
289
+ */
290
+ function actionFor(
291
+ keys: Map<WidgetAction, KeySpec[]>,
292
+ key: KeyContext["key"],
293
+ only?: readonly WidgetAction[],
294
+ ): WidgetAction | null {
295
+ for (const action of only ?? ACTIONS) {
296
+ for (const spec of keys.get(action) ?? []) {
297
+ if (matches(spec, key)) return action;
298
+ }
299
+ }
300
+ return null;
301
+ }
302
+
303
+ /**
304
+ * The key strip a single-pane picker gets unless the caller writes its own.
305
+ *
306
+ * Built from the bindings rather than written out, because the point of putting a key on screen is
307
+ * that it is the key that works — a legend that goes stale the moment somebody rebinds `ui.keys.*`
308
+ * is worse than no legend, since it is believed.
309
+ */
310
+ function defaultHints(keys: Map<WidgetAction, KeySpec[]>, filtering: boolean): string {
311
+ const parts = [
312
+ `${keyLabel(keys, "accept")} choose`,
313
+ `${keyLabel(keys, "prev")}/${keyLabel(keys, "next")} move`,
314
+ ];
315
+ if (filtering) parts.push("type to filter");
316
+ parts.push(`${keyLabel(keys, "dismiss")} close`);
317
+ return parts.join(" ");
318
+ }
319
+
320
+ /** What a two-pane widget answers to. Notably not `complete`, whose key it shares. */
321
+ const RAIL_ACTIONS: readonly WidgetAction[] = [
322
+ "dismiss", "accept", "pane_next", "pane_prev", "next", "prev",
323
+ "page_down", "page_up", "first", "last", "clear", "delete_word",
324
+ ];
325
+
326
+ export interface PickerItem<T> {
327
+ /** What the user reads and what the filter matches against. */
328
+ label: string;
329
+ /** Dimmed, right of the label. A path, a description, a model id. */
330
+ detail?: string;
331
+ /** Extra text the filter should match but that is not shown. */
332
+ keywords?: string;
333
+ /**
334
+ * One column of glyph before the label, coloured by `hl`.
335
+ *
336
+ * What it is for is telling *kinds* of row apart at a glance — a branch from a machine from a
337
+ * directory — in a list where the labels alone read as one undifferentiated column. A picker
338
+ * where only some rows have one still aligns: the ones without get the same gutter, so the
339
+ * labels line up and the icons read as a column rather than as ragged punctuation.
340
+ *
341
+ * **One column.** The runtime cannot measure display width — only `neosh-tui` can — so a
342
+ * two-column emoji here shifts that row's label right by one. Highlighting stays correct
343
+ * regardless, because every offset is computed in bytes from the text actually written.
344
+ */
345
+ icon?: string;
346
+ /**
347
+ * The highlight group for `icon`. A palette name — `Git.Branch`, `Diagnostic.Ok`,
348
+ * `Sidebar.Remote` — never a colour, so a row follows the theme like everything else.
349
+ */
350
+ hl?: string;
351
+ value: T;
352
+ }
353
+
354
+ export interface PickerOptions<T> {
355
+ title?: string;
356
+ /** Shown when the list is empty or nothing matches. */
357
+ placeholder?: string;
358
+ /** Start with the cursor on this index. */
359
+ selected?: number;
360
+ /** Rows of list to show, before the title and filter lines. */
361
+ height?: number;
362
+ width?: number;
363
+ /**
364
+ * Called whenever the highlighted row changes. Use for a live preview — an "intelligence" picker
365
+ * that describes each effort level, a branch picker that shows the tip commit.
366
+ */
367
+ onHighlight?(item: PickerItem<T>, index: number): void;
368
+ /**
369
+ * Where the rows come from, when they depend on what has been typed.
370
+ *
371
+ * With a source, the list is *replaced* on every keystroke instead of fuzzy-filtered — which is
372
+ * what you want when the candidates cannot all be fetched up front: a directory listing, a
373
+ * search, anything that asks something else a question. Without one, `items` is filtered locally.
374
+ *
375
+ * Calls are debounced and the answer is dropped if you have typed again since, so a slow source
376
+ * cannot make the list flicker backwards.
377
+ */
378
+ source?(query: string): Promise<PickerItem<T>[]>;
379
+ /**
380
+ * What the filter starts with.
381
+ *
382
+ * A function is asked at the last possible moment — once the picker is on screen and holds the
383
+ * keyboard — rather than when it was called. That matters for anything completing a field that
384
+ * is still being typed into: opening this takes several round-trips, every keystroke in that
385
+ * window goes to the field, and a picker seeded with what was there *before* them shows an
386
+ * unfiltered list with the wrong row under the accept key. Typing `/compact` quickly and
387
+ * pressing `↵` ran the first command in the list, which was not `compact`.
388
+ */
389
+ query?: string | (() => string);
390
+ /**
391
+ * Whether there is a filter line at all. On by default.
392
+ *
393
+ * Off for a list you pick from rather than search — a yes/no question does not have a text field
394
+ * in it, and drawing one invites the reader to type into something that will not answer.
395
+ */
396
+ filter?: boolean;
397
+ /**
398
+ * What `accept` resolves to when nothing is highlighted, given what was typed.
399
+ *
400
+ * Lets a picker double as a field: a path picker offers completions but must still accept a path
401
+ * you typed in full, which is not one of them.
402
+ */
403
+ freeform?(query: string): T | null;
404
+ /**
405
+ * The key strip at the foot. One line.
406
+ *
407
+ * Written out by default, from the keys actually bound, so a rebinding shows up here rather than
408
+ * making the row a lie. Pass `""` for a picker with nothing worth saying — a two-row yes/no
409
+ * question is not helped by being told that `↵` chooses.
410
+ */
411
+ hints?: string;
412
+ /**
413
+ * Where the float goes. The middle of the screen unless you say otherwise.
414
+ *
415
+ * `{ kind: "dock", dock: "bottom" }` with a negative row offset is how a *completion* is placed:
416
+ * against the message field, above it, rather than over the transcript it has nothing to do with.
417
+ */
418
+ anchor?: FloatOptions["anchor"];
419
+ /** Shifted from the anchor. Negative rows go up. */
420
+ offset?: { row: number; col: number };
421
+ /**
422
+ * How the filter matches.
423
+ *
424
+ * `"fuzzy"` — the default — scores a subsequence match over the label *and* `keywords`, which is
425
+ * what you want in a search box: you are looking for something and half-remember a word from its
426
+ * description.
427
+ *
428
+ * `"name"` matches the start of the label first, then anywhere in it, and never looks at
429
+ * `keywords` at all. That is what a **command menu** needs, and the difference is not cosmetic.
430
+ * Typing `compact` into a fuzzy list of every command matches half of them — `c`…`o`…`m`…`p`…
431
+ * are letters that occur in that order in almost any English sentence, and descriptions are
432
+ * sentences — so the top row is something unrelated and `↵` runs it. A menu whose accept key
433
+ * does an arbitrary thing is worse than one with no matching at all.
434
+ */
435
+ match?: "fuzzy" | "name";
436
+ /**
437
+ * The filter changed.
438
+ *
439
+ * The hook that lets a picker stay in step with something outside itself — a completion that
440
+ * mirrors what is typed back into the composer, so the field says what you typed and dismissing
441
+ * the list leaves it there.
442
+ */
443
+ onQuery?(query: string): void;
444
+ /**
445
+ * Keys `onKey` wants that a *binding elsewhere* would otherwise take.
446
+ *
447
+ * The raw capture only receives what no keymap claimed, so a caller reaching for `<C-x>` gets
448
+ * nothing — that key belongs to the composer. Listing it here binds it to this widget for as long
449
+ * as the widget is open, and drops it again with the window.
450
+ *
451
+ * A picker with a filter should only ever ask for chords. A bare letter taken here is a letter the
452
+ * filter can never contain, which is how a list of conversations ends up unable to search for one
453
+ * with an `x` in its name.
454
+ */
455
+ ownKeys?: readonly string[];
456
+ /**
457
+ * A key the caller wants for itself, checked before the widget's own.
458
+ *
459
+ * Return `"close"` to dismiss, `"reload"` to re-read the rows, `"handled"` to redraw, and nothing
460
+ * at all to let the widget have the key. This is what puts a verb on a row — unarchive, delete,
461
+ * rename — without a second modal in front of the list you were reading.
462
+ *
463
+ * `"reload"` re-runs `source` when there is one, and otherwise re-ranks `items`: the very array
464
+ * you passed in, so a caller that mutates it in place gets a list that has caught up with what it
465
+ * just did. A row acted on is a row that has to leave, and closing the list and opening it again
466
+ * loses your place in it.
467
+ */
468
+ onKey?(
469
+ key: KeyContext,
470
+ ctx: { item: T | undefined; query: string },
471
+ ): Promise<"handled" | "reload" | "close" | undefined> | "handled" | "reload" | "close" | undefined;
472
+ /**
473
+ * Watch something outside the picker, and reload the rows when it moves.
474
+ *
475
+ * Called once as the picker opens; the `Disposable` it returns is disposed with it. Calling
476
+ * `reload` does what `onKey`'s `"reload"` does — re-run `source`, or re-rank `items`, the very
477
+ * array you passed in — for a change no key caused: a peer connecting while the list of
478
+ * computers is open is a row that should change under you, not on the next press.
479
+ *
480
+ * ```ts
481
+ * subscribe: (reload) => neosh.swarm.onChange(reload)
482
+ * ```
483
+ */
484
+ subscribe?(reload: () => void): Disposable;
485
+ }
486
+
487
+ const NS = "neosh.ui.picker";
488
+
489
+ /**
490
+ * The row marker, and its width **in bytes**.
491
+ *
492
+ * `\u276f` is one column wide but three bytes in UTF-8, so the two markers are the same width on
493
+ * screen and different lengths in the buffer. Every mark offset past the marker is computed from
494
+ * the actual prefix rather than from its display width — the exact confusion this API's byte
495
+ * columns exist to prevent.
496
+ */
497
+ const CURSOR_MARKER = "\u276f ";
498
+ const BLANK_MARKER = " ";
499
+
500
+ /**
501
+ * A filterable list in a float. Resolves to the chosen value, or `null` if dismissed.
502
+ *
503
+ * Keys: printable characters filter, `<BS>` deletes, `↑`/`↓` and `<C-p>`/`<C-n>` move, `<CR>`
504
+ * accepts, `<Esc>` dismisses. The float is **modal**, so nothing else reaches the workspace while
505
+ * it is up — `^N` over an open picker used to start a new conversation behind it — bar the keys in
506
+ * `ui.modal_escape_keys`, which is `<C-q>` and `<C-r>` unless you have said otherwise.
507
+ *
508
+ * Only one picker may be open per plugin at a time; opening a second dismisses the first, because
509
+ * two modal lists competing for the keyboard is never what was meant.
510
+ */
511
+ export async function picker<T>(
512
+ neosh: Neosh,
513
+ items: PickerItem<T>[],
514
+ opts: PickerOptions<T> = {},
515
+ ): Promise<T | null> {
516
+ const height = Math.max(1, opts.height ?? 12);
517
+ const width = Math.max(20, opts.width ?? 64);
518
+ const filtering = opts.filter !== false;
519
+
520
+ const buf = await neosh.buf.create({ name: `[${opts.title ?? "picker"}]`, scratch: true, kind: KIND_PICKER });
521
+ const ns = await neosh.ns.create(NS);
522
+
523
+ watchKeys(neosh);
524
+ const keys = await widgetKeys(neosh);
525
+ // A picker with no keys on it is a modal you have to guess your way out of. Every one of these
526
+ // is a list you arrived at by pressing something, and none of them said what to press next.
527
+ const hints = opts.hints ?? defaultHints(keys, filtering);
528
+
529
+ const win = await neosh.float.open(buf, {
530
+ anchor: opts.anchor ?? { kind: "screen" },
531
+ offset: opts.offset,
532
+ width: { kind: "fixed", n: width },
533
+ // Exactly the rows that get drawn: the list, plus a filter line when there is one, a title
534
+ // when there is one, and the key strip unless it was waived.
535
+ height: {
536
+ kind: "fixed",
537
+ n: height + (filtering ? 1 : 0) + (opts.title ? 1 : 0) + (hints === "" ? 0 : 1),
538
+ },
539
+ border: "rounded",
540
+ focusable: true,
541
+ closeOnBlur: true,
542
+ // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
543
+ // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
544
+ // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
545
+ // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
546
+ // fails to bind a way out is never a terminal somebody has to kill: see
547
+ // `ui.modal_escape_keys`.
548
+ modal: true,
549
+ z: 200,
550
+ });
551
+
552
+ let query = typeof opts.query === "string" ? opts.query : "";
553
+ let cursor = Math.min(Math.max(0, opts.selected ?? 0), Math.max(0, items.length - 1));
554
+ let visible = items.map((item, index) => ({ item, index, positions: [] as number[] }));
555
+ let top = 0;
556
+
557
+ const rank = (pool: PickerItem<T>[], q: string) => {
558
+ const scored = pool
559
+ .map((item, index) => {
560
+ if (opts.match === "name") return byName(item, index, q);
561
+ const hay = item.keywords ? `${item.label} ${item.keywords}` : item.label;
562
+ const m = fuzzy(hay, q);
563
+ return m ? { item, index, positions: m.positions, score: m.score } : null;
564
+ })
565
+ .filter((x): x is NonNullable<typeof x> => x !== null);
566
+ // Stable within equal scores: the original order carries meaning (recency, configured order),
567
+ // and re-sorting it away makes the list jump for no visible reason.
568
+ scored.sort((a, b) => b.score - a.score || a.index - b.index);
569
+ return scored;
570
+ };
571
+
572
+ const refilter = () => {
573
+ visible = rank(items, query);
574
+ cursor = Math.min(cursor, Math.max(0, visible.length - 1));
575
+ };
576
+
577
+ /**
578
+ * A row matched by the start of its name, then by any part of it.
579
+ *
580
+ * Two tiers and nothing else, because the whole point of this mode is that it is *predictable*:
581
+ * you can tell from what you have typed which row you are about to accept. Longer names score
582
+ * lower within a tier so an exact `git.diff` outranks `git.diff.staged`, and the highlighted
583
+ * positions are the run that matched, which is where the eye is already looking.
584
+ */
585
+ function byName(item: PickerItem<T>, index: number, q: string) {
586
+ if (!q) return { item, index, positions: [] as number[], score: 0 };
587
+ const label = item.label.toLowerCase();
588
+ const needle = q.toLowerCase();
589
+ const at = label.indexOf(needle);
590
+ if (at < 0) return null;
591
+ const positions = Array.from({ length: needle.length }, (_, k) => at + k);
592
+ return { item, index, positions, score: (at === 0 ? 1000 : 500) - label.length };
593
+ }
594
+
595
+ // Which fetch is current. A slow source answering after you have typed again would replace a
596
+ // newer list with an older one, and the row under your finger would change out from under it.
597
+ let generation = 0;
598
+ // The fetch in flight, so accepting can wait for it. Typing faster than the source answers is
599
+ // ordinary — anyone pasting a path does it — and taking the row that was under the cursor two
600
+ // keystrokes ago is not a near miss, it is a different directory.
601
+ let inflight: Promise<void> = Promise.resolve();
602
+ const refetch = () => {
603
+ if (!opts.source) {
604
+ refilter();
605
+ return inflight;
606
+ }
607
+ const mine = ++generation;
608
+ inflight = opts
609
+ .source(query)
610
+ .catch(() => [] as PickerItem<T>[])
611
+ .then((fetched) => {
612
+ if (mine !== generation) return;
613
+ // Already the answer to this query, so ranked against nothing rather than filtered twice.
614
+ visible = fetched.map((item, index) => ({ item, index, positions: [] as number[] }));
615
+ cursor = Math.min(cursor, Math.max(0, visible.length - 1));
616
+ });
617
+ return inflight;
618
+ };
619
+
620
+ const render = async () => {
621
+ // A burst of keys — anyone typing at speed, or a paste — arrives as several invocations of the
622
+ // key command at once, and any of them may be the one that accepts and closes. Whichever runs
623
+ // next would then be drawing into a window that is gone, which is not an error worth reporting
624
+ // to the user: it is this widget racing itself.
625
+ if (closed) return;
626
+ // Keep the cursor on screen without recentring on every keystroke.
627
+ if (cursor < top) top = cursor;
628
+ if (cursor >= top + height) top = cursor - height + 1;
629
+
630
+ const lines: string[] = [];
631
+ if (opts.title) lines.push(opts.title);
632
+ if (filtering) lines.push(`> ${query}`);
633
+
634
+ // The width the float was asked for *is* the width of its text: a border is drawn outside it,
635
+ // which is why `columnWidth` subtracts one from each side before opening a panel as wide as the
636
+ // dock. Measuring against anything else puts the last visible character on both lines — the
637
+ // continuation says `/ finds` under a row that already ended in `/`.
638
+ const inner = Math.max(8, width);
639
+ let window = visible.slice(top, top + height);
640
+ // One gutter for the whole list, or none at all. Giving it only to the rows that asked for an
641
+ // icon would step every other label one column left, which reads as a list that cannot decide
642
+ // where its left margin is.
643
+ const gutter = window.some((r) => r.item.icon) ? 2 : 0;
644
+ const iconFor = (item: PickerItem<T>) =>
645
+ gutter === 0 ? "" : item.icon ? `${item.icon} ` : " ";
646
+ const textOf = (row: typeof window[number], on: boolean) =>
647
+ `${on ? "❯ " : " "}${iconFor(row.item)}${row.item.label}${row.item.detail ? ` ${row.item.detail}` : ""}`;
648
+
649
+ // The row under the cursor says all of itself. A picker is a list of things you are choosing
650
+ // between, and two rows whose difference is past the right edge are two rows you cannot choose
651
+ // between — which is exactly what a long model id, a path, or a description does here. The
652
+ // rest goes underneath, only while the cursor is on it, so the list is still a list.
653
+ const on = visible[cursor];
654
+ /** The cursor row's first line, which is re-broken at a word when the row has to continue. */
655
+ let firstLine = on ? textOf(on, true) : "";
656
+ let rest: string[] = [];
657
+ if (on && columnsOf(firstLine) > inner) {
658
+ const head = `❯ ${iconFor(on.item)}${on.item.label}`;
659
+ const detail = on.item.detail ? ` ${on.item.detail}` : "";
660
+ // Only the detail is re-broken. The label is what the filter matched and what the match
661
+ // highlight is measured against, so it stays exactly where it was written; a row whose label
662
+ // alone overflows falls back to continuing from wherever the edge cut it.
663
+ if (detail !== "" && columnsOf(head) + 8 < inner) {
664
+ const [take, remainder] = takeWords(detail, inner - columnsOf(head));
665
+ firstLine = `${head}${take}`;
666
+ rest = remainder.trim() === "" ? [] : wrapToWidth(remainder.trim(), inner - 4);
667
+ } else {
668
+ rest = overflowOf(clipToWidth(firstLine, inner), firstLine, inner - 4);
669
+ }
670
+ }
671
+ // The unfolded row pays for its own continuation out of the list's rows rather than out of the
672
+ // float's height: the window was sized for `height` lines and growing past it would push the
673
+ // key strip off the bottom, which is the row that says how to get out.
674
+ if (rest.length > 0) {
675
+ const room = Math.max(1, height - rest.length);
676
+ if (cursor < top) top = cursor;
677
+ if (cursor >= top + room) top = cursor - room + 1;
678
+ window = visible.slice(top, top + room);
679
+ }
680
+
681
+ if (window.length === 0) {
682
+ lines.push(` ${opts.placeholder ?? "no matches"}`);
683
+ }
684
+ /** Which of `lines` each visible row starts on, relative to the first list row. */
685
+ const lineFor: number[] = [];
686
+ const firstListLine = lines.length;
687
+ for (const row of window) {
688
+ const isCursor = row.index === visible[cursor]?.index;
689
+ lineFor.push(lines.length - firstListLine);
690
+ lines.push(isCursor ? firstLine : textOf(row, false));
691
+ if (!isCursor) continue;
692
+ for (const line of rest) lines.push(` ${line}`);
693
+ }
694
+ // Pushed onto the last row rather than floated: the float is sized for it, and a strip that
695
+ // moved up as the list shortened would be a strip you have to look for.
696
+ //
697
+ // The placeholder takes a row of the list's own space, so an empty list has used one of the
698
+ // `height` rows and not none. Counting it as none put the strip one row past the bottom of a
699
+ // float sized for exactly `height`, where it was silently clipped — leaving the empty state,
700
+ // the one state where you most want to be told what the keys do, as the only one with no keys
701
+ // on it.
702
+ // Lines, not rows: the row under the cursor is more than one of them when it has unfolded, and
703
+ // counting rows here would leave the strip that many lines low — off the bottom of a float
704
+ // sized for exactly `height`.
705
+ const listRows = Math.max(1, lines.length - firstListLine);
706
+ const hintLine = hints === "" ? -1 : lines.length + Math.max(0, height - listRows);
707
+ if (hintLine >= 0) {
708
+ while (lines.length < hintLine) lines.push("");
709
+ lines.push(` ${hints}`);
710
+ }
711
+ // Marks are collected against their row and handed over with the text, in one call. Set one at
712
+ // a time they were a sequence the frontend could draw the middle of: the moment the clear had
713
+ // landed and the marks had not, every row drew unmarked — in `Normal`, which is near-white.
714
+ const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] }));
715
+ const mark = (line: number, col: number, o: MarkOptions) => {
716
+ drawn[line]?.marks!.push({ col, opts: o });
717
+ };
718
+
719
+ if (hintLine >= 0) {
720
+ mark(hintLine, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[hintLine] ?? "") });
721
+ }
722
+ const listTop = (opts.title ? 1 : 0) + (filtering ? 1 : 0);
723
+ for (let i = 0; i < window.length; i++) {
724
+ const row = window[i]!;
725
+ const line = listTop + (lineFor[i] ?? i);
726
+ const isCursor = row.index === visible[cursor]?.index;
727
+ const eol = byteLength(lines[line] ?? "");
728
+ if (isCursor) {
729
+ mark(line, 0, { hlGroup: "Picker.Selected", endCol: eol });
730
+ // The band covers the continuation too, so an unfolded row reads as one row that is
731
+ // several lines tall rather than as a selected row with loose text under it.
732
+ for (let k = 1; k <= rest.length; k++) {
733
+ mark(line + k, 0, { hlGroup: "Picker.Selected", endCol: byteLength(lines[line + k] ?? "") });
734
+ }
735
+ }
736
+ const icon = iconFor(row.item);
737
+ const marker = byteLength(isCursor ? CURSOR_MARKER : BLANK_MARKER);
738
+ // The icon is painted before the match runs, and at no priority: a match highlight over the
739
+ // label is the thing the eye is looking for, and nothing here should be able to outrank it.
740
+ if (row.item.icon && row.item.hl) {
741
+ mark(line, marker, {
742
+ hlGroup: row.item.hl,
743
+ endCol: marker + byteLength(row.item.icon),
744
+ });
745
+ }
746
+ const prefix = marker + byteLength(icon);
747
+ const offsets = byteOffsets(row.item.label);
748
+ for (const at of row.positions) {
749
+ const start = offsets[at];
750
+ const end = offsets[at + 1];
751
+ // A match position past the label came from `keywords`, which is filtered on but not
752
+ // shown; there is nothing on screen to highlight.
753
+ if (start === undefined || end === undefined) continue;
754
+ mark(line, prefix + start, {
755
+ hlGroup: "Picker.Match",
756
+ endCol: prefix + end,
757
+ priority: 200,
758
+ });
759
+ }
760
+ if (row.item.detail) {
761
+ mark(line, prefix + byteLength(row.item.label), {
762
+ hlGroup: "Picker.Detail",
763
+ endCol: eol,
764
+ });
765
+ }
766
+ }
767
+ if (opts.title) {
768
+ mark(0, 0, { hlGroup: "Float.Title", endCol: byteLength(opts.title) });
769
+ }
770
+ await neosh.buf.render(buf, ns, 0, -1, drawn);
771
+
772
+ // The caret goes where you are typing. Without this the terminal cursor stays parked at the
773
+ // top-left of the float and the field reads as inert — you type and nothing appears to be
774
+ // listening, even though the text is right there. With no filter there is nowhere to type, so
775
+ // it marks the row instead.
776
+ await neosh.win.setCursor(
777
+ win,
778
+ filtering ? (opts.title ? 1 : 0) : listTop + (lineFor[cursor - top] ?? cursor - top),
779
+ filtering ? byteLength(`> ${query}`) : 0,
780
+ );
781
+ };
782
+
783
+ let settle: (v: T | null) => void = () => {};
784
+ const done = new Promise<T | null>((resolve) => {
785
+ settle = resolve;
786
+ });
787
+
788
+ const command = `${NS}.key.${++pickerSeq}`;
789
+ const disposers: Disposable[] = [];
790
+ let closed = false;
791
+ const close = async (value: T | null) => {
792
+ if (closed) return;
793
+ closed = true;
794
+ for (const d of disposers) d.dispose();
795
+ await neosh.win.close(win).catch(() => {});
796
+ settle(value);
797
+ };
798
+
799
+ const last = () => Math.max(0, visible.length - 1);
800
+
801
+ /**
802
+ * Change the filter, and tell whoever is watching.
803
+ *
804
+ * One place, because four separate cases used to do it themselves and each one had to remember
805
+ * the refetch. A completion also has to say so outwards, so the composer it is completing keeps
806
+ * showing what has been typed.
807
+ */
808
+ const retype = async (next: string) => {
809
+ query = next;
810
+ await refetch();
811
+ opts.onQuery?.(query);
812
+ };
813
+
814
+ if (opts.subscribe) {
815
+ disposers.push(
816
+ opts.subscribe(() => {
817
+ if (closed) return;
818
+ void (async () => {
819
+ await refetch();
820
+ if (!closed) await render();
821
+ })().catch(() => {});
822
+ }),
823
+ );
824
+ }
825
+
826
+ disposers.push(
827
+ await neosh.cmd.register(command, async (_args, key) => {
828
+ if (!key || closed) return;
829
+ const before = cursor;
830
+ // The caller first: a verb it put on a key is a verb about the row under the cursor, and the
831
+ // widget's own handling of that key would be about the filter.
832
+ const outcome = await opts.onKey?.(key, { item: visible[cursor]?.item.value, query });
833
+ if (closed) return;
834
+ if (outcome === "close") {
835
+ await close(null);
836
+ return;
837
+ }
838
+ if (outcome === "reload") {
839
+ await refetch();
840
+ await render();
841
+ return;
842
+ }
843
+ if (outcome === "handled") {
844
+ await render();
845
+ return;
846
+ }
847
+ // Settings next, so a rebound key wins over what the widget would otherwise do with it.
848
+ const action = actionFor(keys, key.key);
849
+ switch (action) {
850
+ case "dismiss":
851
+ await close(null);
852
+ return;
853
+ case "accept": {
854
+ // Settle whatever is in flight first, so this takes the row for what is actually typed.
855
+ await inflight;
856
+ const chosen = visible[cursor];
857
+ if (chosen) {
858
+ await close(chosen.item.value);
859
+ return;
860
+ }
861
+ // Nothing highlighted: a picker that is also a field accepts what you typed.
862
+ await close(opts.freeform?.(query) ?? null);
863
+ return;
864
+ }
865
+ case "next":
866
+ cursor = Math.min(last(), cursor + 1);
867
+ break;
868
+ case "prev":
869
+ cursor = Math.max(0, cursor - 1);
870
+ break;
871
+ case "page_down":
872
+ cursor = Math.min(last(), cursor + height);
873
+ break;
874
+ case "page_up":
875
+ cursor = Math.max(0, cursor - height);
876
+ break;
877
+ case "first":
878
+ cursor = 0;
879
+ break;
880
+ case "last":
881
+ cursor = last();
882
+ break;
883
+ case "clear":
884
+ await retype("");
885
+ break;
886
+ case "delete_word":
887
+ await retype(dropSegment(query));
888
+ break;
889
+ case "complete": {
890
+ // Take the highlighted row into the field without accepting it — how you walk into a
891
+ // directory one segment at a time.
892
+ await inflight;
893
+ const chosen = visible[cursor];
894
+ if (!chosen || !opts.freeform) return;
895
+ cursor = 0;
896
+ await retype(chosen.item.label);
897
+ break;
898
+ }
899
+ default: {
900
+ if (!filtering) return;
901
+ if (key.key.code.kind === "backspace") {
902
+ // Backspacing off the end of an empty filter dismisses, so a completion feels like
903
+ // part of the field rather than a window in front of it. Only where there is something
904
+ // outside to fall back to — a picker with nowhere to put the keystroke keeps it.
905
+ if (!query && opts.onQuery) {
906
+ await close(null);
907
+ return;
908
+ }
909
+ await retype(query.slice(0, -1));
910
+ break;
911
+ }
912
+ if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
913
+ cursor = 0;
914
+ await retype(query + key.key.code.c);
915
+ break;
916
+ }
917
+ }
918
+ await render();
919
+ if (cursor !== before && opts.onHighlight) {
920
+ const row = visible[cursor];
921
+ if (row) opts.onHighlight(row.item, row.index);
922
+ }
923
+ }, { desc: "picker key" }),
924
+ );
925
+
926
+ await neosh.focus.push(win);
927
+ disposers.push(await neosh.keymap.capture(win, command));
928
+ // `<C-c>` is bound globally to `interrupt`, which would arm "press again to quit" while a picker
929
+ // is on screen — the one moment the user obviously meant "close this". A window-scoped binding
930
+ // outranks the global one and is dropped with the window.
931
+ await bindWidgetKeys(neosh, win, command, keys, opts.ownKeys ?? []);
932
+ // Now, and not before: everything typed while this was being built went somewhere, and if it
933
+ // went into the field this is completing then it is part of the query. Asked after the keyboard
934
+ // is ours, so there is no further keystroke to miss.
935
+ if (typeof opts.query === "function") query = opts.query();
936
+ await refetch();
937
+ await render();
938
+ if (opts.onHighlight) {
939
+ const row = visible[cursor];
940
+ if (row) opts.onHighlight(row.item, row.index);
941
+ }
942
+
943
+ return done;
944
+ }
945
+
946
+ let pickerSeq = 0;
947
+
948
+ /**
949
+ * Delete one word, treating a path separator as a boundary.
950
+ *
951
+ * `/home/me/src/` becomes `/home/me/`, not `/home/me/src`. Deleting the separator and stopping
952
+ * would mean pressing the key twice per directory, which is not what anyone means by "back one".
953
+ */
954
+ function dropSegment(text: string): string {
955
+ const trimmed = text.replace(/[\s/]+$/, "");
956
+ const at = Math.max(trimmed.lastIndexOf("/"), trimmed.lastIndexOf(" "));
957
+ return at < 0 ? "" : trimmed.slice(0, at + 1);
958
+ }
959
+
960
+ // ---------------------------------------------------------------------------
961
+ // Derived widgets
962
+ // ---------------------------------------------------------------------------
963
+
964
+ export interface ConfirmOptions {
965
+ /** What the affirming answer says. A verb — `Delete`, `Remove`, `Sign out` — never `OK`. */
966
+ yes?: string;
967
+ /** What the answer that changes nothing says. */
968
+ no?: string;
969
+ /**
970
+ * Under the question, dimmed: what exactly is at stake, and what to do instead.
971
+ *
972
+ * The part that makes a dialog worth stopping for. "Are you sure?" is a speed bump you learn to
973
+ * clear without reading; "4 messages, in neosh · main" and "archiving keeps it" is a question you
974
+ * can answer without leaving the dialog to go and check.
975
+ */
976
+ detail?: string[];
977
+ /**
978
+ * The affirming answer cannot be undone.
979
+ *
980
+ * Starts on the answer that changes nothing — `<CR>` is reflex by the second time you have seen a
981
+ * dialog, and a reflex must not delete anything — and draws the other one in the theme's error
982
+ * colour, so the row that destroys something does not look like the row beside it.
983
+ */
984
+ dangerous?: boolean;
985
+ width?: number;
986
+ }
987
+
988
+ const CONFIRM_NS = "neosh.ui.confirm";
989
+
990
+ /**
991
+ * Ask a yes/no question, and mean it. Resolves `false` when dismissed.
992
+ *
993
+ * A dialog rather than a two-row picker, because the picker's title is one clipped line and the
994
+ * question is the whole point: what is about to happen, to what, and what the alternative is. It
995
+ * wraps, it carries `detail`, and when the answer is destructive it says so in colour rather than
996
+ * relying on you to read a verb.
997
+ *
998
+ * `y` and `n` answer it outright, `↑`/`↓` and `j`/`k` move between the two, `<CR>` takes the one
999
+ * under the cursor, and `<Esc>` — like every other float in the workspace — means no.
1000
+ */
1001
+ export async function confirm(
1002
+ neosh: Neosh,
1003
+ question: string,
1004
+ opts: ConfirmOptions = {},
1005
+ ): Promise<boolean> {
1006
+ const yes = opts.yes ?? "Yes";
1007
+ const no = opts.no ?? "No";
1008
+ const width = Math.min(78, Math.max(36, opts.width ?? 58));
1009
+ const asked = wrapText(question, width - 2);
1010
+ const detail = (opts.detail ?? []).flatMap((d) => wrapText(d, width - 2));
1011
+ const answers = [yes, no];
1012
+ const strip = clipTo(
1013
+ `y ${yes.toLowerCase()} n ${no.toLowerCase()} ↵ choose esc cancel`,
1014
+ width - 2,
1015
+ );
1016
+
1017
+ // On the answer that changes nothing, when the other one cannot be taken back.
1018
+ let cursor = opts.dangerous ? 1 : 0;
1019
+
1020
+ const buf = await neosh.buf.create({ name: "[confirm]", scratch: true, kind: KIND_CONFIRM });
1021
+ const ns = await neosh.ns.create(CONFIRM_NS);
1022
+
1023
+ watchKeys(neosh);
1024
+ const keys = await widgetKeys(neosh);
1025
+
1026
+ /** Where each part of the dialog starts, so the marks do not have to count rows twice. */
1027
+ const detailAt = asked.length + 1;
1028
+ const answersAt = detailAt + (detail.length === 0 ? 0 : detail.length + 1);
1029
+ const stripAt = answersAt + answers.length + 1;
1030
+
1031
+ const win = await neosh.float.open(buf, {
1032
+ anchor: { kind: "screen" },
1033
+ width: { kind: "fixed", n: width },
1034
+ height: { kind: "fixed", n: stripAt + 1 },
1035
+ border: "rounded",
1036
+ focusable: true,
1037
+ closeOnBlur: true,
1038
+ // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
1039
+ // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
1040
+ // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
1041
+ // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
1042
+ // fails to bind a way out is never a terminal somebody has to kill: see
1043
+ // `ui.modal_escape_keys`.
1044
+ modal: true,
1045
+ // Above a picker: this is asked *from* one often enough that being drawn under it would make
1046
+ // the workspace look wedged.
1047
+ z: 260,
1048
+ });
1049
+
1050
+ const render = async () => {
1051
+ const lines: string[] = asked.map((l) => ` ${l}`);
1052
+ lines.push("");
1053
+ if (detail.length > 0) {
1054
+ lines.push(...detail.map((l) => ` ${l}`));
1055
+ lines.push("");
1056
+ }
1057
+ answers.forEach((a, i) => lines.push(`${i === cursor ? `${CURSOR_MARKER}` : BLANK_MARKER}${a}`));
1058
+ lines.push("");
1059
+ lines.push(` ${strip}`);
1060
+ // Text and marks together, in one call: this is redrawn on every keystroke, and a repaint the
1061
+ // frontend can draw the middle of is one that flashes — unmarked rows draw in `Normal`. It also
1062
+ // takes care of the older half of the same bug, that a mark whose line was replaced under it
1063
+ // clamps rather than dies, so rows would end up wearing the colours of the ones before.
1064
+ const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] }));
1065
+ const mark = (line: number, col: number, o: MarkOptions) => {
1066
+ drawn[line]?.marks!.push({ col, opts: o });
1067
+ };
1068
+
1069
+ for (let i = 0; i < asked.length; i++) {
1070
+ mark(i, 0, { hlGroup: "Title", endCol: byteLength(lines[i] ?? "") });
1071
+ }
1072
+ for (let i = 0; i < detail.length; i++) {
1073
+ mark(detailAt + i, 0, {
1074
+ hlGroup: "Comment",
1075
+ endCol: byteLength(lines[detailAt + i] ?? ""),
1076
+ });
1077
+ }
1078
+ for (let i = 0; i < answers.length; i++) {
1079
+ const line = answersAt + i;
1080
+ // The band is the row's *background* rather than a group across its bytes, so the answer keeps
1081
+ // whatever colour said what it was. A ranged group here would leave the destructive row
1082
+ // looking exactly like the safe one for as long as the cursor is on it.
1083
+ if (i === cursor) {
1084
+ mark(line, 0, { lineHlGroup: "Picker.Selected" });
1085
+ }
1086
+ if (opts.dangerous && i === 0) {
1087
+ mark(line, byteLength(BLANK_MARKER), {
1088
+ hlGroup: "Diagnostic.Error",
1089
+ endCol: byteLength(lines[line] ?? ""),
1090
+ });
1091
+ }
1092
+ }
1093
+ mark(stripAt, 0, { hlGroup: "Sidebar.Dim", endCol: byteLength(lines[stripAt] ?? "") });
1094
+ await neosh.buf.render(buf, ns, 0, -1, drawn);
1095
+ // The caret marks the answer, since there is nothing here to type into.
1096
+ await neosh.win.setCursor(win, answersAt + cursor, 0);
1097
+ };
1098
+
1099
+ let settle: (v: boolean) => void = () => {};
1100
+ const done = new Promise<boolean>((resolve) => {
1101
+ settle = resolve;
1102
+ });
1103
+
1104
+ const command = `${CONFIRM_NS}.key.${++pickerSeq}`;
1105
+ const disposers: Disposable[] = [];
1106
+ let closed = false;
1107
+ const close = async (value: boolean) => {
1108
+ if (closed) return;
1109
+ closed = true;
1110
+ for (const d of disposers) d.dispose();
1111
+ await neosh.win.close(win).catch(() => {});
1112
+ settle(value);
1113
+ };
1114
+
1115
+ disposers.push(
1116
+ await neosh.cmd.register(command, async (_args, key) => {
1117
+ if (!key || closed) return;
1118
+ switch (actionFor(keys, key.key)) {
1119
+ case "dismiss":
1120
+ await close(false);
1121
+ return;
1122
+ case "accept":
1123
+ await close(cursor === 0);
1124
+ return;
1125
+ case "next":
1126
+ case "last":
1127
+ cursor = answers.length - 1;
1128
+ break;
1129
+ case "prev":
1130
+ case "first":
1131
+ cursor = 0;
1132
+ break;
1133
+ default: {
1134
+ if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
1135
+ switch (key.key.code.c.toLowerCase()) {
1136
+ // Answering outright, which is what anyone who has read the question wants. Nothing here
1137
+ // filters, so the letters are free — and `y`/`n` are the two nobody has to be told.
1138
+ case "y":
1139
+ await close(true);
1140
+ return;
1141
+ case "n":
1142
+ case "q":
1143
+ await close(false);
1144
+ return;
1145
+ case "j":
1146
+ cursor = answers.length - 1;
1147
+ break;
1148
+ case "k":
1149
+ cursor = 0;
1150
+ break;
1151
+ default:
1152
+ return;
1153
+ }
1154
+ }
1155
+ }
1156
+ await render();
1157
+ }, { desc: "confirm key" }),
1158
+ );
1159
+
1160
+ await neosh.focus.push(win);
1161
+ disposers.push(await neosh.keymap.capture(win, command));
1162
+ await bindWidgetKeys(neosh, win, command, keys);
1163
+ await render();
1164
+ return done;
1165
+ }
1166
+
1167
+ /**
1168
+ * Ask before something that cannot be undone — unless the user has said not to.
1169
+ *
1170
+ * One place, so `ui.confirm_destructive` means the same thing everywhere and a plugin does not have
1171
+ * to know the option exists. The bar is *irreversible*, not merely significant: closing a panel,
1172
+ * switching a model or archiving a conversation asks nothing, because you can put those back. A
1173
+ * dialog charged for a reversible action is what teaches people to clear dialogs without reading
1174
+ * them, which is how the one that matters stops working.
1175
+ */
1176
+ export async function confirmDestructive(
1177
+ neosh: Neosh,
1178
+ question: string,
1179
+ opts: Omit<ConfirmOptions, "dangerous"> = {},
1180
+ ): Promise<boolean> {
1181
+ const ask = (await neosh.opt.get<boolean>("ui.confirm_destructive").catch(() => true)) ?? true;
1182
+ if (!ask) return true;
1183
+ return confirm(neosh, question, { ...opts, dangerous: true });
1184
+ }
1185
+
1186
+ /**
1187
+ * Break text into lines of at most `width` characters.
1188
+ *
1189
+ * Counted in characters and not columns: measuring display width is the frontend's job, and this
1190
+ * feeds a float that is sized with a column to spare. A word longer than the line is left long and
1191
+ * clipped where it is drawn, by the one thing that knows where its own edge is.
1192
+ */
1193
+ function wrapText(text: string, width: number): string[] {
1194
+ const out: string[] = [];
1195
+ for (const paragraph of text.split("\n")) {
1196
+ let line = "";
1197
+ for (const word of paragraph.split(/\s+/).filter((w) => w !== "")) {
1198
+ const next = line === "" ? word : `${line} ${word}`;
1199
+ if (line !== "" && Array.from(next).length > width) {
1200
+ out.push(line);
1201
+ line = word;
1202
+ } else {
1203
+ line = next;
1204
+ }
1205
+ }
1206
+ out.push(line);
1207
+ }
1208
+ return out;
1209
+ }
1210
+
1211
+ function clipTo(text: string, width: number): string {
1212
+ const chars = Array.from(text);
1213
+ return chars.length <= width ? text : `${chars.slice(0, Math.max(1, width - 1)).join("")}…`;
1214
+ }
1215
+
1216
+ // ---------------------------------------------------------------------------
1217
+ // Showing the rest of a row
1218
+ // ---------------------------------------------------------------------------
1219
+
1220
+ /**
1221
+ * Break `text` into lines of at most `columns` **display columns**, splitting words that are wider
1222
+ * than the column rather than letting them overflow it.
1223
+ *
1224
+ * The difference from {@link wrapText} is the whole reason both exist. That one counts code points
1225
+ * and leaves a long word long, because it feeds a float that is sized with a column to spare and
1226
+ * the frontend clips the overhang. This one feeds a *column of a row* — a description beside a
1227
+ * label, a continuation under a title — where an overhanging word does not get clipped, it pushes
1228
+ * whatever is to its right off the edge and takes the alignment of every row below it with it. A
1229
+ * URL, a path or a `--flag=value` is routinely wider than the column it lands in, so this is the
1230
+ * common case and not the exotic one.
1231
+ *
1232
+ * Measured with {@link width}, which is the op the renderer itself uses — so a CJK label and an
1233
+ * ASCII one wrap at the same visual place.
1234
+ */
1235
+ export function wrapToWidth(text: string, columns: number): string[] {
1236
+ const limit = Math.max(1, Math.floor(columns));
1237
+ const out: string[] = [];
1238
+ for (const paragraph of text.split("\n")) {
1239
+ let line = "";
1240
+ for (const word of paragraph.split(/\s+/).filter((w) => w !== "")) {
1241
+ const next = line === "" ? word : `${line} ${word}`;
1242
+ if (width(next) <= limit) {
1243
+ line = next;
1244
+ continue;
1245
+ }
1246
+ if (line !== "") {
1247
+ out.push(line);
1248
+ line = "";
1249
+ }
1250
+ // A word that cannot fit a line of its own is cut across as many as it needs. Cutting is
1251
+ // right here and wrong in prose: this is an identifier, not a sentence, and the alternative
1252
+ // is not a nicer break but a row that is silently wider than its column.
1253
+ let rest = word;
1254
+ while (width(rest) > limit) {
1255
+ const head = clipToWidth(rest, limit);
1256
+ if (head === "") break;
1257
+ out.push(head);
1258
+ rest = rest.slice(head.length);
1259
+ }
1260
+ line = rest;
1261
+ }
1262
+ out.push(line);
1263
+ }
1264
+ return out.length > 0 ? out : [""];
1265
+ }
1266
+
1267
+ /**
1268
+ * The part of `full` that `shown` did not get to say, as lines of `columns` columns.
1269
+ *
1270
+ * `shown` is the row as it was actually written — clipped, and ending in an ellipsis if it was.
1271
+ * The ellipsis is dropped before comparing, so what comes back starts exactly where the visible
1272
+ * text stopped, and the two read as one sentence broken across a line.
1273
+ *
1274
+ * Empty when nothing was lost, which is the common case and has to cost nothing: a list of short
1275
+ * rows must not grow a blank line under the cursor.
1276
+ */
1277
+ export function overflowOf(shown: string, full: string, columns: number): string[] {
1278
+ if (full === "" || columns < 1) return [];
1279
+ // Everything before the ellipsis is what actually reached the screen. What comes *after* it is
1280
+ // decoration that survived the clip — an unread dot, a count, a state glyph pinned to the end of
1281
+ // the row — and it is not part of the text, so it is neither compared against `full` nor said
1282
+ // again underneath.
1283
+ const cut = shown.lastIndexOf("…");
1284
+ const visible = cut >= 0 ? shown.slice(0, cut) : shown;
1285
+ // Nothing was lost. The common case, and it has to cost nothing: a list of short rows must not
1286
+ // grow a blank line under the cursor.
1287
+ if (visible.startsWith(full)) return [];
1288
+ // A `full` that is not a continuation of what is on screen means the caller built the two
1289
+ // differently rather than by clipping one from the other. The whole of it is then the honest
1290
+ // thing to show, because there is no prefix to trust.
1291
+ const rest = full.startsWith(visible) ? full.slice(visible.length) : full;
1292
+ if (rest.trim() === "") return [];
1293
+ return wrapToWidth(rest.replace(/^\s+/, ""), columns);
1294
+ }
1295
+
1296
+ /**
1297
+ * As much of `text` as fits `columns` **without breaking a word**, and the rest of it.
1298
+ *
1299
+ * What a row's first line needs. {@link overflowOf} continues from wherever the clip landed, which
1300
+ * is right when the clip is somebody else's — the row is already drawn and ends in an ellipsis that
1301
+ * says so. When the row is *ours* to lay out, breaking `work` into `wor` and `k` is a choice, and a
1302
+ * bad one: the eye stops at the ragged edge and the reader spends a beat rejoining a word instead of
1303
+ * reading the sentence.
1304
+ *
1305
+ * A single word wider than the column is cut anyway. There is nothing else to do with it, and the
1306
+ * alternative — a first line left empty — is worse.
1307
+ */
1308
+ export function takeWords(text: string, columns: number): [string, string] {
1309
+ const limit = Math.max(1, Math.floor(columns));
1310
+ if (width(text) <= limit) return [text, ""];
1311
+ // Split keeping the separators, so what is left over is the exact remainder of the original
1312
+ // rather than a rejoin of it: two spaces after a full stop stay two spaces.
1313
+ const parts = text.split(/(\s+)/);
1314
+ let head = "";
1315
+ for (const part of parts) {
1316
+ const next = head + part;
1317
+ if (width(next.trimEnd()) > limit) break;
1318
+ head = next;
1319
+ }
1320
+ if (head.trim() === "") head = clipToWidth(text, limit);
1321
+ return [head.trimEnd(), text.slice(head.length)];
1322
+ }
1323
+
1324
+ /**
1325
+ * {@link width}, under a name nothing shadows.
1326
+ *
1327
+ * `picker` binds `width` to its own float's column count, which is a number — so calling the
1328
+ * measuring function inside it is a type error at best and a silent wrong answer at worst.
1329
+ */
1330
+ const columnsOf = width;
1331
+
1332
+ /** How many columns of leading space `text` has. */
1333
+ function leading(text: string): number {
1334
+ return text.length - text.replace(/^ +/, "").length;
1335
+ }
1336
+
1337
+ /**
1338
+ * Type a directory path, with completion as you go.
1339
+ *
1340
+ * The list under the field is the directories that match what you have typed so far, refreshed on
1341
+ * every keystroke. `<Tab>` takes the highlighted one into the field so you can keep going, the
1342
+ * movement keys pick a different one, and `<CR>` accepts either the highlighted row or exactly what
1343
+ * you typed — because the directory you want may not be one this offers.
1344
+ *
1345
+ * `<C-w>` deletes a whole segment rather than a character, which is the difference between walking
1346
+ * back up a tree and holding backspace.
1347
+ *
1348
+ * Resolves to the path, or `null` if dismissed.
1349
+ */
1350
+ export async function pathPicker(
1351
+ neosh: Neosh,
1352
+ title: string,
1353
+ opts: { initial?: string; width?: number; height?: number } = {},
1354
+ ): Promise<string | null> {
1355
+ return picker<string>(neosh, [], {
1356
+ title,
1357
+ width: Math.max(40, opts.width ?? 72),
1358
+ height: Math.max(4, opts.height ?? 10),
1359
+ query: opts.initial ?? "",
1360
+ placeholder: "no directory matches — <CR> takes what you typed",
1361
+ source: async (query) => {
1362
+ const paths = await neosh.path.complete(query).catch(() => []);
1363
+ return paths.map((path) => ({ label: path, value: path }));
1364
+ },
1365
+ // What you typed, when it is not one of the offered rows. A completion list that refuses a path
1366
+ // it did not think of is a list that gets in the way.
1367
+ freeform: (query) => (query.trim() === "" ? null : query.trim()),
1368
+ });
1369
+ }
1370
+
1371
+ /**
1372
+ * A single-line text field. Resolves to the text, or `null` if dismissed.
1373
+ *
1374
+ * Built on the same capture the picker uses, so it behaves the same way: bindings still win, and
1375
+ * `<Esc>` gets you out.
1376
+ */
1377
+ export async function prompt(
1378
+ neosh: Neosh,
1379
+ title: string,
1380
+ opts: { initial?: string; width?: number } = {},
1381
+ ): Promise<string | null> {
1382
+ const width = Math.max(24, opts.width ?? 60);
1383
+ const buf = await neosh.buf.create({ name: "[prompt]", scratch: true, kind: KIND_PROMPT });
1384
+ const win = await neosh.float.open(buf, {
1385
+ anchor: { kind: "screen" },
1386
+ width: { kind: "fixed", n: width },
1387
+ height: { kind: "fixed", n: 2 },
1388
+ border: "rounded",
1389
+ focusable: true,
1390
+ closeOnBlur: true,
1391
+ // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
1392
+ // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
1393
+ // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
1394
+ // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
1395
+ // fails to bind a way out is never a terminal somebody has to kill: see
1396
+ // `ui.modal_escape_keys`.
1397
+ modal: true,
1398
+ z: 200,
1399
+ });
1400
+
1401
+ watchKeys(neosh);
1402
+ const keys = await widgetKeys(neosh);
1403
+
1404
+ let text = opts.initial ?? "";
1405
+ const render = async () => {
1406
+ await neosh.buf.setLines(buf, 0, -1, [title, `> ${text}`]);
1407
+ // The caret belongs where the typing goes. Left at the origin it sits on the title, and the
1408
+ // field reads as inert no matter what appears in it.
1409
+ await neosh.win.setCursor(win, 1, byteLength(`> ${text}`));
1410
+ };
1411
+
1412
+ let settle: (v: string | null) => void = () => {};
1413
+ const done = new Promise<string | null>((resolve) => {
1414
+ settle = resolve;
1415
+ });
1416
+
1417
+ const command = `neosh.ui.prompt.key.${++pickerSeq}`;
1418
+ const disposers: Disposable[] = [];
1419
+ let closed = false;
1420
+ const close = async (value: string | null) => {
1421
+ if (closed) return;
1422
+ closed = true;
1423
+ for (const d of disposers) d.dispose();
1424
+ await neosh.win.close(win).catch(() => {});
1425
+ settle(value);
1426
+ };
1427
+
1428
+ disposers.push(
1429
+ await neosh.cmd.register(command, async (_args, key: KeyContext | undefined) => {
1430
+ if (!key) return;
1431
+ switch (actionFor(keys, key.key)) {
1432
+ case "dismiss":
1433
+ await close(null);
1434
+ return;
1435
+ case "accept":
1436
+ await close(text);
1437
+ return;
1438
+ case "clear":
1439
+ text = "";
1440
+ break;
1441
+ case "delete_word":
1442
+ text = dropSegment(text);
1443
+ break;
1444
+ default: {
1445
+ if (key.key.code.kind === "backspace") {
1446
+ text = text.slice(0, -1);
1447
+ break;
1448
+ }
1449
+ if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
1450
+ text += key.key.code.c;
1451
+ break;
1452
+ }
1453
+ }
1454
+ await render();
1455
+ }, { desc: "prompt key" }),
1456
+ );
1457
+
1458
+ await neosh.focus.push(win);
1459
+ disposers.push(await neosh.keymap.capture(win, command));
1460
+ await bindWidgetKeys(neosh, win, command, keys);
1461
+ await render();
1462
+ return done;
1463
+ }
1464
+
1465
+ /**
1466
+ * Route every widget key to the widget's own handler while its window is focused.
1467
+ *
1468
+ * A raw capture only receives what *no binding claimed*. That used to be the whole story, and it
1469
+ * meant `<C-n>` never reached a picker: `<C-n>` is bound globally to "new conversation", so you
1470
+ * would be typing a filter and suddenly find yourself in a new conversation with the picker gone.
1471
+ *
1472
+ * Window-scoped bindings outrank global ones and are dropped with the window, so a widget owns its
1473
+ * keys for exactly as long as it is on screen and not one keystroke longer. Modes are enumerated
1474
+ * because a binding is per mode and a picker can be opened from any of them.
1475
+ *
1476
+ * These floats are also `modal`, which is the other half and the half this could not be: a widget
1477
+ * can name the keys it *wants* and never the ones it merely does not want to happen. Both are
1478
+ * needed — modality stops `<C-t>` opening a panel behind the picker, and these bindings are what
1479
+ * point `<C-n>` at "next" rather than at nothing.
1480
+ */
1481
+ async function bindWidgetKeys(
1482
+ neosh: Neosh,
1483
+ win: WindowId,
1484
+ command: string,
1485
+ keys: Map<WidgetAction, KeySpec[]>,
1486
+ extra: readonly string[] = [],
1487
+ ): Promise<void> {
1488
+ const lhs = new Set<string>(extra);
1489
+ for (const specs of keys.values()) {
1490
+ for (const spec of specs) lhs.add(spec.lhs);
1491
+ }
1492
+ for (const mode of ["normal", "insert", "visual", "chat"] as const) {
1493
+ for (const key of lhs) {
1494
+ // A key that will not parse on the host side is not worth failing the whole widget over; the
1495
+ // capture still delivers it if nothing else claimed it.
1496
+ await neosh.keymap.set(mode, key, command, { scope: { kind: "window", win } }).catch(() => {});
1497
+ }
1498
+ }
1499
+ }
1500
+
1501
+ /**
1502
+ * Highlight groups the widgets here use, linked to ones a theme already defines.
1503
+ *
1504
+ * Call once from your plugin's `activate` if you want them; they are links, not colors, so an
1505
+ * unknown theme still renders something sensible.
1506
+ */
1507
+ export async function defineHighlights(_neosh: Neosh): Promise<void> {
1508
+ // Nothing to define any more: the groups below are part of the core palette, so they resolve in
1509
+ // every theme and follow a theme switch without this library hearing about it. Kept as a no-op
1510
+ // because plugins call it, and because a future widget may need a group of its own.
1511
+ }
1512
+
1513
+ /** Re-exported so a caller can type a picker's list without importing from two places. */
1514
+ export type { BufferId, WindowId };
1515
+
1516
+ // ---------------------------------------------------------------------------
1517
+ // Version-control display
1518
+ // ---------------------------------------------------------------------------
1519
+
1520
+ /**
1521
+ * The single letter git itself uses for a state.
1522
+ *
1523
+ * Written out rather than derived from the enum name, because two of them do not agree with their
1524
+ * spelling: untracked is `?`, not `U`, and `U` means *unmerged*. A sidebar that labels every new
1525
+ * file `U` is telling the user they have a merge conflict.
1526
+ */
1527
+ export function stateLetter(state: FileState | null | undefined): string {
1528
+ switch (state) {
1529
+ case "added": return "A";
1530
+ case "modified": return "M";
1531
+ case "deleted": return "D";
1532
+ case "renamed": return "R";
1533
+ case "copied": return "C";
1534
+ case "type_changed": return "T";
1535
+ case "conflicted": return "U";
1536
+ case "untracked": return "?";
1537
+ case "ignored": return "!";
1538
+ default: return " ";
1539
+ }
1540
+ }
1541
+
1542
+ /** The two-column `XY` prefix git status prints: staged state, then working-tree state. */
1543
+ export function statusPrefix(change: FileChange): string {
1544
+ return `${stateLetter(change.staged)}${stateLetter(change.unstaged)}`;
1545
+ }
1546
+
1547
+ /**
1548
+ * Fit a path into `width` display columns, keeping the end.
1549
+ *
1550
+ * The tail is what identifies a file; the head is what repeats. Measured in columns rather than
1551
+ * characters, so a path with CJK in it does not overflow a narrow panel.
1552
+ *
1553
+ * Git reports untracked *directories* with a trailing slash — `crates/` — which `basename` turns
1554
+ * into an empty string. Kept here so every caller gets that right.
1555
+ */
1556
+ export function shortenPath(path: string, width: number): string {
1557
+ const trimmed = path.endsWith("/") ? path.slice(0, -1) : path;
1558
+ const suffix = path.endsWith("/") ? "/" : "";
1559
+ const chars = Array.from(trimmed);
1560
+ const budget = Math.max(4, width - suffix.length);
1561
+ if (chars.length <= budget) return trimmed + suffix;
1562
+ return `\u2026${chars.slice(-(budget - 1)).join("")}${suffix}`;
1563
+ }
1564
+
1565
+ // ---------------------------------------------------------------------------
1566
+ // Motion
1567
+ // ---------------------------------------------------------------------------
1568
+
1569
+ /**
1570
+ * Ambient motion, on one shared clock.
1571
+ *
1572
+ * The rule this follows is not a terminal compromise — it is the same rule the web app states
1573
+ * outright: *no continuously repainting animations*. Its four animations are duty-cycled with
1574
+ * `steps()` so a 2-second pulse is a dozen discrete frames rather than 240. A terminal is that
1575
+ * model already: discrete cells, discrete frames, and a diffing renderer that writes only what
1576
+ * changed.
1577
+ *
1578
+ * So: **one clock**, module-global and therefore shared by every plugin, ticking at
1579
+ * {@link TICK_MS}. Every spinner in the session is on the same frame and every pulse toggles in the
1580
+ * same repaint, rather than each row owning a timer and smearing writes across the second.
1581
+ *
1582
+ * Measured cost of the whole system in a real terminal: ~1.3% of one core and 0.6 KiB/s at 80 ms,
1583
+ * because the renderer sends only changed cells. That holds over SSH.
1584
+ */
1585
+
1586
+ /** One frame of the shared clock. 100 ms × 10 braille frames is exactly the web's 1000 ms spin. */
1587
+ export const TICK_MS = 100;
1588
+
1589
+ /** The spinner the web app uses 28 times over, as braille. */
1590
+ const BRAILLE = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];
1591
+ /** For a terminal that cannot draw braille. Four frames at 250 ms is the same 1000 ms cycle. */
1592
+ const ASCII = ["|", "/", "-", "\\"];
1593
+
1594
+ let ticks = 0;
1595
+ let handle: number | null = null;
1596
+ const listeners = new Set<() => void>();
1597
+ let ascii = false;
1598
+ let enabled = true;
1599
+
1600
+ /**
1601
+ * Subscribe to the shared clock.
1602
+ *
1603
+ * The clock starts on the first subscriber and stops on the last, so an idle session has no timer
1604
+ * at all — and unloading a plugin, which disposes its subscriptions, takes its share with it.
1605
+ *
1606
+ * Push the result into `ctx.subscriptions`. A subscriber that is never disposed keeps the clock
1607
+ * running for the life of the session.
1608
+ */
1609
+ export function onTick(cb: () => void): Disposable {
1610
+ listeners.add(cb);
1611
+ if (handle === null) {
1612
+ handle = setInterval(() => {
1613
+ ticks++;
1614
+ for (const l of listeners) {
1615
+ try {
1616
+ l();
1617
+ } catch {
1618
+ // One misbehaving subscriber must not stop every other spinner in the session.
1619
+ }
1620
+ }
1621
+ }, TICK_MS) as unknown as number;
1622
+ }
1623
+ return {
1624
+ dispose() {
1625
+ listeners.delete(cb);
1626
+ if (listeners.size === 0 && handle !== null) {
1627
+ clearInterval(handle);
1628
+ handle = null;
1629
+ }
1630
+ },
1631
+ };
1632
+ }
1633
+
1634
+ /**
1635
+ * The current spinner frame.
1636
+ *
1637
+ * Read it, do not own it: every caller reading the same function on the same clock is what keeps
1638
+ * two spinners on screen from wobbling against each other.
1639
+ */
1640
+ export function spinnerFrame(): string {
1641
+ if (!enabled) return ascii ? "*" : "•";
1642
+ const frames = ascii ? ASCII : BRAILLE;
1643
+ const divisor = ascii ? Math.round(250 / TICK_MS) : 1;
1644
+ return frames[Math.floor(ticks / divisor) % frames.length] ?? frames[0]!;
1645
+ }
1646
+
1647
+ /**
1648
+ * The 1 Hz duty cycle behind every pulsing dot: one second bright, one second dim.
1649
+ *
1650
+ * Two states, never a ramp — the web's `status-pulse` quantizes to exactly two opacities and this
1651
+ * is the same thing with the `dim` attribute. Returning a boolean rather than a glyph keeps the
1652
+ * cell width and the accessible text stable while only the attribute changes.
1653
+ */
1654
+ export function pulseBright(): boolean {
1655
+ if (!enabled) return true;
1656
+ return Math.floor(ticks / Math.round(1000 / TICK_MS)) % 2 === 0;
1657
+ }
1658
+
1659
+ /** Highlight group for a pulsing indicator, for use as `hlGroup`. */
1660
+ export function pulseHl(bright: string, dim = "Comment"): string {
1661
+ return pulseBright() ? bright : dim;
1662
+ }
1663
+
1664
+ /**
1665
+ * Configure motion for this session.
1666
+ *
1667
+ * Called once by whoever owns the setting — the bundled plugins read `ui.motion` and
1668
+ * `ui.ascii_only`. With motion off, `spinnerFrame` returns a single static glyph and `pulseBright`
1669
+ * is always true, so callers need no branch of their own.
1670
+ */
1671
+ export function configureMotion(opts: { enabled?: boolean; ascii?: boolean }): void {
1672
+ if (opts.enabled !== undefined) enabled = opts.enabled;
1673
+ if (opts.ascii !== undefined) ascii = opts.ascii;
1674
+ }
1675
+
1676
+ export function motionEnabled(): boolean {
1677
+ return enabled;
1678
+ }
1679
+
1680
+ /** Elapsed time as the web app words it: `12s`, `1m 04s`, `1h 02m`. */
1681
+ export function elapsed(ms: number): string {
1682
+ const total = Math.max(0, Math.floor(ms / 1000));
1683
+ if (total < 60) return `${total}s`;
1684
+ const minutes = Math.floor(total / 60);
1685
+ const seconds = total % 60;
1686
+ if (minutes < 60) return `${minutes}m ${String(seconds).padStart(2, "0")}s`;
1687
+ const hours = Math.floor(minutes / 60);
1688
+ return `${hours}h ${String(minutes % 60).padStart(2, "0")}m`;
1689
+ }
1690
+
1691
+ /**
1692
+ * A horizontal meter, as cells.
1693
+ *
1694
+ * Used for the context-window gauge and for anything with a fraction. Repaint only when the filled
1695
+ * count changes: a meter that rewrites itself every tick to draw the same thing is the exact cost
1696
+ * this module exists to avoid.
1697
+ */
1698
+ export function meter(fraction: number, width: number, opts: { ascii?: boolean } = {}): string {
1699
+ const w = Math.max(1, width);
1700
+ const clamped = Math.min(1, Math.max(0, Number.isFinite(fraction) ? fraction : 0));
1701
+ const filled = Math.round(clamped * w);
1702
+ const [on, off] = opts.ascii ?? ascii ? ["#", "-"] : ["█", "░"];
1703
+ return on!.repeat(filled) + off!.repeat(w - filled);
1704
+ }
1705
+
1706
+ /** `1.2k`, `45.3k`, `1.8M` — the compact forms a status line has room for. */
1707
+ export function compact(n: number): string {
1708
+ const v = Math.max(0, Math.round(n));
1709
+ if (v < 1000) return String(v);
1710
+ if (v < 1_000_000) {
1711
+ const k = v / 1000;
1712
+ return `${k < 10 ? k.toFixed(1) : Math.round(k)}k`;
1713
+ }
1714
+ const m = v / 1_000_000;
1715
+ return `${m < 10 ? m.toFixed(1) : Math.round(m)}M`;
1716
+ }
1717
+
1718
+ /** `$0.0042`, `$1.23`, `$12.30` — enough precision to be useful at both ends. */
1719
+ export function money(usd: number): string {
1720
+ if (!Number.isFinite(usd) || usd <= 0) return "$0";
1721
+ if (usd < 0.01) return `$${usd.toFixed(4)}`;
1722
+ if (usd < 1) return `$${usd.toFixed(3)}`;
1723
+ return `$${usd.toFixed(2)}`;
1724
+ }
1725
+
1726
+ // ---------------------------------------------------------------------------
1727
+ // Cursored list
1728
+ // ---------------------------------------------------------------------------
1729
+
1730
+ /**
1731
+ * A row in a {@link CursoredList}.
1732
+ *
1733
+ * `right` is rendered flush against the pane's right edge by the frontend, which is the only thing
1734
+ * that knows how wide the pane is or how many columns a character occupies. Building that alignment
1735
+ * here would mean measuring display width in a plugin, which is exactly what the byte-offset
1736
+ * protocol exists to prevent.
1737
+ */
1738
+ export interface ListRow<T = unknown> {
1739
+ text: string;
1740
+ /**
1741
+ * The whole of what this row says, when `text` is a clipped version of it.
1742
+ *
1743
+ * A panel is a column of fixed width and a conversation title is not, so rows get cut — and a row
1744
+ * cut at the edge is a row you cannot read, which in a list of conversations means you cannot
1745
+ * tell two of them apart. Set this to the row as it *would* have been written with unlimited
1746
+ * room, and the list shows the rest of it on continuation lines whenever the cursor is on that
1747
+ * row, folding back to one line the moment the cursor leaves.
1748
+ *
1749
+ * The cursor is what asks. Only one row can be under it, so only one row is ever more than a line
1750
+ * tall, and the column stays scannable — which is the thing a list is for and the thing that
1751
+ * wrapping everything all the time takes away.
1752
+ *
1753
+ * Include the same prefix `text` has: the marker, the glyph, the indent. What comes back is the
1754
+ * difference between the two, so `text: " ▸ some long ti…"` with `full: " ▸ some long title"`
1755
+ * continues with `tle` and not with the glyph again. Leave out anything the clip *kept* — a
1756
+ * trailing unread dot, a count — since that is already on screen and saying it twice under the
1757
+ * row is how a marker stops meaning anything. Nothing happens without
1758
+ * {@link CursoredListOptions.width}, because nothing here can measure a column it was not told
1759
+ * about.
1760
+ */
1761
+ full?: string;
1762
+ /**
1763
+ * Keep this row unfolded whether or not the cursor is on it.
1764
+ *
1765
+ * The cursor asking is the rule, and this is the one exception worth having: the conversation
1766
+ * you are *in* is not a row you are considering, it is where you are, and a panel that abbreviates
1767
+ * it to `Fix the login re…` until you happen to move the cursor onto it is abbreviating the one
1768
+ * row you already know you want. Needs {@link ListRow.full} and
1769
+ * {@link CursoredListOptions.width}, like every other unfolding here. Use it for a row that is
1770
+ * *current*, never for one that is merely long — every row that opts in is a row the column can
1771
+ * no longer be scanned down.
1772
+ */
1773
+ expand?: boolean;
1774
+ /**
1775
+ * The column continuation lines line up under. Defaults to the row's own indent plus two.
1776
+ *
1777
+ * Set it where the default would be wrong — a row whose text starts with a marker and a glyph
1778
+ * has its *content* several columns in from its indent, and continuations that ignore that read
1779
+ * as a second row rather than as the rest of this one.
1780
+ */
1781
+ indent?: number;
1782
+ /** Highlight group for the whole row. Link to one the theme defines. */
1783
+ hl?: string;
1784
+ /**
1785
+ * Highlights for pieces of the row, over the top of `hl`: a favourite marker, a state glyph, a
1786
+ * matched substring. `from`/`to` are UTF-8 byte offsets into `text` — the same unit every column
1787
+ * on the wire uses — so build them with {@link byteLength} rather than `.length`.
1788
+ */
1789
+ spans?: Array<{ from: number; to: number; hl: string }>;
1790
+ /** Flush-right status: a timestamp, a count, a state word. */
1791
+ right?: { text: string; hl?: string };
1792
+ /** Rows that cannot be landed on: headings, separators, blanks. */
1793
+ inert?: boolean;
1794
+ value?: T;
1795
+ }
1796
+
1797
+ export interface CursoredListOptions {
1798
+ /** Highlight for the row the cursor is on. Defaults to the theme's `Sidebar.Selected`. */
1799
+ cursorHl?: string;
1800
+ /** Called after every move, with the row landed on. */
1801
+ onMove?(index: number): void;
1802
+ /**
1803
+ * How many columns the panel has, asked at render time.
1804
+ *
1805
+ * Asked rather than passed once, because a panel is resized and a width captured at construction
1806
+ * is a width that goes stale on the first drag. Without it {@link ListRow.full} does nothing:
1807
+ * a plugin cannot measure the dock it was given, and guessing is how a continuation line ends up
1808
+ * wrapping one column past the edge for the rest of the session.
1809
+ */
1810
+ width?(): number;
1811
+ }
1812
+
1813
+ /**
1814
+ * The list behaviour every panel in a workspace needs, in one place.
1815
+ *
1816
+ * Owns a cursor that skips inert rows, renders text and marks together, and keeps the cursor on
1817
+ * screen. It does **not** own a window, a buffer or any keys — a docked sidebar and a floating
1818
+ * picker want different chrome and different bindings, and the part worth sharing is the part
1819
+ * below.
1820
+ */
1821
+ export class CursoredList<T = unknown> {
1822
+ private rows: ListRow<T>[] = [];
1823
+ private cursor = 0;
1824
+
1825
+ constructor(
1826
+ private readonly neosh: Neosh,
1827
+ private readonly buf: BufferId,
1828
+ private readonly ns: number,
1829
+ private readonly opts: CursoredListOptions = {},
1830
+ ) {}
1831
+
1832
+ get index(): number {
1833
+ return this.cursor;
1834
+ }
1835
+
1836
+ get current(): ListRow<T> | undefined {
1837
+ return this.rows[this.cursor];
1838
+ }
1839
+
1840
+ get value(): T | undefined {
1841
+ return this.rows[this.cursor]?.value;
1842
+ }
1843
+
1844
+ /** Every row's value, top to bottom, skipping rows that have none. What a panel publishes. */
1845
+ get values(): T[] {
1846
+ return this.rows.flatMap((r) => (r.value === undefined ? [] : [r.value]));
1847
+ }
1848
+
1849
+ get length(): number {
1850
+ return this.rows.length;
1851
+ }
1852
+
1853
+ /**
1854
+ * Replace the contents, keeping the cursor where it makes sense.
1855
+ *
1856
+ * Anchored to `value` identity rather than index: a list that reorders under a running turn would
1857
+ * otherwise move the selection out from under the user's next keystroke.
1858
+ */
1859
+ setRows(rows: ListRow<T>[], sameAs?: (a: T, b: T) => boolean): void {
1860
+ const previous = this.rows[this.cursor]?.value;
1861
+ this.rows = rows;
1862
+ if (previous !== undefined && sameAs) {
1863
+ const found = rows.findIndex((r) => r.value !== undefined && sameAs(r.value, previous));
1864
+ if (found >= 0) {
1865
+ this.cursor = found;
1866
+ return;
1867
+ }
1868
+ }
1869
+ this.clamp();
1870
+ }
1871
+
1872
+ private clamp(): void {
1873
+ if (this.rows.length === 0) {
1874
+ this.cursor = 0;
1875
+ return;
1876
+ }
1877
+ this.cursor = Math.min(Math.max(0, this.cursor), this.rows.length - 1);
1878
+ if (this.rows[this.cursor]?.inert) {
1879
+ const after = this.rows.findIndex((r, i) => i >= this.cursor && !r.inert);
1880
+ const anywhere = this.rows.findIndex((r) => !r.inert);
1881
+ this.cursor = after >= 0 ? after : Math.max(0, anywhere);
1882
+ }
1883
+ }
1884
+
1885
+ /**
1886
+ * Move by `delta` selectable rows.
1887
+ *
1888
+ * Wraps by default, because a list you cannot get back to the top of is a chore. A page step
1889
+ * passes `wrap: false`: `^D` at the foot of the list means "there is no more", and one that
1890
+ * silently reappears at the top is a keypress that loses your place in a column you were
1891
+ * reading downwards.
1892
+ *
1893
+ * `delta` counts rows you can land on, not buffer rows — headings, rules and blanks are not
1894
+ * places, so `5j` past two separators moves five conversations rather than three.
1895
+ */
1896
+ move(delta: number, opts: { wrap?: boolean } = {}): void {
1897
+ if (this.rows.length === 0 || delta === 0) return;
1898
+ const wrap = opts.wrap ?? true;
1899
+ const step = delta < 0 ? -1 : 1;
1900
+ let remaining = Math.abs(delta);
1901
+ let i = this.cursor;
1902
+ let landed = -1;
1903
+ // Bounded by the whole list per row asked for: an all-inert list has nowhere to go, and a
1904
+ // wrapping search for a row that does not exist would otherwise spin.
1905
+ const limit = this.rows.length * Math.abs(delta) + this.rows.length;
1906
+ for (let n = 0; n < limit && remaining > 0; n++) {
1907
+ let next = i + step;
1908
+ if (next < 0 || next >= this.rows.length) {
1909
+ if (!wrap) break;
1910
+ next = (next + this.rows.length) % this.rows.length;
1911
+ }
1912
+ i = next;
1913
+ if (!this.rows[i]?.inert) {
1914
+ landed = i;
1915
+ remaining--;
1916
+ }
1917
+ }
1918
+ if (landed < 0) return;
1919
+ this.cursor = landed;
1920
+ this.opts.onMove?.(landed);
1921
+ }
1922
+
1923
+ /**
1924
+ * The first or last row you can land on — `gg` and `G`.
1925
+ *
1926
+ * A separate verb from a large `move`, because "the end" is a place and "a hundred rows down"
1927
+ * is a guess about where the end is.
1928
+ */
1929
+ toEnd(which: "first" | "last"): void {
1930
+ const found = which === "first"
1931
+ ? this.rows.findIndex((r) => !r.inert)
1932
+ : this.rows.reduce((acc, r, i) => (r.inert ? acc : i), -1);
1933
+ if (found < 0) return;
1934
+ this.cursor = found;
1935
+ this.opts.onMove?.(found);
1936
+ }
1937
+
1938
+ /** The n-th row you can land on, 1-based, the way `5G` counts. */
1939
+ nth(n: number): void {
1940
+ let seen = 0;
1941
+ for (const [i, row] of this.rows.entries()) {
1942
+ if (row.inert) continue;
1943
+ seen += 1;
1944
+ if (seen === n) {
1945
+ this.cursor = i;
1946
+ this.opts.onMove?.(i);
1947
+ return;
1948
+ }
1949
+ }
1950
+ this.toEnd("last");
1951
+ }
1952
+
1953
+ /** Put the cursor on the first row whose value matches. */
1954
+ select(match: (value: T) => boolean): boolean {
1955
+ const found = this.rows.findIndex((r) => r.value !== undefined && match(r.value));
1956
+ if (found < 0) return false;
1957
+ this.cursor = found;
1958
+ return true;
1959
+ }
1960
+
1961
+ /**
1962
+ * Write the rows and their marks.
1963
+ *
1964
+ * `showCursor` is false when the panel does not have the keyboard: a cursor drawn on an unfocused
1965
+ * list claims an attention it does not have, and two visible cursors is worse than none.
1966
+ *
1967
+ * `pinned` is how many rows at the end sit against the *bottom edge* rather than after the
1968
+ * content: a gauge or a key strip is a foot, and a foot that floats halfway up a half-empty
1969
+ * column is chrome that moves every time the list above it grows by one. It costs blank lines
1970
+ * and nothing else — when the list is longer than the window there is no room to pin anything
1971
+ * to, and the rows go back to being the end of the list, which is where scrolling will find
1972
+ * them.
1973
+ */
1974
+ async render(
1975
+ opts: { showCursor?: boolean; win?: WindowId; pinned?: number } = {},
1976
+ ): Promise<void> {
1977
+ const cursorHl = this.opts.cursorHl ?? "Sidebar.Selected";
1978
+ const columns = this.opts.width?.() ?? 0;
1979
+ const drawn: DrawnRow[] = [];
1980
+ /** Which buffer line each row starts on. Not the row's index once anything has unfolded. */
1981
+ const lineOf: number[] = [];
1982
+ /** How tall the cursor's row turned out, so scrolling can keep all of it on screen. */
1983
+ let block = 1;
1984
+
1985
+ this.rows.forEach((row, i) => {
1986
+ lineOf[i] = drawn.length;
1987
+ const eol = byteLength(row.text);
1988
+ const onCursor = opts.showCursor !== false && i === this.cursor;
1989
+ const marks: DrawnMark[] = [];
1990
+ if (onCursor && eol > 0) {
1991
+ marks.push({ col: 0, opts: { hlGroup: cursorHl, endCol: eol, priority: 200 } });
1992
+ }
1993
+ if (row.hl && eol > 0) {
1994
+ marks.push({ col: 0, opts: { hlGroup: row.hl, endCol: eol } });
1995
+ }
1996
+ // Above the row's own highlight, below the cursor's 200: a marker keeps its colour on an
1997
+ // ordinary row and yields to the selection, which is the row the eye is already on.
1998
+ for (const s of row.spans ?? []) {
1999
+ if (s.to <= s.from || s.from >= eol) continue;
2000
+ marks.push({
2001
+ col: s.from,
2002
+ opts: { hlGroup: s.hl, endCol: Math.min(s.to, eol), priority: 100 },
2003
+ });
2004
+ }
2005
+ if (row.right) {
2006
+ marks.push({
2007
+ col: 0,
2008
+ opts: {
2009
+ virtText: [{ text: row.right.text, hlGroup: row.right.hl ?? "Comment" }],
2010
+ virtTextPos: "right",
2011
+ },
2012
+ });
2013
+ }
2014
+ drawn.push({ text: row.text, marks });
2015
+
2016
+ // The rest of a row that did not fit: under the cursor, or on a row that asked to stay open.
2017
+ if ((!onCursor && !row.expand) || row.full === undefined || columns < 1) return;
2018
+ const indent = Math.max(0, Math.min(row.indent ?? leading(row.text) + 2, columns - 8));
2019
+ const rest = overflowOf(row.text, row.full, columns - indent);
2020
+ // Only the cursor's block is scrolled to. A row that is permanently open is not somewhere
2021
+ // the cursor is, so counting its height here would scroll the panel to a row nobody moved to.
2022
+ if (onCursor) block = 1 + rest.length;
2023
+ for (const line of rest) {
2024
+ const text = `${" ".repeat(indent)}${line}`;
2025
+ const end = byteLength(text);
2026
+ const cont: DrawnMark[] = [];
2027
+ // The band runs the whole block. A continuation drawn on the terminal background reads as
2028
+ // a separate row that happens to be indented, which is the one thing it must not be. Only
2029
+ // where there is a band to run: an always-open row that is not under the cursor gets its
2030
+ // own colour and nothing else, or every one of them would look selected.
2031
+ if (onCursor) cont.push({ col: 0, opts: { hlGroup: cursorHl, endCol: end, priority: 200 } });
2032
+ if (row.hl) cont.push({ col: 0, opts: { hlGroup: row.hl, endCol: end } });
2033
+ drawn.push({ text, marks: cont });
2034
+ }
2035
+ });
2036
+
2037
+ // The foot, against the bottom edge. Measured after everything is drawn rather than counted in
2038
+ // rows, because an unfolded row is several lines tall and padding by row count would leave the
2039
+ // strip one line short for every row that happened to be open.
2040
+ const pinned = Math.min(Math.max(0, opts.pinned ?? 0), this.rows.length);
2041
+ // One round trip, used twice — and skipped when neither the foot nor the cursor needs it, since
2042
+ // this runs on a tick while a turn is in flight and an unfocused panel that pins nothing has
2043
+ // nothing to measure.
2044
+ const needed = pinned > 0 || opts.showCursor !== false;
2045
+ const view = opts.win === undefined || !needed
2046
+ ? null
2047
+ : await this.neosh.win.viewport(opts.win).catch(() => null);
2048
+ if (pinned > 0) {
2049
+ const at = lineOf[this.rows.length - pinned] ?? drawn.length;
2050
+ const filler = (view?.height ?? 0) - drawn.length;
2051
+ if (filler > 0) {
2052
+ drawn.splice(at, 0, ...Array.from({ length: filler }, () => ({ text: "", marks: [] })));
2053
+ for (let i = this.rows.length - pinned; i < this.rows.length; i++) {
2054
+ lineOf[i] = (lineOf[i] ?? 0) + filler;
2055
+ }
2056
+ }
2057
+ }
2058
+
2059
+ // One call, not one per mark. A panel that wrote its text, cleared its namespace and then set
2060
+ // its marks a call at a time was observable halfway through — and a frame landing after the
2061
+ // clear drew every row unmarked, which is `Normal`, which is near-white. That is what a sidebar
2062
+ // full of running agents was flashing.
2063
+ await this.neosh.buf.render(this.buf, this.ns, 0, -1, drawn);
2064
+
2065
+ // Keep the cursor on screen without recentring on every keystroke. In buffer lines, not row
2066
+ // indices: an unfolded row is several lines tall and the two stopped agreeing the moment one
2067
+ // was — which would scroll to the wrong place by however many rows above it had ever unfolded.
2068
+ if (opts.win !== undefined && opts.showCursor !== false) {
2069
+ const v = view;
2070
+ if (v && v.height > 0) {
2071
+ const at = lineOf[this.cursor] ?? this.cursor;
2072
+ // The whole of the row, not only the line it starts on: scrolling until the *first* line
2073
+ // is visible leaves the continuation you unfolded it for below the bottom edge.
2074
+ const end = at + Math.min(block, v.height) - 1;
2075
+ const top = v.top_line;
2076
+ if (at < top) await this.neosh.win.scrollTo(opts.win, at);
2077
+ else if (end >= top + v.height) {
2078
+ await this.neosh.win.scrollTo(opts.win, end - v.height + 1);
2079
+ }
2080
+ }
2081
+ }
2082
+ }
2083
+ }
2084
+
2085
+ // ---------------------------------------------------------------------------
2086
+ // Two-pane picker
2087
+ // ---------------------------------------------------------------------------
2088
+
2089
+ /**
2090
+ * One entry in the left rail: a provider, an account, a category.
2091
+ *
2092
+ * The mark is drawn in its own highlight group, which is how a rail of eleven providers stays
2093
+ * legible — you find the one you want by shape and colour before you have read a word.
2094
+ */
2095
+ export interface RailItem<G> {
2096
+ /** A brand mark, one or two columns. Drawn in `mark.hl`. */
2097
+ mark?: { text: string; hl?: string };
2098
+ label: string;
2099
+ /** A state marker at the right edge — signed in, needs a key, unavailable. */
2100
+ badge?: { text: string; hl?: string };
2101
+ /** Heading this sits under. Groups appear in the order their first member does. */
2102
+ group?: string;
2103
+ /** Listed, dimmed, and skipped by the cursor. For a provider whose driver is missing. */
2104
+ disabled?: boolean;
2105
+ value: G;
2106
+ }
2107
+
2108
+ /** One row of the right-hand list. */
2109
+ export interface PaneItem<T> extends PickerItem<T> {
2110
+ /** A middle column, aligned across rows: a tier, a size, a state. */
2111
+ badge?: { text: string; hl?: string };
2112
+ /**
2113
+ * A collapsible section this row belongs to.
2114
+ *
2115
+ * Rows in a section are hidden behind a header that says how many there are. What it is for:
2116
+ * last year's models, which you want reachable and do not want in the way.
2117
+ */
2118
+ section?: string;
2119
+ disabled?: boolean;
2120
+ }
2121
+
2122
+ export interface RailPickerOptions<G, T> {
2123
+ title?: string;
2124
+ /** Columns for the rail. The list takes the rest. */
2125
+ railWidth?: number;
2126
+ /** Total inner width. */
2127
+ width?: number;
2128
+ /** Rows of body. */
2129
+ height?: number;
2130
+ rail: RailItem<G>[];
2131
+ /** Which rail entry to open on. */
2132
+ railAt?: number;
2133
+ /** The rows for a rail entry. Called on open and on every rail move. */
2134
+ items(group: G): Promise<PaneItem<T>[]>;
2135
+ /** Which row to start on, given the rows. */
2136
+ itemAt?(items: PaneItem<T>[]): number;
2137
+ /** The key strip at the foot. Keep it to one line. */
2138
+ hints?: string;
2139
+ /**
2140
+ * What the two panes are called, for the affordance in the title row.
2141
+ *
2142
+ * `"providers"` and `"models"` rather than `"panes"` and `"list"`, wherever the caller knows —
2143
+ * a key labelled with what it reaches is a key somebody presses.
2144
+ */
2145
+ railLabel?: string;
2146
+ paneLabel?: string;
2147
+ /**
2148
+ * Shown in place of the list when there is nothing in it.
2149
+ *
2150
+ * Not decoration: an empty pane beside a populated rail reads as a broken widget, and the two
2151
+ * reasons it can be empty — your filter matched nothing, or this provider serves nothing until
2152
+ * it can authenticate — need different things done about them.
2153
+ */
2154
+ placeholder?: string;
2155
+ /**
2156
+ * Keys `onKey` wants that a *binding elsewhere* would otherwise take.
2157
+ *
2158
+ * The raw capture only receives what no keymap claimed, so a caller reaching for `<C-s>` gets
2159
+ * nothing — that key belongs to the transcript reader. Listing it here binds it to this widget
2160
+ * for as long as the widget is open, the same way its own movement keys are bound.
2161
+ *
2162
+ * Bare letters do not need this and should not use it: they reach `onKey` already, and taking
2163
+ * one means it can never be typed into the filter — which is how a model picker ends up unable
2164
+ * to search for "sonnet".
2165
+ */
2166
+ ownKeys?: readonly string[];
2167
+ /**
2168
+ * A key the caller wants for itself, checked before the widget's own.
2169
+ *
2170
+ * Return `"close"` to dismiss, `"reload"` to re-fetch the current list, `"handled"` to redraw,
2171
+ * and nothing at all to let the widget have the key. This is what lets a model picker put "sign
2172
+ * in" and "set effort" on keys without a second modal.
2173
+ */
2174
+ onKey?(
2175
+ key: KeyContext,
2176
+ ctx: { rail: G | undefined; item: T | undefined },
2177
+ ): Promise<"handled" | "reload" | "close" | undefined> | "handled" | "reload" | "close" | undefined;
2178
+ }
2179
+
2180
+ const RAIL_NS = "neosh.ui.rail";
2181
+
2182
+ /**
2183
+ * A picker in two panes: a rail of categories, and the rows belonging to the selected one.
2184
+ *
2185
+ * The shape exists because one flat list cannot answer two questions at once. "Which provider" and
2186
+ * "which model" are different choices with different cardinalities — a dozen providers, hundreds of
2187
+ * models — and flattening them produces a list where the thing you want is thirty rows below a
2188
+ * provider you do not use.
2189
+ *
2190
+ * Both panes are one buffer with a rule down the middle rather than two floats, because two floats
2191
+ * cannot be kept adjacent without each of them knowing the other's width, and neither of them may
2192
+ * measure anything. One buffer makes the rule a character, which is a thing a plugin can place.
2193
+ *
2194
+ * Typing filters the list. `pane_next`/`pane_prev` — `<Tab>`/`<S-Tab>` and `←`/`→` by default —
2195
+ * move between the panes, and the rail's own entries are reachable with the same movement keys as
2196
+ * the list, so nothing here needs a mouse or a chord you have to be told about.
2197
+ */
2198
+ export async function railPicker<G, T>(
2199
+ neosh: Neosh,
2200
+ opts: RailPickerOptions<G, T>,
2201
+ ): Promise<T | null> {
2202
+ const height = Math.max(3, opts.height ?? 14);
2203
+ const total = Math.max(40, opts.width ?? 84);
2204
+ const railWidth = Math.min(Math.max(12, opts.railWidth ?? 22), total - 24);
2205
+ const paneWidth = total - railWidth - 1; // the rule takes a column
2206
+
2207
+ const buf = await neosh.buf.create({ name: `[${opts.title ?? "select"}]`, scratch: true, kind: KIND_PICKER });
2208
+ const ns = await neosh.ns.create(RAIL_NS);
2209
+ const win = await neosh.float.open(buf, {
2210
+ anchor: { kind: "screen" },
2211
+ width: { kind: "fixed", n: total },
2212
+ // title, filter, rule, body, rule, hints
2213
+ height: { kind: "fixed", n: height + 5 },
2214
+ border: "rounded",
2215
+ focusable: true,
2216
+ closeOnBlur: true,
2217
+ // Modal: nothing global resolves while this is up. Shadowing the keys a widget wants — which
2218
+ // is what `bindWidgetKeys` does and still does — only ever covered the keys it uses; `^T`,
2219
+ // `^G`, `^L` and the rest fell straight through and opened a second panel behind this one,
2220
+ // with focus somewhere neither of them expected. `^Q` and `^R` still work, so a widget that
2221
+ // fails to bind a way out is never a terminal somebody has to kill: see
2222
+ // `ui.modal_escape_keys`.
2223
+ modal: true,
2224
+ z: 200,
2225
+ });
2226
+
2227
+ watchKeys(neosh);
2228
+ const keys = await widgetKeys(neosh);
2229
+ const railLabel = opts.railLabel ?? "panes";
2230
+ const paneLabel = opts.paneLabel ?? "list";
2231
+
2232
+ // ---- rail rows, headings interleaved ------------------------------------
2233
+ type RailRow =
2234
+ | { kind: "heading"; text: string }
2235
+ | { kind: "entry"; item: RailItem<G>; index: number };
2236
+
2237
+ const railRows: RailRow[] = [];
2238
+ {
2239
+ let group: string | undefined;
2240
+ opts.rail.forEach((item, index) => {
2241
+ if (item.group !== undefined && item.group !== group) {
2242
+ group = item.group;
2243
+ railRows.push({ kind: "heading", text: item.group });
2244
+ }
2245
+ railRows.push({ kind: "entry", item, index });
2246
+ });
2247
+ }
2248
+ const selectableRail = railRows
2249
+ .map((r, at) => (r.kind === "entry" && !r.item.disabled ? at : -1))
2250
+ .filter((at) => at >= 0);
2251
+
2252
+ let railAt =
2253
+ selectableRail.find(
2254
+ (at) => (railRows[at] as { index: number }).index === (opts.railAt ?? 0),
2255
+ ) ?? selectableRail[0] ?? 0;
2256
+ let railTop = 0;
2257
+
2258
+ // ---- pane state ----------------------------------------------------------
2259
+ let all: PaneItem<T>[] = [];
2260
+ let open = new Set<string>();
2261
+ let query = "";
2262
+ let cursor = 0;
2263
+ let paneTop = 0;
2264
+ /** In the rail, or in the list. */
2265
+ let focus: "rail" | "pane" = "pane";
2266
+
2267
+ type PaneRow =
2268
+ | { kind: "section"; name: string; count: number }
2269
+ | { kind: "item"; item: PaneItem<T>; positions: number[] };
2270
+
2271
+ let rows: PaneRow[] = [];
2272
+
2273
+ const rebuild = () => {
2274
+ const ranked = query === ""
2275
+ ? all.map((item) => ({ item, positions: [] as number[], score: 0 }))
2276
+ : all
2277
+ .map((item) => {
2278
+ const hay = item.keywords ? `${item.label} ${item.keywords}` : item.label;
2279
+ const m = fuzzy(hay, query);
2280
+ return m ? { item, positions: m.positions, score: m.score } : null;
2281
+ })
2282
+ .filter((x): x is NonNullable<typeof x> => x !== null)
2283
+ .sort((a, b) => b.score - a.score);
2284
+
2285
+ const out: PaneRow[] = [];
2286
+ const sections = new Map<string, number>();
2287
+ for (const r of ranked) {
2288
+ const section = r.item.section;
2289
+ if (section === undefined) {
2290
+ out.push({ kind: "item", item: r.item, positions: r.positions });
2291
+ continue;
2292
+ }
2293
+ sections.set(section, (sections.get(section) ?? 0) + 1);
2294
+ }
2295
+ // Filtering opens every section: a row you cannot see is a row your query did not find, and
2296
+ // "no matches" while the match sits folded away is the worst possible answer.
2297
+ for (const [name, count] of sections) {
2298
+ const expanded = open.has(name) || query !== "";
2299
+ out.push({ kind: "section", name, count });
2300
+ if (!expanded) continue;
2301
+ for (const r of ranked) {
2302
+ if (r.item.section === name) out.push({ kind: "item", item: r.item, positions: r.positions });
2303
+ }
2304
+ }
2305
+ rows = out;
2306
+ cursor = Math.min(cursor, Math.max(0, rows.length - 1));
2307
+ };
2308
+
2309
+ // Bumped on every rail move, so a slow load cannot land after a faster one that came later.
2310
+ let generation = 0;
2311
+
2312
+ const load = async () => {
2313
+ const mine = ++generation;
2314
+ const entry = railRows[railAt];
2315
+ const got = entry?.kind === "entry" ? await opts.items(entry.item.value).catch(() => []) : [];
2316
+ // Holding a movement key starts one load per row, and they finish in whatever order the
2317
+ // network allows — so the last *answer* is routinely not the last *question*. Without this,
2318
+ // pressing down eight times lands you on a provider showing the empty list of a provider you
2319
+ // passed through on the way, which reads as "this one has no models".
2320
+ if (mine !== generation) return;
2321
+ all = got;
2322
+ rebuild();
2323
+ cursor = Math.max(0, Math.min(opts.itemAt?.(all) ?? 0, Math.max(0, rows.length - 1)));
2324
+ // Land on something selectable rather than on a section header.
2325
+ if (rows[cursor]?.kind !== "item") cursor = rows.findIndex((r) => r.kind === "item");
2326
+ if (cursor < 0) cursor = 0;
2327
+ paneTop = 0;
2328
+ };
2329
+
2330
+ // ---- drawing -------------------------------------------------------------
2331
+ const RULE_V = "│";
2332
+ const railCell = (row: RailRow | undefined, on: boolean): { text: string; marks: Mark[] } => {
2333
+ const marks: Mark[] = [];
2334
+ if (!row) return { text: padToWidth("", railWidth), marks };
2335
+ if (row.kind === "heading") {
2336
+ const text = padToWidth(` ${row.text}`, railWidth);
2337
+ marks.push({ from: 0, to: byteLength(text), hl: "Sidebar.Heading" });
2338
+ return { text, marks };
2339
+ }
2340
+ const { item } = row;
2341
+ const mark = item.mark?.text ?? " ";
2342
+ const badge = item.badge?.text ?? "";
2343
+ const room = railWidth - 3 - width(mark) - width(badge) - 1;
2344
+ const label = clipToWidth(item.label, Math.max(1, room));
2345
+ const head = `${on ? "❯" : " "} ${mark} `;
2346
+ const body = `${head}${label}`;
2347
+ const text = padToWidth(body, railWidth - width(badge) - 1) + badge + " ";
2348
+ if (item.mark) {
2349
+ const at = byteLength(`${on ? "❯" : " "} `);
2350
+ marks.push({ from: at, to: at + byteLength(mark), hl: item.mark.hl ?? "Normal" });
2351
+ }
2352
+ if (item.badge) {
2353
+ const at = byteLength(padToWidth(body, railWidth - width(badge) - 1));
2354
+ marks.push({ from: at, to: at + byteLength(badge), hl: item.badge.hl ?? "Comment" });
2355
+ }
2356
+ if (on) {
2357
+ marks.push({ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 });
2358
+ } else if (item.disabled) {
2359
+ marks.push({ from: 0, to: byteLength(text), hl: "Comment" });
2360
+ }
2361
+ return { text, marks };
2362
+ };
2363
+
2364
+ /**
2365
+ * One row of the list, and — when the cursor is on it — the rest of what it had to say.
2366
+ *
2367
+ * `more` is what makes the three columns bearable. A model row is a name, a rung and a sentence
2368
+ * about it inside 45% of half a float, so the sentence is the first thing cut and the sentence is
2369
+ * the whole reason a catalogue of forty models is choosable at all. It goes under the row rather
2370
+ * than beside it, because the columns are what let you read *down* the list and widening one for
2371
+ * the row you are on would move the other two every time you pressed a key.
2372
+ */
2373
+ const paneCell = (
2374
+ row: PaneRow | undefined,
2375
+ on: boolean,
2376
+ ): { text: string; marks: Mark[]; more: string[] } => {
2377
+ const marks: Mark[] = [];
2378
+ const more: string[] = [];
2379
+ if (!row) return { text: "", marks, more };
2380
+ if (row.kind === "section") {
2381
+ const text = ` ${open.has(row.name) || query !== "" ? "▾" : "▸"} ${row.count} ${row.name}`;
2382
+ marks.push({ from: 0, to: byteLength(text), hl: on ? "Picker.Selected" : "Comment" });
2383
+ return { text, marks, more };
2384
+ }
2385
+ const { item } = row;
2386
+ const badge = item.badge?.text ?? "";
2387
+ const detail = item.detail ?? "";
2388
+ // Three columns: label, badge, detail. The badge column is fixed so the rungs of the ladder
2389
+ // line up — reading down it is how you see the shape of what a provider offers.
2390
+ const badgeWidth = badge === "" ? 0 : 10;
2391
+ const labelWidth = Math.max(10, Math.floor((paneWidth - 3 - badgeWidth) * 0.45));
2392
+ const head = on ? " ❯ " : " ";
2393
+ const room = paneWidth - width(head) - labelWidth - badgeWidth;
2394
+ // On the cursor's row the columns are broken at words and continued underneath; everywhere else
2395
+ // they are clipped, because a list of forty models is read by running an eye down it.
2396
+ const [labelHead, labelRest] = on
2397
+ ? takeWords(item.label, labelWidth)
2398
+ : [clipToWidth(item.label, labelWidth), ""];
2399
+ const [detailHead, detailRest] = on
2400
+ ? takeWords(detail, Math.max(0, room))
2401
+ : [clipToWidth(detail, Math.max(0, room)), ""];
2402
+ const label = padToWidth(labelHead, labelWidth);
2403
+ const badgeCell = badgeWidth === 0 ? "" : padToWidth(clipToWidth(badge, badgeWidth), badgeWidth);
2404
+ const text = `${head}${label}${badgeCell}${detailHead}`;
2405
+
2406
+ const labelAt = byteLength(head);
2407
+ const offsets = byteOffsets(item.label);
2408
+ for (const at of row.positions) {
2409
+ const from = offsets[at];
2410
+ const to = offsets[at + 1];
2411
+ if (from === undefined || to === undefined) continue;
2412
+ marks.push({ from: labelAt + from, to: labelAt + to, hl: "Picker.Match", priority: 200 });
2413
+ }
2414
+ if (badgeWidth > 0) {
2415
+ const at = labelAt + byteLength(label);
2416
+ marks.push({ from: at, to: at + byteLength(badgeCell), hl: item.badge?.hl ?? "Comment" });
2417
+ }
2418
+ if (detailHead !== "") {
2419
+ const at = labelAt + byteLength(label) + byteLength(badgeCell);
2420
+ marks.push({ from: at, to: byteLength(text), hl: "Picker.Detail" });
2421
+ }
2422
+ if (item.disabled) marks.push({ from: 0, to: byteLength(text), hl: "Comment" });
2423
+ if (on) marks.push({ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 });
2424
+
2425
+ if (on) {
2426
+ // The name first, if the name itself did not fit — an id is what you are choosing by, and
2427
+ // half of one is not a thing you can choose by.
2428
+ const nameCol = width(head);
2429
+ if (labelRest.trim() !== "") {
2430
+ for (const line of wrapToWidth(labelRest.trim(), Math.max(8, paneWidth - nameCol - 2))) {
2431
+ more.push(`${" ".repeat(nameCol)}${line}`);
2432
+ }
2433
+ }
2434
+ const detailCol = nameCol + labelWidth + badgeWidth;
2435
+ if (detailRest.trim() !== "") {
2436
+ for (const line of wrapToWidth(detailRest.trim(), Math.max(8, paneWidth - detailCol - 1))) {
2437
+ more.push(`${" ".repeat(detailCol)}${line}`);
2438
+ }
2439
+ }
2440
+ }
2441
+ return { text, marks, more };
2442
+ };
2443
+
2444
+ interface Mark {
2445
+ from: number;
2446
+ to: number;
2447
+ hl: string;
2448
+ priority?: number;
2449
+ }
2450
+
2451
+ const render = async () => {
2452
+ // Each pane scrolls on its own: moving down a long model list must not scroll the rail out
2453
+ // from under the provider you are looking at.
2454
+ if (railAt < railTop) railTop = railAt;
2455
+ if (railAt >= railTop + height) railTop = railAt - height + 1;
2456
+ // The unfolded row is paid for out of the list rather than out of the float: growing the float
2457
+ // instead would move the key strip under the reader's eyes on every keystroke, and the strip
2458
+ // is the row that says how to get out.
2459
+ const unfolded = focus === "pane" ? paneCell(rows[cursor], true).more.length : 0;
2460
+ const room = Math.max(1, height - unfolded);
2461
+ if (cursor < paneTop) paneTop = cursor;
2462
+ if (cursor >= paneTop + room) paneTop = cursor - room + 1;
2463
+
2464
+ const lines: string[] = [];
2465
+ const marks: { line: number; mark: Mark }[] = [];
2466
+
2467
+ // The title row, and at its right edge the key for *the pane you are not in*. Live rather
2468
+ // than a legend: the shortcut row below can only say the key exists, and a two-pane widget
2469
+ // whose second pane has no visible way in is a pane nobody finds.
2470
+ const title = opts.title ?? "";
2471
+ const cross = focus === "pane" ? `${keyLabel(keys, "pane_prev")} ${railLabel}` : `${keyLabel(keys, "pane_next")} ${paneLabel}`;
2472
+ const gap = Math.max(1, total - width(title) - width(cross));
2473
+ lines.push(`${title}${" ".repeat(gap)}${cross}`);
2474
+ marks.push({ line: 0, mark: { from: 0, to: byteLength(title), hl: "Float.Title" } });
2475
+ marks.push({
2476
+ line: 0,
2477
+ mark: {
2478
+ from: byteLength(title) + gap,
2479
+ to: byteLength(lines[0]!),
2480
+ hl: "Composer.HintKey",
2481
+ },
2482
+ });
2483
+ lines.push(`> ${query}`);
2484
+ lines.push("─".repeat(railWidth) + "┬" + "─".repeat(paneWidth));
2485
+ marks.push({ line: 2, mark: { from: 0, to: byteLength(lines[2]!), hl: "Separator" } });
2486
+
2487
+ const empty = rows.length === 0
2488
+ ? ` ${query === "" ? (opts.placeholder ?? "nothing here") : "no matches"}`
2489
+ : null;
2490
+
2491
+ // The two columns are filled independently and then zipped, because they no longer advance
2492
+ // together: the pane's cursor row is several lines tall when it has unfolded, and the rail
2493
+ // beside it must keep listing providers one per line rather than skipping the ones the
2494
+ // unfolded row happened to sit across.
2495
+ const paneLines: { text: string; marks: Mark[] }[] = [];
2496
+ if (empty !== null) {
2497
+ paneLines.push({ text: empty, marks: [{ from: 0, to: byteLength(empty), hl: "Comment" }] });
2498
+ } else {
2499
+ for (let i = paneTop; i < rows.length && paneLines.length < height; i++) {
2500
+ const on = focus === "pane" && i === cursor;
2501
+ const cell = paneCell(rows[i], on);
2502
+ paneLines.push({ text: cell.text, marks: cell.marks });
2503
+ if (!on) continue;
2504
+ for (const text of cell.more) {
2505
+ paneLines.push({
2506
+ text,
2507
+ marks: [{ from: 0, to: byteLength(text), hl: "Picker.Selected", priority: 1 }],
2508
+ });
2509
+ }
2510
+ }
2511
+ }
2512
+
2513
+ for (let i = 0; i < height; i++) {
2514
+ const left = railCell(railRows[railTop + i], focus === "rail" && railTop + i === railAt);
2515
+ const right = paneLines[i] ?? { text: "", marks: [] as Mark[] };
2516
+ const line = lines.length;
2517
+ lines.push(`${left.text}${RULE_V}${right.text}`);
2518
+ for (const m of left.marks) marks.push({ line, mark: m });
2519
+ const shift = byteLength(left.text) + byteLength(RULE_V);
2520
+ marks.push({ line, mark: { from: byteLength(left.text), to: shift, hl: "Separator" } });
2521
+ for (const m of right.marks) {
2522
+ marks.push({ line, mark: { from: m.from + shift, to: m.to + shift, hl: m.hl, priority: m.priority } });
2523
+ }
2524
+ }
2525
+
2526
+ lines.push("─".repeat(railWidth) + "┴" + "─".repeat(paneWidth));
2527
+ marks.push({
2528
+ line: lines.length - 1,
2529
+ mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Separator" },
2530
+ });
2531
+ const hints = opts.hints ?? "↵ use ⇥ panes ^N/^P move esc close";
2532
+ lines.push(` ${hints}`);
2533
+ marks.push({
2534
+ line: lines.length - 1,
2535
+ mark: { from: 0, to: byteLength(lines[lines.length - 1]!), hl: "Sidebar.Dim" },
2536
+ });
2537
+
2538
+ // The marks were already collected against their rows; they now travel with them. One call
2539
+ // rather than one per mark, so there is no frame in which the text has arrived and the colour
2540
+ // has not — an unmarked row draws in `Normal`, which is near-white.
2541
+ const drawn: DrawnRow[] = lines.map((text) => ({ text, marks: [] }));
2542
+ for (const { line, mark } of marks) {
2543
+ drawn[line]?.marks!.push({
2544
+ col: mark.from,
2545
+ opts: { hlGroup: mark.hl, endCol: mark.to, priority: mark.priority ?? 0 },
2546
+ });
2547
+ }
2548
+ await neosh.buf.render(buf, ns, 0, -1, drawn);
2549
+ // The caret belongs where the typing goes. Parked at the origin, the filter reads as inert.
2550
+ await neosh.win.setCursor(win, 1, byteLength(`> ${query}`));
2551
+ };
2552
+
2553
+ // ---- keys ----------------------------------------------------------------
2554
+ let settle: (v: T | null) => void = () => {};
2555
+ const done = new Promise<T | null>((resolve) => {
2556
+ settle = resolve;
2557
+ });
2558
+
2559
+ const command = `${RAIL_NS}.key.${++pickerSeq}`;
2560
+ const disposers: Disposable[] = [];
2561
+ let closed = false;
2562
+ const close = async (value: T | null) => {
2563
+ if (closed) return;
2564
+ closed = true;
2565
+ for (const d of disposers) d.dispose();
2566
+ await neosh.win.close(win).catch(() => {});
2567
+ settle(value);
2568
+ };
2569
+
2570
+ const moveRail = async (delta: number) => {
2571
+ const at = selectableRail.indexOf(railAt);
2572
+ const next = selectableRail[Math.min(selectableRail.length - 1, Math.max(0, at + delta))];
2573
+ if (next === undefined || next === railAt) return;
2574
+ railAt = next;
2575
+ // The filter belongs to the list that was showing. Carrying it to a different provider hides
2576
+ // most of what you just switched to, for a reason that has scrolled off the screen.
2577
+ query = "";
2578
+ await load();
2579
+ };
2580
+
2581
+ /**
2582
+ * Move the list cursor, stepping over rows that cannot be landed on.
2583
+ *
2584
+ * Section headers *are* landable — `↵` on one folds it — so the only thing skipped is a row the
2585
+ * caller marked unusable. Skipping in the direction of travel, and giving up at the end rather
2586
+ * than reversing, so holding a movement key never bounces.
2587
+ */
2588
+ const movePane = (delta: number) => {
2589
+ const step = delta > 0 ? 1 : -1;
2590
+ let at = cursor;
2591
+ for (let n = 0; n < Math.abs(delta); n++) {
2592
+ let candidate = at + step;
2593
+ while (candidate >= 0 && candidate < rows.length) {
2594
+ const row = rows[candidate];
2595
+ if (row && !(row.kind === "item" && row.item.disabled)) break;
2596
+ candidate += step;
2597
+ }
2598
+ if (candidate < 0 || candidate >= rows.length) break;
2599
+ at = candidate;
2600
+ }
2601
+ cursor = Math.max(0, Math.min(Math.max(0, rows.length - 1), at));
2602
+ };
2603
+
2604
+ const currentItem = (): PaneItem<T> | undefined => {
2605
+ const row = rows[cursor];
2606
+ return row?.kind === "item" ? row.item : undefined;
2607
+ };
2608
+
2609
+ disposers.push(
2610
+ await neosh.cmd.register(command, async (_args, key) => {
2611
+ if (!key) return;
2612
+ const railEntry = railRows[railAt];
2613
+ const outcome = await opts.onKey?.(key, {
2614
+ rail: railEntry?.kind === "entry" ? railEntry.item.value : undefined,
2615
+ item: currentItem()?.value,
2616
+ });
2617
+ if (outcome === "close") {
2618
+ await close(null);
2619
+ return;
2620
+ }
2621
+ if (outcome === "reload") {
2622
+ await load();
2623
+ await render();
2624
+ return;
2625
+ }
2626
+ if (outcome === "handled") {
2627
+ await render();
2628
+ return;
2629
+ }
2630
+
2631
+ switch (actionFor(keys, key.key, RAIL_ACTIONS)) {
2632
+ case "dismiss":
2633
+ await close(null);
2634
+ return;
2635
+ case "accept": {
2636
+ const row = rows[cursor];
2637
+ if (focus === "rail") {
2638
+ focus = "pane";
2639
+ break;
2640
+ }
2641
+ if (row?.kind === "section") {
2642
+ if (open.has(row.name)) open.delete(row.name);
2643
+ else open.add(row.name);
2644
+ rebuild();
2645
+ break;
2646
+ }
2647
+ const item = row?.kind === "item" ? row.item : undefined;
2648
+ if (!item || item.disabled) return;
2649
+ await close(item.value);
2650
+ return;
2651
+ }
2652
+ case "pane_next":
2653
+ focus = "pane";
2654
+ break;
2655
+ case "pane_prev":
2656
+ focus = "rail";
2657
+ break;
2658
+ case "next":
2659
+ if (focus === "rail") await moveRail(1);
2660
+ else movePane(1);
2661
+ break;
2662
+ case "prev":
2663
+ if (focus === "rail") await moveRail(-1);
2664
+ else movePane(-1);
2665
+ break;
2666
+ case "page_down":
2667
+ if (focus === "pane") movePane(height);
2668
+ break;
2669
+ case "page_up":
2670
+ if (focus === "pane") movePane(-height);
2671
+ break;
2672
+ case "first":
2673
+ if (focus === "pane") cursor = Math.max(0, rows.findIndex((r) => r.kind === "item"));
2674
+ break;
2675
+ case "last":
2676
+ if (focus === "pane") cursor = rows.length - 1;
2677
+ break;
2678
+ case "clear":
2679
+ query = "";
2680
+ rebuild();
2681
+ break;
2682
+ case "delete_word":
2683
+ query = dropSegment(query);
2684
+ rebuild();
2685
+ break;
2686
+ default: {
2687
+ if (key.key.code.kind === "backspace") {
2688
+ query = query.slice(0, -1);
2689
+ rebuild();
2690
+ break;
2691
+ }
2692
+ if (key.key.code.kind !== "char" || key.key.mods.ctrl || key.key.mods.alt) return;
2693
+ // Typing is about the list, wherever the focus is: nobody switches panes first.
2694
+ focus = "pane";
2695
+ query += key.key.code.c;
2696
+ cursor = 0;
2697
+ rebuild();
2698
+ if (rows[cursor]?.kind !== "item") movePane(1);
2699
+ break;
2700
+ }
2701
+ }
2702
+ await render();
2703
+ }, { desc: "rail picker key" }),
2704
+ );
2705
+
2706
+ await neosh.focus.push(win);
2707
+ disposers.push(await neosh.keymap.capture(win, command));
2708
+ await bindWidgetKeys(neosh, win, command, keys, opts.ownKeys ?? []);
2709
+ await load();
2710
+ await render();
2711
+ return done;
2712
+ }
2713
+
2714
+
2715
+ // ---------------------------------------------------------------------------
2716
+ // What a panel owes the plugins that build on it
2717
+ //
2718
+ // A section somebody contributed, placed by name; a decoration somebody put on one of the panel's
2719
+ // own rows; the row itself with the decoration applied. Three functions any list panel can use —
2720
+ // the bundled sidebar does — so that `sidebar.section`, `acme.tasks.section` and every other
2721
+ // `<kind>.section` mean exactly the same thing to the plugin contributing to them.
2722
+ // ---------------------------------------------------------------------------
2723
+
2724
+ /** A block of rows somebody else contributed to a panel. Data, so it can be listed and disabled. */
2725
+ export interface SectionItem {
2726
+ /** Drawn as a heading with a rule under it. Omit for rows with no heading. */
2727
+ title?: string;
2728
+ /** The way in, drawn dim at the right of the heading: `^L`, `^G`. */
2729
+ hint?: string;
2730
+ /** Coarse placement relative to the panel's own blocks. Defaults to `below`. */
2731
+ at?: "above" | "below";
2732
+ /** Finer: sit directly before or after one of the panel's slots, or another section's id. */
2733
+ before?: string;
2734
+ after?: string;
2735
+ rows?: Array<{
2736
+ text: string;
2737
+ hl?: string;
2738
+ /** Highlights for pieces of the row: UTF-8 byte offsets into *your* `text`. */
2739
+ spans?: Array<{ from: number; to: number; hl: string }>;
2740
+ right?: { text: string; hl?: string };
2741
+ /** Run on `↵`. Without one the row is inert — a label rather than a verb. */
2742
+ command?: string;
2743
+ args?: string[];
2744
+ }>;
2745
+ }
2746
+
2747
+ /** A verb on a panel's rows, contributed by somebody else and bound by the panel. */
2748
+ export interface ActionItem {
2749
+ /** Key notation, as `keymap.set` takes it. */
2750
+ key: string;
2751
+ /** For the hint strip and `^Z`. */
2752
+ label: string;
2753
+ command: string;
2754
+ /** Which rows it applies to — a row kind the panel names, `custom` for contributed rows, or `any`. */
2755
+ on?: string;
2756
+ }
2757
+
2758
+ /** A mark on a row a panel already draws, keyed by what the row is about. */
2759
+ export interface DecorationItem {
2760
+ /** The row it is about, in the panel's own terms: `{ project: cwd }`, `{ task: id }`. */
2761
+ target: Record<string, string>;
2762
+ /** A short mark after the name, in `hl`. The name is clipped to make room. */
2763
+ badge?: { text: string; hl?: string };
2764
+ /** The row's highlight, when the panel has no opinion of its own. */
2765
+ hl?: string;
2766
+ /** The right-hand column, on a row that is not busy. */
2767
+ right?: { text: string; hl?: string };
2768
+ }
2769
+
2770
+ /** Every decoration on one target, merged. */
2771
+ export interface Decoration {
2772
+ badge?: { text: string; hl?: string };
2773
+ hl?: string;
2774
+ right?: { text: string; hl?: string };
2775
+ }
2776
+
2777
+ /** The key a decoration's `target` files under: `project:/w/x`, `task:17`. */
2778
+ export function decorationKey(target: unknown): string | null {
2779
+ if (!target || typeof target !== "object") return null;
2780
+ const entries = Object.entries(target as Record<string, unknown>)
2781
+ .filter(([, v]) => typeof v === "string")
2782
+ .sort(([a], [b]) => a.localeCompare(b));
2783
+ const first = entries[0];
2784
+ return first ? `${first[0]}:${first[1] as string}` : null;
2785
+ }
2786
+
2787
+ /**
2788
+ * Every decoration on a point, folded by target: later contributions (lower priority) fill in what
2789
+ * earlier ones left unsaid, and badges are joined with a space, because two plugins each with one
2790
+ * word to say about a row are both right.
2791
+ */
2792
+ export function mergeDecorations(
2793
+ items: Array<Contribution & { item: DecorationItem }>,
2794
+ ): Map<string, Decoration> {
2795
+ const out = new Map<string, Decoration>();
2796
+ for (const c of items) {
2797
+ const key = decorationKey(c.item?.target);
2798
+ if (key === null) continue;
2799
+ const d = out.get(key) ?? {};
2800
+ const badge = c.item.badge;
2801
+ if (typeof badge?.text === "string" && badge.text !== "") {
2802
+ d.badge = d.badge
2803
+ ? { text: `${d.badge.text} ${badge.text}`, hl: d.badge.hl }
2804
+ : { text: badge.text, hl: badge.hl };
2805
+ }
2806
+ if (d.hl === undefined && typeof c.item.hl === "string") d.hl = c.item.hl;
2807
+ if (d.right === undefined && typeof c.item.right?.text === "string") {
2808
+ d.right = { text: c.item.right.text, hl: c.item.right.hl };
2809
+ }
2810
+ out.set(key, d);
2811
+ }
2812
+ return out;
2813
+ }
2814
+
2815
+ /** How many columns a decoration's badge will take, for a row builder to leave free. */
2816
+ export function badgeWidth(d: Decoration | undefined): number {
2817
+ return d?.badge ? byteLength(` ${d.badge.text}`) : 0;
2818
+ }
2819
+
2820
+ /**
2821
+ * A decoration, applied to a row the panel built.
2822
+ *
2823
+ * The badge goes on the end of the clipped text with its own span — the builder left room for it
2824
+ * with {@link badgeWidth}. `hl` only fills a row the panel left plain; `right` only replaces the
2825
+ * column on a row that is not `busy`, because a row's own state — working, asking, failed — is
2826
+ * what the column exists to show.
2827
+ */
2828
+ export function decorateRow<T>(
2829
+ row: ListRow<T>,
2830
+ d: Decoration | undefined,
2831
+ busy = false,
2832
+ ): ListRow<T> {
2833
+ if (!d) return row;
2834
+ if (d.badge) {
2835
+ const mark = ` ${d.badge.text}`;
2836
+ const from = byteLength(row.text);
2837
+ row.text = `${row.text}${mark}`;
2838
+ if (row.full !== undefined) row.full = `${row.full}${mark}`;
2839
+ if (d.badge.hl) {
2840
+ row.spans = [...(row.spans ?? []), { from, to: from + byteLength(mark), hl: d.badge.hl }];
2841
+ }
2842
+ }
2843
+ if (d.hl && row.hl === undefined) row.hl = d.hl;
2844
+ if (d.right && !busy) row.right = { text: `${d.right.text} `, hl: d.right.hl };
2845
+ return row;
2846
+ }
2847
+
2848
+ /**
2849
+ * Where every contributed section goes, as a walk over a panel's slots.
2850
+ *
2851
+ * `before`/`after` names a slot or another section's id; `at` is the coarse version. An anchor on
2852
+ * another section resolves once that section has found its place, so a chain settles in a pass or
2853
+ * two, and a section whose anchor never appears falls back to `at`. Sections with the same
2854
+ * placement keep the registry's priority order.
2855
+ */
2856
+ export function placeSections<S extends string>(
2857
+ slots: readonly S[],
2858
+ sections: Array<Contribution & { item: SectionItem }>,
2859
+ ): Array<S | (Contribution & { item: SectionItem })> {
2860
+ type Entry = S | (Contribution & { item: SectionItem });
2861
+ const order: Entry[] = [...slots];
2862
+ const idOf = (e: Entry) => (typeof e === "string" ? e : e.id);
2863
+ const indexOf = (name: string) => order.findIndex((e) => idOf(e) === name);
2864
+ const first = slots[0];
2865
+
2866
+ let pending = sections.filter((c) => c.item && typeof c.item === "object");
2867
+ for (let pass = 0; pass < 8 && pending.length > 0; pass++) {
2868
+ const next: typeof pending = [];
2869
+ for (const c of pending) {
2870
+ const before = typeof c.item.before === "string" ? indexOf(c.item.before) : -1;
2871
+ const after = typeof c.item.after === "string" ? indexOf(c.item.after) : -1;
2872
+ if (before >= 0) order.splice(before, 0, c);
2873
+ else if (after >= 0) order.splice(after + 1, 0, c);
2874
+ else if (typeof c.item.before === "string" || typeof c.item.after === "string") {
2875
+ next.push(c);
2876
+ } else if ((c.item.at ?? "below") === "above" && first !== undefined) {
2877
+ order.splice(indexOf(first), 0, c);
2878
+ } else {
2879
+ order.push(c);
2880
+ }
2881
+ }
2882
+ if (next.length === pending.length) {
2883
+ for (const c of next) {
2884
+ if ((c.item.at ?? "below") === "above" && first !== undefined) {
2885
+ order.splice(indexOf(first), 0, c);
2886
+ } else order.push(c);
2887
+ }
2888
+ break;
2889
+ }
2890
+ pending = next;
2891
+ }
2892
+ return order;
2893
+ }
2894
+
2895
+ /**
2896
+ * Rows for a contributed section, every field checked rather than trusted.
2897
+ *
2898
+ * A contribution is JSON from a plugin the panel has never heard of, and a panel that throws on a
2899
+ * missing `text` is a panel a third party can break by getting one row wrong.
2900
+ */
2901
+ export function sectionRows<T>(
2902
+ c: Contribution & { item: SectionItem },
2903
+ opts: { width: number; custom: (command: string, args: string[]) => T },
2904
+ ): ListRow<T>[] {
2905
+ const rows: ListRow<T>[] = [];
2906
+ const contributed = Array.isArray(c.item?.rows) ? c.item.rows : [];
2907
+ if (contributed.length === 0 && !c.item?.title) return rows;
2908
+ rows.push({ text: "", inert: true });
2909
+ if (typeof c.item.title === "string" && c.item.title !== "") {
2910
+ const hint = typeof c.item.hint === "string" ? c.item.hint : undefined;
2911
+ rows.push({
2912
+ text: ` ${clipToWidth(c.item.title, Math.max(1, opts.width - 2))}`,
2913
+ hl: "Sidebar.Heading",
2914
+ right: hint ? { text: `${hint} `, hl: "Sidebar.Dim" } : undefined,
2915
+ inert: true,
2916
+ });
2917
+ rows.push({ text: "─".repeat(Math.max(1, opts.width)), hl: "Separator", inert: true });
2918
+ }
2919
+ const margin = " ";
2920
+ for (const r of contributed) {
2921
+ if (typeof r?.text !== "string") continue;
2922
+ const text = `${margin}${clipToWidth(r.text, Math.max(4, opts.width - 2 - margin.length))}`;
2923
+ const eol = byteLength(text);
2924
+ const spans = Array.isArray(r.spans)
2925
+ ? r.spans.flatMap((sp) => {
2926
+ if (typeof sp?.from !== "number" || typeof sp?.to !== "number" || typeof sp?.hl !== "string") {
2927
+ return [];
2928
+ }
2929
+ const from = Math.max(0, Math.floor(sp.from)) + byteLength(margin);
2930
+ const to = Math.min(Math.max(0, Math.floor(sp.to)) + byteLength(margin), eol);
2931
+ return to > from ? [{ from, to, hl: sp.hl }] : [];
2932
+ })
2933
+ : [];
2934
+ const args = Array.isArray(r.args) ? r.args.filter((a): a is string => typeof a === "string") : [];
2935
+ rows.push({
2936
+ text,
2937
+ full: `${margin}${r.text}`,
2938
+ indent: margin.length,
2939
+ hl: typeof r.hl === "string" ? r.hl : undefined,
2940
+ spans: spans.length > 0 ? spans : undefined,
2941
+ right: typeof r.right?.text === "string" ? { text: `${r.right.text} `, hl: r.right.hl } : undefined,
2942
+ inert: typeof r.command !== "string",
2943
+ value: typeof r.command === "string" ? opts.custom(r.command, args) : undefined,
2944
+ });
2945
+ }
2946
+ return rows;
2947
+ }
2948
+
2949
+ // ---------------------------------------------------------------------------
2950
+ // ListPanel
2951
+ // ---------------------------------------------------------------------------
2952
+
2953
+ /** What a {@link ListPanel} needs to know about its own rows. */
2954
+ export interface ListPanelOptions<T> {
2955
+ /** The buffer kind — `acme.tasks`. Everything else hangs off it: the points are
2956
+ * `<kind>.section`, `<kind>.action`, `<kind>.decoration`; the verbs are `<kind>.up` and so on;
2957
+ * the cursor event is `<kind>.cursor`. */
2958
+ kind: string;
2959
+ /** Buffer name. Defaults to `[<kind>]`. */
2960
+ name?: string;
2961
+ dock?: "left" | "right" | "bottom";
2962
+ /** Columns (or rows, for a bottom dock). Asked on every open and draw. */
2963
+ size?: () => number | Promise<number>;
2964
+ /** The panel's own rows, rebuilt on every draw. A row's `value` is what every verb receives. */
2965
+ rows: () => ListRow<T>[] | Promise<ListRow<T>[]>;
2966
+ /** A row's identity, for anchoring the cursor across redraws and filing decorations:
2967
+ * `{ project: cwd }` becomes `project:<cwd>`. */
2968
+ key?: (value: T) => Record<string, string> | null;
2969
+ /** The row kind a contributed action's `on` may name: `"project"`, `"session"`. */
2970
+ kindOf?: (value: T) => string;
2971
+ /** The arguments a contributed action receives for a row. Defaults to `[kindOf(value), ...key values]`. */
2972
+ argsFor?: (value: T) => string[];
2973
+ /** `↵` on a row. A contributed row runs its own command instead. */
2974
+ onOpen?: (value: T) => void | Promise<void>;
2975
+ /** Named blocks a section may sit `before`/`after`. Defaults to `["main"]`; `rows` fill the first. */
2976
+ slots?: readonly string[];
2977
+ cursorHl?: string;
2978
+ /** Open the panel as soon as it is created. Default true. */
2979
+ open?: boolean;
2980
+ }
2981
+
2982
+ /** A contributed row's value, for panels whose own rows are something else. */
2983
+ export type CustomRow = { kind: "custom"; command: string; args: string[] };
2984
+
2985
+ /**
2986
+ * A docked list panel that is a surface, not a program — the three panel mechanisms, and a
2987
+ * published cursor, for the price of a kind and a `rows` function.
2988
+ *
2989
+ * Given `kind: "acme.tasks"`, this creates the buffer with that kind, opens it in a dock, binds
2990
+ * every verb as a named command at `buf_kind` scope (`acme.tasks.down`, `.up`, `.first`, `.last`,
2991
+ * `.open`, `.leave`, `.toggle`, `.focus`, `.refresh`, `.cursor`, `.rows`) so `^Z` lists them and
2992
+ * `init.ts` can move them, reads `acme.tasks.section` / `.action` / `.decoration` exactly as the
2993
+ * sidebar reads its own, publishes the row under the cursor as the buffer var `cursor` and the
2994
+ * event `acme.tasks.cursor`, and redraws when any of that changes. What is left for the plugin is
2995
+ * what the rows say.
2996
+ */
2997
+ export class ListPanel<T> {
2998
+ private buf!: BufferId;
2999
+ private ns!: number;
3000
+ private list!: CursoredList<T | CustomRow>;
3001
+ private win: WindowId | null = null;
3002
+ private focused = false;
3003
+ private capture: Disposable | null = null;
3004
+ private width = 30;
3005
+ private actions: Array<Contribution & { item: ActionItem }> = [];
3006
+ private bound: Array<{ key: string; command: string }> = [];
3007
+ private registered: Disposable[] = [];
3008
+ private drawing = false;
3009
+ private again = false;
3010
+ readonly subscriptions: Disposable[] = [];
3011
+
3012
+ private constructor(
3013
+ private readonly neosh: Neosh,
3014
+ private readonly opts: ListPanelOptions<T>,
3015
+ ) {}
3016
+
3017
+ /** Create the panel: buffer, verbs, points, and — unless `open: false` — the window. */
3018
+ static async create<T>(neosh: Neosh, opts: ListPanelOptions<T>): Promise<ListPanel<T>> {
3019
+ const p = new ListPanel<T>(neosh, opts);
3020
+ await p.setup();
3021
+ if (opts.open !== false) await p.open();
3022
+ return p;
3023
+ }
3024
+
3025
+ get kind(): string {
3026
+ return this.opts.kind;
3027
+ }
3028
+
3029
+ /** The contribution points this panel reads. */
3030
+ get points(): { section: string; action: string; decoration: string } {
3031
+ const k = this.opts.kind;
3032
+ return { section: `${k}.section`, action: `${k}.action`, decoration: `${k}.decoration` };
3033
+ }
3034
+
3035
+ /** The row under the cursor, or `null`. */
3036
+ get cursor(): T | CustomRow | null {
3037
+ return this.list.value ?? null;
3038
+ }
3039
+
3040
+ /** Every row that can be landed on. */
3041
+ get rows(): Array<T | CustomRow> {
3042
+ return this.list.values;
3043
+ }
3044
+
3045
+ isOpen(): boolean {
3046
+ return this.win !== null;
3047
+ }
3048
+
3049
+ private keyOf(value: T | CustomRow): string | null {
3050
+ if (this.isCustom(value)) return null;
3051
+ return decorationKey(this.opts.key?.(value) ?? null);
3052
+ }
3053
+
3054
+ private isCustom(value: T | CustomRow): value is CustomRow {
3055
+ return typeof value === "object" && value !== null && (value as CustomRow).kind === "custom" &&
3056
+ typeof (value as CustomRow).command === "string";
3057
+ }
3058
+
3059
+ private async setup(): Promise<void> {
3060
+ const { neosh, opts } = this;
3061
+ const k = opts.kind;
3062
+ this.buf = await neosh.buf.create({ name: opts.name ?? `[${k}]`, scratch: true, kind: k });
3063
+ this.ns = await neosh.ns.create(k);
3064
+ this.list = new CursoredList<T | CustomRow>(neosh, this.buf, this.ns, {
3065
+ cursorHl: opts.cursorHl,
3066
+ width: () => this.width,
3067
+ onMove: () => this.published(),
3068
+ });
3069
+
3070
+ const scope = { kind: "buf_kind", name: k } as const;
3071
+ const verb = async (name: string, keys: string[], desc: string, fn: () => void | Promise<void>, redraw = true) => {
3072
+ this.subscriptions.push(
3073
+ await neosh.cmd.register(`${k}.${name}`, async () => {
3074
+ await fn();
3075
+ if (redraw) await this.draw();
3076
+ }, { desc }),
3077
+ );
3078
+ for (const key of keys) {
3079
+ await neosh.keymap.set("chat", key, `${k}.${name}`, { scope, desc });
3080
+ }
3081
+ };
3082
+ await verb("down", ["j", "<Down>", "<C-n>"], "Next row", () => this.list.move(1));
3083
+ await verb("up", ["k", "<Up>", "<C-p>"], "Previous row", () => this.list.move(-1));
3084
+ await verb("first", ["gg"], "First row", () => this.list.toEnd("first"));
3085
+ await verb("last", ["G"], "Last row", () => this.list.toEnd("last"));
3086
+ await verb("open", ["<CR>"], "Open the row under the cursor", async () => {
3087
+ const v = this.list.value;
3088
+ if (v === undefined) return;
3089
+ if (this.isCustom(v)) {
3090
+ await neosh.cmd.exec(v.command, v.args).catch((e: unknown) => neosh.notify(String(e), "warn"));
3091
+ } else {
3092
+ await opts.onOpen?.(v);
3093
+ }
3094
+ });
3095
+ await verb("leave", ["<Esc>", "q"], "Back to the composer", () => this.leave(), false);
3096
+ await verb("refresh", [], "Redraw the panel now", () => {}, true);
3097
+ this.subscriptions.push(
3098
+ await neosh.cmd.register(`${k}.toggle`, () => (this.isOpen() ? this.close() : this.open()), {
3099
+ desc: "Show or hide the panel",
3100
+ }),
3101
+ );
3102
+ this.subscriptions.push(
3103
+ await neosh.cmd.register(`${k}.focus`, () => this.enter(), { desc: "Move into the panel" }),
3104
+ );
3105
+ this.subscriptions.push(
3106
+ await neosh.cmd.register(`${k}.cursor`, () => this.cursor, { desc: "The row under the cursor" }),
3107
+ );
3108
+ this.subscriptions.push(
3109
+ await neosh.cmd.register(`${k}.rows`, () => this.rows, { desc: "Every row you can land on" }),
3110
+ );
3111
+ // A sink for the keys nothing claimed, so an unbound letter does not fall through to the
3112
+ // composer and start a message.
3113
+ this.subscriptions.push(
3114
+ await neosh.cmd.register(`${k}.key`, () => {}, { desc: "Swallow an unbound key in the panel" }),
3115
+ );
3116
+
3117
+ const { section, action, decoration } = this.points;
3118
+ this.subscriptions.push(
3119
+ neosh.ext.onChange((e) => {
3120
+ if (e.point === section || e.point === decoration) void this.draw();
3121
+ if (e.point === action) void this.syncActions();
3122
+ }),
3123
+ );
3124
+ this.subscriptions.push(
3125
+ neosh.event.on("neosh.viewport", (e) => {
3126
+ const d = e.data as { win?: number; width?: number } | null;
3127
+ if (d?.win === this.win && typeof d.width === "number") {
3128
+ this.width = d.width;
3129
+ void this.draw();
3130
+ }
3131
+ }),
3132
+ );
3133
+ await this.syncActions();
3134
+ this.subscriptions.push({
3135
+ dispose: () => {
3136
+ for (const d of this.registered) d.dispose();
3137
+ for (const b of this.bound) void neosh.keymap.del("chat", b.key, scope).catch(() => {});
3138
+ },
3139
+ });
3140
+ }
3141
+
3142
+ /** Bind the verbs other plugins contributed, on their behalf, with the row as arguments. */
3143
+ private async syncActions(): Promise<void> {
3144
+ const { neosh, opts } = this;
3145
+ const k = opts.kind;
3146
+ const scope = { kind: "buf_kind", name: k } as const;
3147
+ const got = await neosh.ext.list<ActionItem>(this.points.action).catch(() => []);
3148
+ for (const b of this.bound) await neosh.keymap.del("chat", b.key, scope).catch(() => {});
3149
+ for (const d of this.registered) d.dispose();
3150
+ this.bound = [];
3151
+ this.registered = [];
3152
+ const mine = new Set(
3153
+ (await neosh.keymap.list("chat").catch(() => []))
3154
+ .filter((m) => m.scope.kind === "buf_kind" && m.scope.name === k && !m.command.startsWith(`${k}.action.`))
3155
+ .map((m) => m.lhs),
3156
+ );
3157
+ this.actions = got.filter((c) => typeof c.item?.key === "string" && typeof c.item?.command === "string");
3158
+ for (const c of this.actions) {
3159
+ const name = `${k}.action.${c.plugin}.${c.id}`;
3160
+ const reg = await neosh.cmd.register(name, async () => {
3161
+ const v = this.list.value;
3162
+ if (v === undefined) return;
3163
+ const on = c.item.on ?? "any";
3164
+ const rowKind = this.isCustom(v) ? "custom" : opts.kindOf?.(v) ?? "row";
3165
+ if (on !== "any" && on !== rowKind) return;
3166
+ const args = this.isCustom(v)
3167
+ ? ["custom", ...v.args]
3168
+ : opts.argsFor?.(v) ?? [rowKind, ...Object.values(opts.key?.(v) ?? {})];
3169
+ await neosh.cmd.exec(c.item.command, args).catch((e: unknown) => neosh.notify(String(e), "warn"));
3170
+ await this.draw();
3171
+ }, { desc: c.item.label }).catch(() => null);
3172
+ if (reg) this.registered.push(reg);
3173
+ if (mine.has(c.item.key)) {
3174
+ neosh.log.warn(`${c.plugin} asked for '${c.item.key}' in ${k}, which is already a panel key`);
3175
+ continue;
3176
+ }
3177
+ await neosh.keymap.set("chat", c.item.key, name, { scope, desc: c.item.label }).catch(() => {});
3178
+ this.bound.push({ key: c.item.key, command: name });
3179
+ }
3180
+ await this.draw();
3181
+ }
3182
+
3183
+ private published(): void {
3184
+ const v = this.list.value ?? null;
3185
+ void this.neosh.vars.set({ scope: "buffer", buf: this.buf }, "cursor", v).catch(() => {});
3186
+ void this.neosh.event.emit(`${this.opts.kind}.cursor`, v);
3187
+ }
3188
+
3189
+ /** Redraw: the plugin's rows, decorated, with contributed sections placed among the slots. */
3190
+ async draw(): Promise<void> {
3191
+ if (this.win === null) return;
3192
+ if (this.drawing) {
3193
+ this.again = true;
3194
+ return;
3195
+ }
3196
+ this.drawing = true;
3197
+ try {
3198
+ do {
3199
+ this.again = false;
3200
+ const { neosh, opts } = this;
3201
+ const own = await opts.rows();
3202
+ const [sections, decorations] = await Promise.all([
3203
+ neosh.ext.list<SectionItem>(this.points.section).catch(() => []),
3204
+ neosh.ext.list<DecorationItem>(this.points.decoration).catch(() => []),
3205
+ ]);
3206
+ const merged = mergeDecorations(decorations);
3207
+ const slots = opts.slots ?? ["main"];
3208
+ const order = placeSections(slots, sections);
3209
+ const rows: ListRow<T | CustomRow>[] = [];
3210
+ let body = 0;
3211
+ for (const entry of order) {
3212
+ if (typeof entry === "string") {
3213
+ if (entry === slots[0]) {
3214
+ for (const r of own) {
3215
+ const key = r.value === undefined ? null : this.keyOf(r.value);
3216
+ rows.push(key === null ? r : decorateRow(r, merged.get(key)));
3217
+ }
3218
+ }
3219
+ body = rows.length;
3220
+ } else {
3221
+ rows.push(...sectionRows<T | CustomRow>(entry, {
3222
+ width: this.width,
3223
+ custom: (command, args) => ({ kind: "custom", command, args }),
3224
+ }));
3225
+ }
3226
+ }
3227
+ this.list.setRows(rows, (a, b) => {
3228
+ const ka = this.keyOf(a);
3229
+ return ka !== null && ka === this.keyOf(b);
3230
+ });
3231
+ await this.list.render({ showCursor: this.focused, win: this.win ?? undefined, pinned: rows.length - body });
3232
+ } while (this.again);
3233
+ } finally {
3234
+ this.drawing = false;
3235
+ }
3236
+ }
3237
+
3238
+ async open(): Promise<void> {
3239
+ if (this.win !== null) return;
3240
+ const size = (await this.opts.size?.()) ?? 30;
3241
+ this.width = size;
3242
+ this.win = await this.neosh.win.open(this.buf, this.opts.dock ?? "left", { size });
3243
+ await this.draw();
3244
+ }
3245
+
3246
+ async close(): Promise<void> {
3247
+ if (this.win === null) return;
3248
+ if (this.focused) await this.leave();
3249
+ await this.neosh.win.close(this.win).catch(() => {});
3250
+ this.win = null;
3251
+ }
3252
+
3253
+ /** Take the keyboard. */
3254
+ async enter(): Promise<void> {
3255
+ if (this.win === null) await this.open();
3256
+ if (this.win === null || this.focused) return;
3257
+ await this.neosh.focus.push(this.win);
3258
+ this.capture = await this.neosh.keymap.capture(this.win, `${this.opts.kind}.key`);
3259
+ this.focused = true;
3260
+ this.published();
3261
+ await this.draw();
3262
+ }
3263
+
3264
+ /** Give it back. */
3265
+ async leave(): Promise<void> {
3266
+ if (!this.focused) return;
3267
+ this.focused = false;
3268
+ this.capture?.dispose();
3269
+ this.capture = null;
3270
+ await this.neosh.focus.pop();
3271
+ await this.draw();
3272
+ }
3273
+
3274
+ /** Close the window and unregister everything. */
3275
+ dispose(): void {
3276
+ void this.close();
3277
+ for (const d of this.subscriptions) d.dispose();
3278
+ }
3279
+ }