@neosh/questions 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (3) hide show
  1. package/main.ts +1288 -0
  2. package/package.json +21 -0
  3. package/plugin.toml +5 -0
package/main.ts ADDED
@@ -0,0 +1,1288 @@
1
+ /**
2
+ * The question panel — what an agent gets when it asks *you* something.
3
+ *
4
+ * # What this is not
5
+ *
6
+ * It is not an approval prompt, and the difference is the whole reason this file exists. An
7
+ * approval asks *may this happen*, every answer to it is a yes or a no, and policy can answer it
8
+ * without waking anybody — which is exactly what `permissions.mode = allow` is for. A question asks
9
+ * *which of these*, several times over, sometimes with more than one answer each and sometimes with
10
+ * an answer nobody listed; and there is no mode, rule or allow-list that knows which database you
11
+ * want.
12
+ *
13
+ * Routed through the permission path — which is where it was — a question did not merely read
14
+ * badly. The configured default is full access, so the layer said yes before anybody was asked, the
15
+ * driver sent back a bare yes carrying no answers, and `claude` reported to its own model: *"The
16
+ * user did not answer the questions."* A turn asked you something, never showed it to you, and then
17
+ * told the agent you had ignored it.
18
+ *
19
+ * # The shape of it
20
+ *
21
+ * **One question at a time.** A panel showing four at once is a form, and a form over a transcript
22
+ * is a modal you have to finish before you can read what prompted it. The rail at the top right
23
+ * says how many there are and which one this is, and `⇧⇥` goes back to change an earlier answer.
24
+ *
25
+ * **The composer is the field.** Typing is not filtering, it is *answering* — the option nobody
26
+ * listed, which is the one people reach for most. It goes into the composer, where you can see it,
27
+ * because that is the field on this screen and a second one drawn inside a float is a field you
28
+ * have to notice. While there is something typed the numbered shortcuts go away, which is how the
29
+ * panel says a digit is a character now.
30
+ *
31
+ * **Every key is a named command.** `question.accept`, `question.next` — so `^Z` lists them and an
32
+ * `init.ts` can move them, and a third party can bind their own against the `neosh.question` buffer
33
+ * kind without forking this. Only the printable keys come through the raw capture, and only because
34
+ * they are text rather than verbs.
35
+ *
36
+ * **A question belongs to its conversation.** Several turns run at once, and one of them asking is
37
+ * not a reason to take the screen away from the one you are reading. A question for a conversation
38
+ * that is not on screen waits, says so in the status line, and opens the moment you switch to it.
39
+ */
40
+
41
+ import type {
42
+ HookOutcome,
43
+ HookPayload,
44
+ Neosh,
45
+ PluginContext,
46
+ QuestionAnswer,
47
+ SessionId,
48
+ UserQuestion,
49
+ ViewId,
50
+ } from "@neosh/api";
51
+ import { byteLength } from "@neosh/api";
52
+ import type { DrawnMark, DrawnRow } from "@neosh/api";
53
+
54
+ /**
55
+ * How long a question may sit unanswered.
56
+ *
57
+ * Long, and deliberately much longer than the approval prompt's two minutes. An approval is asked
58
+ * about something already in flight and you are at the keyboard; a question is asked *of* you and
59
+ * the honest answer is often "let me go and look". Nothing is lost by waiting — the agent is
60
+ * blocked either way — and `<Esc>` ends it the moment you want it ended.
61
+ *
62
+ * It is still finite, because a blocking hook is what the host waits on: past this the host reads
63
+ * it as a refusal and the agent is told nobody answered, which is true.
64
+ */
65
+ const ASK_TIMEOUT_MS = 30 * 60 * 1000;
66
+
67
+ /** What the buffer declares itself to be. Bind against this to take a key in the panel. */
68
+ const KIND = "neosh.question";
69
+
70
+ /** The most options a digit can reach. Ten would be `0`, which reads as "none of them". */
71
+ const SHORTCUTS = 9;
72
+
73
+ /**
74
+ * Which conversations have stopped and are waiting on an answer, in a workspace var.
75
+ *
76
+ * Written here because this is the only thing that knows, and shared because it is not about this
77
+ * plugin: "somebody is waiting on you in *that* conversation" is a fact about the conversation, and
78
+ * every list of conversations wants it. The footer can only ever say *how many* — it is one line and
79
+ * it is shared — so without this the answer to "which one" is opening conversations until you find
80
+ * it, which is the same hunt `SessionInfo::unread` exists to end.
81
+ *
82
+ * A conversation that is asking looks exactly like one that is thinking: the turn is still in
83
+ * flight, blocked on the hook below, so `active_turn` is set and every panel draws a spinner. That
84
+ * is the bug this fixes, and it is why anything reading this must let it outrank *working*.
85
+ *
86
+ * The value is the session ids, newest last. It is on disk with the rest of `vars.json` and the
87
+ * queue is in memory, so a workspace that stopped with a question on it would come back claiming
88
+ * one is still open — [`announce`] therefore writes once at activation, when the queue is empty,
89
+ * and that is what clears it.
90
+ */
91
+ const VAR_ASKING = "question.asking";
92
+
93
+ /** How much of the row the label column may take before the description gives up its place. */
94
+ const LABEL_MAX = 28;
95
+
96
+ /**
97
+ * The label on the row that takes an answer of your own.
98
+ *
99
+ * One word, and a conventional one, because it shares the label column with the options: anything
100
+ * longer widens that column on every question, including the `Yes`/`No` ones where the whole row is
101
+ * four characters and the description is what you are reading.
102
+ */
103
+ const OTHER = "Other";
104
+
105
+ /**
106
+ * How many lines of the answer you are writing the panel will show at once.
107
+ *
108
+ * A field, not a paragraph: what is being typed goes on as many lines as it takes and the panel
109
+ * follows the *end* of it, because the end is where the caret is and the caret is what you are
110
+ * looking at. Unbounded, a long answer would grow the float until it had eaten the conversation it
111
+ * is a question about; clipped to one line — which is what this was — everything past the width of
112
+ * the row is an ellipsis, and typing into a field that stopped showing what you were typing is the
113
+ * one thing a field must never do.
114
+ */
115
+ const FIELD_LINES = 6;
116
+
117
+ /**
118
+ * The characters the panel is drawn with, at whatever fidelity the terminal has.
119
+ *
120
+ * A ticked box and an untouched one have to be the same width or the labels beside them step
121
+ * sideways as you tick things — which is why the ASCII pair is `[x]`/`[ ]` and not `[x]`/`[]`.
122
+ */
123
+ type Glyphs = {
124
+ cursor: string;
125
+ blank: string;
126
+ radioOn: string;
127
+ radioOff: string;
128
+ boxOn: string;
129
+ boxOff: string;
130
+ dotHere: string;
131
+ dotDone: string;
132
+ dotTodo: string;
133
+ pen: string;
134
+ };
135
+
136
+ const UNICODE: Glyphs = {
137
+ cursor: "\u276f",
138
+ blank: " ",
139
+ radioOn: "\u25c9",
140
+ radioOff: "\u25cb",
141
+ boxOn: "\u2611",
142
+ boxOff: "\u2610",
143
+ dotHere: "\u25c9",
144
+ dotDone: "\u25cf",
145
+ dotTodo: "\u25cb",
146
+ pen: "\u270e",
147
+ };
148
+
149
+ const ASCII: Glyphs = {
150
+ cursor: ">",
151
+ blank: " ",
152
+ radioOn: "(*)",
153
+ radioOff: "( )",
154
+ boxOn: "[x]",
155
+ boxOff: "[ ]",
156
+ dotHere: "@",
157
+ dotDone: "*",
158
+ dotTodo: ".",
159
+ pen: "?",
160
+ };
161
+
162
+ /**
163
+ * Display columns, as closely as anything outside the frontend can know.
164
+ *
165
+ * Code points, not bytes — every layout sum here is about *where a thing lands on screen*, and
166
+ * `\u276f` is one column and three bytes. Padding computed in bytes is how the row with the cursor
167
+ * on it ends up two columns shorter than every other row, which is what this panel did until the
168
+ * first screenshot of it. Byte offsets are still what the marks are built from; the two units have
169
+ * different jobs and this is the one that is about width.
170
+ */
171
+ function cols(s: string): number {
172
+ return [...s].length;
173
+ }
174
+
175
+ /** `text`, padded on the right to `n` columns. */
176
+ function pad(text: string, n: number): string {
177
+ return text + " ".repeat(Math.max(0, n - cols(text)));
178
+ }
179
+
180
+ type Draft = {
181
+ /** Labels taken, in the order they were taken. */
182
+ picked: string[];
183
+ /** What was typed instead. Non-empty means the options are not the answer. */
184
+ typed: string;
185
+ };
186
+
187
+ /** One question the panel has been asked and not yet answered. */
188
+ type Ask = {
189
+ session: SessionId | null;
190
+ questions: UserQuestion[];
191
+ /** By question text, which is the key the agent looks answers up under. */
192
+ drafts: Map<string, Draft>;
193
+ /** Which question is on screen. */
194
+ at: number;
195
+ /** Which option is under the cursor, for the question on screen. */
196
+ cursor: number;
197
+ /**
198
+ * What is in the field, and whether the field is open at all.
199
+ *
200
+ * On the ask rather than in the panel, because the panel is closed and built again from nothing
201
+ * every time you look at another conversation — and a sentence somebody is half-way through
202
+ * writing belongs to the question, not to the float that happened to be drawing it. Separate
203
+ * from `drafts`, which is what has been *answered*: a half-written sentence is not an answer,
204
+ * and one filed as though it were would have the panel tick the question off and walk past it.
205
+ */
206
+ typing: string;
207
+ writing: boolean;
208
+ /** Called once, with the answers or `null` for "nobody answered". */
209
+ settle: (answers: QuestionAnswer[] | null) => void;
210
+ settled: boolean;
211
+ };
212
+
213
+ export async function activate({ neosh, subscriptions }: PluginContext) {
214
+ /** Everything asked and not answered, oldest first. */
215
+ const queue: Ask[] = [];
216
+ /** The one on screen, if any. */
217
+ let open: Open | null = null;
218
+ /**
219
+ * Where each terminal is.
220
+ *
221
+ * "The conversation being looked at" used to be one session id, because there was one screen. A
222
+ * workspace can have several and each is somewhere, so a question is put in front of a terminal
223
+ * that is reading *its* conversation — and if none is, it waits, and the footer says how many
224
+ * are waiting.
225
+ *
226
+ * Empty until the first refresh, which is deliberate: every `await` before the hook is
227
+ * registered is a window in which a question arrives and finds no blocking hook — answered as
228
+ * "nobody answered", instantly and wrongly — so the hook goes on first and this is filled in
229
+ * behind it.
230
+ */
231
+ let where = new Map<ViewId, SessionId>();
232
+ const relocate = async () => {
233
+ const views = await neosh.view.list().catch(() => []);
234
+ where = new Map(views.map((v) => [v.view, v.session]));
235
+ };
236
+
237
+ /**
238
+ * The first queued question there is a terminal to show it in, and which terminal that is.
239
+ *
240
+ * A question raised with no conversation of its own — a plugin's `neosh.ask` — belongs wherever
241
+ * you are, so it takes the terminal being served if there is one and any terminal otherwise.
242
+ */
243
+ const showable = (): { ask: Ask; view: ViewId } | null => {
244
+ for (const ask of queue) {
245
+ if (ask.session === null) {
246
+ const anywhere = [...where.keys()][0];
247
+ if (anywhere !== undefined) return { ask, view: anywhere };
248
+ continue;
249
+ }
250
+ for (const [view, session] of where) {
251
+ if (session === ask.session) return { ask, view };
252
+ }
253
+ }
254
+ return null;
255
+ };
256
+
257
+ // What is in the composer, mirrored — there is no call that reads it, only an event that says it
258
+ // changed. Kept only while nothing is on screen: once the panel is up it is the panel writing the
259
+ // composer, and every change from then on is an echo of something it just did.
260
+ let draft = "";
261
+ subscriptions.push(
262
+ neosh.agent.onComposerChange(({ text }) => {
263
+ if (!open) draft = text;
264
+ }),
265
+ );
266
+
267
+ /**
268
+ * Put the panel in step with the queue: open the one for this conversation, close what is not.
269
+ *
270
+ * One function rather than a call at every site that changes either, because the two things that
271
+ * move — which conversation you are looking at, and what is waiting — change independently and
272
+ * every combination of them has to end up here.
273
+ */
274
+ // One at a time. `sync` awaits a window opening and several round trips inside it, and two
275
+ // callers arriving in that gap — a question landing while a conversation switch is still being
276
+ // handled — would both find no panel open and both draw one, leaving two panels registering the
277
+ // same command names against one keyboard.
278
+ let running: Promise<void> = Promise.resolve();
279
+ const sync = () => {
280
+ running = running.then(step, step);
281
+ return running;
282
+ };
283
+
284
+ const step = async () => {
285
+ const want = showable();
286
+ // Closed when the question changes *or* when the terminal reading it does — somebody switched
287
+ // away, and a panel left on their screen is a question they are no longer being asked.
288
+ if (open && (open.ask !== want?.ask || open.view !== want.view)) {
289
+ await open.close();
290
+ open = null;
291
+ }
292
+ if (want && !open) {
293
+ // Whatever was half-typed becomes the seed of the answer. A question that landed over a
294
+ // sentence somebody was writing should take that sentence, not throw it away — it is very
295
+ // often exactly what they were about to say.
296
+ const seed = draft;
297
+ draft = "";
298
+ // In the terminal reading the conversation that asked, which is what makes a question
299
+ // findable in a workspace with three windows open on three different things.
300
+ open = await draw(neosh.view.at(want.view), want.view, want.ask, seed, () => {
301
+ // Answered, dismissed, or gone. Either way it leaves the queue and the next one — which
302
+ // may belong to another conversation entirely — gets its turn at the screen.
303
+ const i = queue.indexOf(want.ask);
304
+ if (i >= 0) queue.splice(i, 1);
305
+ open = null;
306
+ void sync();
307
+ });
308
+ }
309
+ await elsewhere(neosh, queue, open?.ask ?? null);
310
+ await announce();
311
+ };
312
+
313
+ /**
314
+ * Say which conversations are waiting, for anything that draws a list of them.
315
+ *
316
+ * `null` to start rather than an empty string, so the first call writes whatever the queue is —
317
+ * including the empty one, which is how a var left behind by a workspace that stopped mid-question
318
+ * is cleared. Diffed rather than written every time because a var is a file: `sync` runs on every
319
+ * conversation switch and most of them change nothing here.
320
+ */
321
+ let announced: string | null = null;
322
+ const announce = async () => {
323
+ // A question raised by something with no conversation of its own belongs to no row, so it is
324
+ // the footer's business and not this one's.
325
+ const ids = [...new Set(queue.map((a) => a.session).filter((s): s is SessionId => Boolean(s)))];
326
+ const next = ids.join("\n");
327
+ if (announced === next) return;
328
+ announced = next;
329
+ await (ids.length === 0
330
+ ? neosh.vars.remove({ scope: "global" }, VAR_ASKING)
331
+ : neosh.vars.set({ scope: "global" }, VAR_ASKING, ids)).catch(() => {});
332
+ };
333
+
334
+ // First, before anything that awaits. A question arriving while this plugin is still starting up
335
+ // finds no blocking hook, and "no blocking hook" is answered as "nobody answered" — instantly,
336
+ // and to an agent that was never given the chance to be told otherwise.
337
+ subscriptions.push(
338
+ await neosh.hook.register(
339
+ "ask_user",
340
+ async (payload): Promise<HookOutcome> => {
341
+ if (payload.hook !== "ask_user") return { action: "continue" };
342
+ if (payload.questions.length === 0) return { action: "continue" };
343
+
344
+ const ask: Ask = {
345
+ session: payload.session ?? null,
346
+ questions: payload.questions,
347
+ drafts: new Map(),
348
+ at: 0,
349
+ cursor: 0,
350
+ typing: "",
351
+ writing: false,
352
+ settle: () => {},
353
+ settled: false,
354
+ };
355
+ const answered = new Promise<QuestionAnswer[] | null>((resolve) => {
356
+ ask.settle = resolve;
357
+ });
358
+ queue.push(ask);
359
+ void sync();
360
+
361
+ const answers = await answered;
362
+ // Nothing, rather than an empty list: the two are different on the wire and the difference
363
+ // is the sentence the agent is given. A veto's reason is passed to the model verbatim.
364
+ if (!answers || answers.length === 0) {
365
+ return {
366
+ action: "veto",
367
+ reason: "The user dismissed the question without answering.",
368
+ };
369
+ }
370
+ const next: HookPayload = { ...payload, answers };
371
+ return { action: "modify", payload: next };
372
+ },
373
+ { blocking: true, timeoutMs: ASK_TIMEOUT_MS },
374
+ ),
375
+ );
376
+
377
+ // The colours, after the hook rather than before it. A group defined late still colours a mark
378
+ // that is already on screen — the theme is consulted when the row is drawn — so nothing is lost
379
+ // by not making a question wait for fourteen round trips.
380
+ await defineGroups(neosh);
381
+ await relocate();
382
+ void sync();
383
+
384
+ subscriptions.push(
385
+ neosh.session.onChange(({ session, view }) => {
386
+ where.set(view, session);
387
+ void sync();
388
+ }),
389
+ );
390
+ // A terminal arriving is a screen a waiting question may now have. One leaving is a panel that
391
+ // has gone with it, and a question that has to go back in the queue.
392
+ subscriptions.push(
393
+ neosh.view.onOpen(() => void relocate().then(sync)),
394
+ );
395
+ subscriptions.push(
396
+ neosh.view.onClose((view) => {
397
+ where.delete(view);
398
+ if (open?.view === view) open = null;
399
+ void sync();
400
+ }),
401
+ );
402
+
403
+ // A turn that ends is a turn that is not waiting on an answer any more — it was interrupted, or
404
+ // it failed, and the driver has already told the agent so. Without this the panel would sit there
405
+ // with nothing behind it: the question it belongs to has no reader left, and answering it would
406
+ // write into a pipe that has moved on.
407
+ subscriptions.push(
408
+ neosh.agent.onTurnEnd(({ session }) => {
409
+ let moved = false;
410
+ for (const ask of [...queue]) {
411
+ if (ask.session !== session) continue;
412
+ finish(ask, null);
413
+ const i = queue.indexOf(ask);
414
+ if (i >= 0) queue.splice(i, 1);
415
+ moved = true;
416
+ }
417
+ if (moved) void sync();
418
+ }),
419
+ );
420
+
421
+ subscriptions.push({
422
+ dispose() {
423
+ // Unloading with a question on screen must not leave a turn blocked on a panel that no
424
+ // longer has anybody drawing it.
425
+ for (const ask of queue.splice(0)) finish(ask, null);
426
+ void open?.close();
427
+ open = null;
428
+ void neosh.status.clear("question");
429
+ // And take the mark off the rows. Unloading this plugin unblocks every turn it was holding,
430
+ // so a conversation still flagged as asking would be one nothing is going to ask again.
431
+ void neosh.vars.remove({ scope: "global" }, VAR_ASKING).catch(() => {});
432
+ },
433
+ });
434
+ }
435
+
436
+ /** Settle an ask exactly once. */
437
+ function finish(ask: Ask, answers: QuestionAnswer[] | null): void {
438
+ if (ask.settled) return;
439
+ ask.settled = true;
440
+ ask.settle(answers);
441
+ }
442
+
443
+ /**
444
+ * Tell the footer about a question that is *not* on this screen.
445
+ *
446
+ * The one case where a status segment is the whole of the UI: a conversation you are not looking at
447
+ * has stopped and is waiting on you, and nothing else on screen would ever say so. A row that named
448
+ * the conversation would be better and cannot be — the footer has one line and it is shared — so it
449
+ * says how many, and `^T` is where you go.
450
+ */
451
+ async function elsewhere(neosh: Neosh, queue: Ask[], shown: Ask | null): Promise<void> {
452
+ const waiting = queue.filter((a) => a !== shown).length;
453
+ if (waiting === 0) {
454
+ await neosh.status.clear("question").catch(() => {});
455
+ return;
456
+ }
457
+ await neosh.status
458
+ .set("question", {
459
+ text: waiting === 1 ? "1 conversation is asking" : `${waiting} conversations are asking`,
460
+ keys: "^T",
461
+ hl: "Question.Waiting",
462
+ priority: 40,
463
+ })
464
+ .catch(() => {});
465
+ }
466
+
467
+ // ---------------------------------------------------------------------------
468
+ // The panel
469
+ // ---------------------------------------------------------------------------
470
+
471
+ interface Open {
472
+ ask: Ask;
473
+ /** Which terminal it is on. A question is answered once, on one screen. */
474
+ view: ViewId;
475
+ close(): Promise<void>;
476
+ }
477
+
478
+ /**
479
+ * Put one ask on screen and drive it until it is answered or dismissed.
480
+ *
481
+ * Returns as soon as it is drawn — the answering happens on keys, and `done` is called when the ask
482
+ * has settled so the caller can move on to the next one.
483
+ */
484
+ async function draw(
485
+ neosh: Neosh,
486
+ view: ViewId,
487
+ ask: Ask,
488
+ seed: string,
489
+ done: () => void,
490
+ ): Promise<Open> {
491
+ const buf = await neosh.buf.create({ name: "[question]", scratch: true, kind: KIND });
492
+ const ns = await neosh.ns.create("neosh.questions");
493
+
494
+ // As wide as the conversation, so the panel reads as part of the column it interrupts rather than
495
+ // as a box that landed on it. Asked rather than computed: only the frontend knows how much room
496
+ // the docks left, and `fill` would span the sidebar too.
497
+ const width = await columnWidth(neosh);
498
+ // The setting, not a guess about the terminal: `ui.ascii_only` is what the user says when their
499
+ // font cannot draw a ballot box, and every other part of neosh reads the same option.
500
+ const glyphs = (await neosh.opt.get<boolean>("ui.ascii_only").catch(() => false))
501
+ ? ASCII
502
+ : UNICODE;
503
+
504
+ const win = await neosh.float.open(buf, {
505
+ anchor: { kind: "dock", dock: "bottom" },
506
+ width: { kind: "fixed", n: width },
507
+ height: { kind: "auto" },
508
+ border: "rounded",
509
+ borderHl: "Question.Border",
510
+ focusable: true,
511
+ // Managed here, not by blur. Focus moves for a dozen ordinary reasons and a question that
512
+ // vanished when it did would be a question you cannot answer and cannot get back.
513
+ closeOnBlur: false,
514
+ z: 180,
515
+ });
516
+
517
+ /** What is in the composer, which is the answer nobody listed. */
518
+ let typed = "";
519
+ /**
520
+ * Whether the panel is being written into rather than chosen from.
521
+ *
522
+ * Explicit rather than `typed !== ""`, and the difference is the row at the foot of the list. You
523
+ * get here by landing on that row and pressing `↵`, which has to show you an empty field to type
524
+ * into — derived from the text, the field would not appear until after the first character, so
525
+ * the key would look like it did nothing and the thing it opened would look like a typo.
526
+ */
527
+ let writing = false;
528
+ let closed = false;
529
+
530
+ const q = () => ask.questions[ask.at]!;
531
+ const draftOf = (question: UserQuestion): Draft =>
532
+ ask.drafts.get(question.question) ?? { picked: [], typed: "" };
533
+
534
+ const render = async () => {
535
+ if (closed) return;
536
+ const rows = compose(ask, typed, writing, width, glyphs);
537
+ await neosh.buf.render(buf, ns, 0, -1, rows.rows).catch(() => {});
538
+ // The caret goes where you are acting: on the row under the cursor, and at the end of what you
539
+ // have typed once you are typing. Without it the terminal's caret parks in the corner of a
540
+ // panel that is plainly listening, and a field with no insertion point in it reads as inert.
541
+ await neosh.win.setCursor(win, rows.caret, rows.caretCol).catch(() => {});
542
+ };
543
+
544
+ const close = async () => {
545
+ if (closed) return;
546
+ closed = true;
547
+ // Onto the ask, before the panel goes. Looking at another conversation closes this panel and
548
+ // builds a new one on the way back — the cursor and which question you were on already survive
549
+ // that, and what you were writing is the one part of it you would notice losing.
550
+ ask.typing = typed;
551
+ ask.writing = writing;
552
+ for (const d of disposers) d.dispose();
553
+ await neosh.focus.pop().catch(() => {});
554
+ await neosh.win.close(win).catch(() => {});
555
+ // What is in the composer is deliberately left there. An answer that was *taken* has already
556
+ // been cleared by whoever took it; anything still sitting there is a sentence somebody typed
557
+ // and did not send — often the one they were half-way through when the question landed on top
558
+ // of it — and a panel that wiped the composer on its way out would be a panel that ate a
559
+ // message for the crime of being interrupted.
560
+ };
561
+
562
+ const settle = async (answers: QuestionAnswer[] | null) => {
563
+ finish(ask, answers);
564
+ await close();
565
+ done();
566
+ };
567
+
568
+ /**
569
+ * Take the answer to the question on screen, and go on to the next one.
570
+ *
571
+ * The one place an answer is written down, so "what counts as answered" has a single definition:
572
+ * something typed beats something chosen, because typing over a selection is how you change your
573
+ * mind, and a question with neither is not answered at all.
574
+ */
575
+ const advance = async (): Promise<void> => {
576
+ const question = q();
577
+ const answer = typed.trim()
578
+ ? { question: question.question, answers: [typed.trim()], custom: true }
579
+ : {
580
+ question: question.question,
581
+ answers: draftOf(question).picked,
582
+ custom: false,
583
+ };
584
+ if (answer.answers.length === 0) return;
585
+ ask.drafts.set(question.question, {
586
+ picked: answer.custom ? [] : answer.answers,
587
+ typed: answer.custom ? answer.answers[0]! : "",
588
+ });
589
+ if (typed) {
590
+ typed = "";
591
+ await neosh.agent.setDraft("").catch(() => {});
592
+ }
593
+ writing = false;
594
+
595
+ // The first one still unanswered, which is not always the next one along: `⇧⇥` goes back to
596
+ // change an earlier answer, and finishing that one should return you to where you were rather
597
+ // than walk you forward through questions you have already done.
598
+ const pending = ask.questions.findIndex((qq) => resolve(ask, qq).length === 0);
599
+ if (pending < 0) {
600
+ await settle(
601
+ ask.questions.map((qq) => {
602
+ const answers = resolve(ask, qq);
603
+ return { question: qq.question, answers, custom: draftOf(qq).typed.length > 0 };
604
+ }),
605
+ );
606
+ return;
607
+ }
608
+ ask.at = pending;
609
+ ask.cursor = 0;
610
+ await render();
611
+ };
612
+
613
+ /**
614
+ * Move the cursor, over the options *and* the row that lets you write your own.
615
+ *
616
+ * `+ 1` because that row is real: it takes the cursor, it wraps round to the first option, and it
617
+ * is reachable with the same key as everything else. A row you can see and cannot arrive at is
618
+ * worse than no row.
619
+ */
620
+ const move = async (delta: number) => {
621
+ const n = q().options.length + 1;
622
+ // Moving off the field is how you go back to the options without erasing what you wrote — it
623
+ // is still there when you come back to the row.
624
+ if (writing) writing = false;
625
+ ask.cursor = (ask.cursor + delta + n) % n;
626
+ await render();
627
+ };
628
+
629
+ /** Start writing an answer of your own, with the cursor parked on the row that is the field. */
630
+ const write = async () => {
631
+ ask.cursor = q().options.length;
632
+ writing = true;
633
+ await render();
634
+ };
635
+
636
+ /**
637
+ * Stop writing, and go back to choosing.
638
+ *
639
+ * What was typed stays typed. Backspacing past the first character is how you change your mind
640
+ * about writing at all, and one that threw the sentence away would make `⌫` a key you have to be
641
+ * careful with — on a panel whose entire job is to be answered quickly.
642
+ */
643
+ const unwrite = async () => {
644
+ writing = false;
645
+ await render();
646
+ };
647
+
648
+ /**
649
+ * Take an option, by index. Single-select answers with it; multi-select toggles it.
650
+ *
651
+ * The index one past the last option is the row that lets you write your own, which is a row and
652
+ * so answers to every key the rows answer to.
653
+ */
654
+ const take = async (index: number) => {
655
+ const question = q();
656
+ if (index === question.options.length) {
657
+ await write();
658
+ return;
659
+ }
660
+ const option = question.options[index];
661
+ if (!option) return;
662
+ writing = false;
663
+ // Typing wins over the rows while there is anything typed — but reaching for a row is a plain
664
+ // statement that the rows are the answer after all, so it clears what was typed rather than
665
+ // being ignored underneath it.
666
+ if (typed) {
667
+ typed = "";
668
+ await neosh.agent.setDraft("").catch(() => {});
669
+ }
670
+ ask.cursor = index;
671
+ const draft = draftOf(question);
672
+ if (question.multi_select) {
673
+ const picked = draft.picked.includes(option.label)
674
+ ? draft.picked.filter((l) => l !== option.label)
675
+ : [...draft.picked, option.label];
676
+ ask.drafts.set(question.question, { picked, typed: "" });
677
+ await render();
678
+ return;
679
+ }
680
+ ask.drafts.set(question.question, { picked: [option.label], typed: "" });
681
+ await advance();
682
+ };
683
+
684
+ const retype = async (next: string) => {
685
+ typed = next;
686
+ await neosh.agent.setDraft(next).catch(() => {});
687
+ await render();
688
+ };
689
+
690
+ const disposers: Array<{ dispose(): void }> = [];
691
+ const cmd = async (name: string, desc: string, fn: () => Promise<void>) => {
692
+ disposers.push(await neosh.cmd.register(name, fn, { desc }));
693
+ };
694
+
695
+ await cmd("question.accept", "Answer this question and go on to the next", async () => {
696
+ if (writing) {
697
+ if (typed.trim()) {
698
+ await advance();
699
+ return;
700
+ }
701
+ // An empty field and `↵`. Saying so beats sending nothing: an answer of "" is one the agent
702
+ // reads as an answer, and a key that silently does nothing is one you press again harder.
703
+ neosh.notify("type an answer, or ⌫ back to the options", "info");
704
+ return;
705
+ }
706
+ if (typed.trim()) {
707
+ await advance();
708
+ return;
709
+ }
710
+ // Nothing ticked, on a question that takes one answer: `↵` takes the row under the cursor,
711
+ // because that is what a cursor on a row means. Arrow-then-enter is how the rows are chosen
712
+ // without reaching for a digit, and a panel where it did nothing would be one where the cursor
713
+ // was decoration.
714
+ if (!q().multi_select) {
715
+ await take(ask.cursor);
716
+ return;
717
+ }
718
+ if (resolve(ask, q()).length > 0) {
719
+ await advance();
720
+ return;
721
+ }
722
+ // A question that takes several, with none of them ticked, is not answered — and `↵` has to
723
+ // say so rather than do nothing, because a key that appears inert is one you press again.
724
+ neosh.notify("tick at least one, or type your own answer", "info");
725
+ });
726
+ await cmd("question.next", "Next option", () => move(1));
727
+ await cmd("question.prev", "Previous option", () => move(-1));
728
+ await cmd("question.toggle", "Tick the option under the cursor", () => take(ask.cursor));
729
+ await cmd("question.back", "Back to the previous question", async () => {
730
+ if (ask.at === 0) return;
731
+ ask.at -= 1;
732
+ // Onto whatever was answered there, so going back and pressing `↵` re-affirms rather than
733
+ // silently changing the answer to whichever row happened to be first.
734
+ const picked = draftOf(q()).picked[0];
735
+ ask.cursor = Math.max(0, q().options.findIndex((o) => o.label === picked));
736
+ const was = draftOf(q()).typed;
737
+ // Back onto a question you answered in your own words puts you back in the field with those
738
+ // words in it, rather than on a list of options you had already decided against.
739
+ writing = was !== "";
740
+ if (writing) ask.cursor = q().options.length;
741
+ if (was !== typed) await retype(was);
742
+ else await render();
743
+ });
744
+ await cmd("question.dismiss", "Dismiss the question without answering", () => settle(null));
745
+
746
+ const scope = { scope: { kind: "buf_kind", name: KIND } as const };
747
+ // In both modes, because `^S` puts the editor in `Normal` and a question that arrives while you
748
+ // are reading the transcript is still a question. Every one of these is a *chord or a named key*
749
+ // — never a printable character, which has to stay available for the answer you type.
750
+ for (const mode of ["chat", "normal"] as const) {
751
+ for (const [lhs, command, desc] of [
752
+ ["<CR>", "question.accept", "Answer"],
753
+ ["<Down>", "question.next", "Next option"],
754
+ ["<Up>", "question.prev", "Previous option"],
755
+ ["<C-n>", "question.next", "Next option"],
756
+ ["<C-p>", "question.prev", "Previous option"],
757
+ ["<Tab>", "question.toggle", "Tick this option"],
758
+ ["<S-Tab>", "question.back", "Previous question"],
759
+ ["<Esc>", "question.dismiss", "Dismiss"],
760
+ ] as const) {
761
+ await neosh.keymap.set(mode, lhs, command, { ...scope, desc });
762
+ disposers.push({
763
+ dispose: () => void neosh.keymap.del(mode, lhs, scope.scope).catch(() => {}),
764
+ });
765
+ }
766
+ }
767
+
768
+ // Everything nothing else claimed, which here means the text of an answer. Digits are the one
769
+ // ambiguous case and the panel resolves it in the open: while nothing has been typed they are
770
+ // shortcuts and the rows wear their numbers, and the moment something has been they are
771
+ // characters and the numbers are gone.
772
+ const sink = "neosh.questions.key";
773
+ disposers.push(
774
+ await neosh.cmd.register(sink, async (_args, key) => {
775
+ if (!key || closed) return;
776
+ const code = key.key.code;
777
+ if (code.kind === "backspace") {
778
+ // Back off the end of an empty field and you are choosing again. The one key that leaves
779
+ // the field, and the one people already reach for.
780
+ if (!typed) await unwrite();
781
+ else await retype(typed.slice(0, -1));
782
+ return;
783
+ }
784
+ if (code.kind !== "char" || key.key.mods.alt) return;
785
+ if (key.key.mods.ctrl) {
786
+ if (code.c === "u") await retype("");
787
+ else if (code.c === "w") await retype(dropWord(typed));
788
+ return;
789
+ }
790
+ const c = code.c;
791
+ // Digits are shortcuts until something is being written, and characters afterwards — which
792
+ // the panel says out loud by taking the numbers off the rows. `0` is the row at the foot:
793
+ // on a numbered list it already reads as *none of them*, which is what that row is.
794
+ if (!writing && c >= "0" && c <= "9") {
795
+ await take(c === "0" ? q().options.length : Number(c) - 1);
796
+ return;
797
+ }
798
+ // The first character of an answer is also the thing that starts one. Typing has always been
799
+ // how you say something the options do not, and it stays that way — the row at the foot is
800
+ // how you *find out* that it is, not a gate in front of it.
801
+ if (!writing) {
802
+ ask.cursor = q().options.length;
803
+ writing = true;
804
+ }
805
+ await retype(typed + c);
806
+ }, { desc: "question: a character of your own answer" }),
807
+ );
808
+
809
+ await neosh.focus.push(win);
810
+ disposers.push(await neosh.keymap.capture(win, sink));
811
+ // `^U` and `^W` belong to the composer, and the capture only ever receives what no binding
812
+ // claimed — so without these two the field they act on would stop answering to them mid-sentence.
813
+ // Window-scoped, so they go back to the composer with the window.
814
+ for (const lhs of ["<C-u>", "<C-w>"] as const) {
815
+ const scoped = { kind: "window", win } as const;
816
+ await neosh.keymap.set("chat", lhs, sink, { scope: scoped, desc: "Erase what you typed" });
817
+ disposers.push({
818
+ dispose: () => void neosh.keymap.del("chat", lhs, scoped).catch(() => {}),
819
+ });
820
+ }
821
+
822
+ // What was in the field when this question was last on screen, if it has been: coming back to a
823
+ // conversation you left mid-sentence puts you back in the sentence. Otherwise, whatever was
824
+ // already half-typed when the question arrived, rather than thrown away — the panel appeared
825
+ // over a field somebody was using, and what they had written is very often the answer.
826
+ typed = ask.typing || seed;
827
+ writing = ask.writing || typed.trim() !== "";
828
+ if (writing) ask.cursor = q().options.length;
829
+ // And the composer says the same thing, because switching conversations emptied it. The panel
830
+ // mirrors the answer into the composer so you can see it in the field this screen actually has;
831
+ // restoring one without the other is arriving at two answers that disagree.
832
+ if (typed !== "") await neosh.agent.setDraft(typed).catch(() => {});
833
+ await render();
834
+
835
+ return { ask, view, close };
836
+ }
837
+
838
+ /** Erase back to the start of the last word, as `^W` does everywhere else. */
839
+ function dropWord(text: string): string {
840
+ const trimmed = text.replace(/\s+$/, "");
841
+ const at = trimmed.lastIndexOf(" ");
842
+ return at < 0 ? "" : trimmed.slice(0, at + 1);
843
+ }
844
+
845
+ /**
846
+ * How wide the conversation column is.
847
+ *
848
+ * The main dock's own width, minus the border this panel draws. `Extent::Fill` is the screen and
849
+ * would run under the sidebar; a fixed guess is wrong on every terminal but the one it was written
850
+ * on.
851
+ */
852
+ async function columnWidth(neosh: Neosh): Promise<number> {
853
+ const windows = await neosh.win.list().catch(() => []);
854
+ const main = windows.find(
855
+ (w) => w.layout.kind === "docked" && w.layout.dock === "main",
856
+ );
857
+ const port = main ? await neosh.win.viewport(main.win).catch(() => null) : null;
858
+ const outer = port?.width ?? 76;
859
+ return Math.max(28, Math.min(110, outer - 2));
860
+ }
861
+
862
+ /** The labels standing as the answer to `question`, chosen or typed. */
863
+ function resolve(ask: Ask, question: UserQuestion): string[] {
864
+ const draft = ask.drafts.get(question.question);
865
+ if (!draft) return [];
866
+ if (draft.typed.trim()) return [draft.typed.trim()];
867
+ return draft.picked;
868
+ }
869
+
870
+ // ---------------------------------------------------------------------------
871
+ // Drawing
872
+ // ---------------------------------------------------------------------------
873
+
874
+ /** A mark, built where the text was so the offsets are the text's own. */
875
+ function mark(col: number, end: number, hl: string, priority?: number): DrawnMark {
876
+ return { col, opts: { hlGroup: hl, endCol: end, ...(priority ? { priority } : {}) } };
877
+ }
878
+
879
+ /**
880
+ * The whole panel, as rows.
881
+ *
882
+ * A pure function of the ask, what has been typed and how much room there is, which is what makes
883
+ * it testable and what keeps a redraw from depending on the order the last one happened in.
884
+ *
885
+ * Two units, and they are not interchangeable. Every *layout* sum is in [`cols`] — code points,
886
+ * the closest thing to display width available outside the frontend — because a row's padding is
887
+ * about where things land on screen. Every *mark* is in bytes, computed from the string actually
888
+ * written, because that is the unit every column on the wire uses.
889
+ */
890
+ function compose(
891
+ ask: Ask,
892
+ typed: string,
893
+ writing: boolean,
894
+ width: number,
895
+ g: Glyphs,
896
+ ): { rows: DrawnRow[]; caret: number; caretCol: number } {
897
+ const question = ask.questions[ask.at]!;
898
+ const custom = writing;
899
+ /** The row after the last option: the answer nobody listed. */
900
+ const customAt = question.options.length;
901
+ const picked = new Set(ask.drafts.get(question.question)?.picked ?? []);
902
+ const rows: DrawnRow[] = [];
903
+ /** The left margin, shared by every row so the panel has one edge rather than several. */
904
+ const LEFT = 2;
905
+ const room = width - LEFT * 2;
906
+
907
+ // --- the header: what this is about, and how far through you are -----------
908
+ const header = clip(question.header.trim() || "Question", Math.max(8, room - 16));
909
+ const rail = progress(ask, g);
910
+ const gap = Math.max(1, room - cols(header) - cols(rail));
911
+ const headText = ` ${header}${" ".repeat(gap)}${rail}`;
912
+ const headMarks: DrawnMark[] = [
913
+ // Shimmers, because the agent has stopped and is waiting on you. The one thing on this panel
914
+ // that moves, and it is the thing that says why the panel is here at all.
915
+ mark(LEFT, LEFT + byteLength(header), "Question.Header"),
916
+ ];
917
+ if (rail) {
918
+ headMarks.push(mark(byteLength(headText) - byteLength(rail), byteLength(headText), "Question.Rail"));
919
+ }
920
+ rows.push({ text: headText, marks: headMarks });
921
+ rows.push({ text: "" });
922
+
923
+ // --- the question itself ---------------------------------------------------
924
+ for (const line of wrap(question.question, room)) {
925
+ const text = ` ${line}`;
926
+ rows.push({ text, marks: [mark(LEFT, byteLength(text), "Question.Text")] });
927
+ }
928
+ rows.push({ text: "" });
929
+
930
+ // --- the options -----------------------------------------------------------
931
+ const [on, off] = question.multi_select ? [g.boxOn, g.boxOff] : [g.radioOn, g.radioOff];
932
+ // One gutter for the whole list — the marker, a space, the box, a space — so the labels line up
933
+ // whether or not the cursor is on the row.
934
+ const gutter = cols(g.cursor) + 1 + cols(on) + 1;
935
+ // The row at the foot is one of the rows, so its label is one of the labels the column has to
936
+ // fit. Left out, a question whose options are `Yes` and `No` gives the column three columns and
937
+ // `Other` arrives clipped — a row offering to take your answer, unable to say so.
938
+ const labelWidth = Math.min(
939
+ LABEL_MAX,
940
+ question.options.reduce((w, o) => Math.max(w, cols(o.label)), cols(OTHER)),
941
+ );
942
+ const shortcuts = !custom && question.options.length > 1;
943
+ const badgeWidth = shortcuts ? cols(String(Math.min(SHORTCUTS, question.options.length))) : 0;
944
+
945
+ let caret = rows.length;
946
+ let caretCol = 0;
947
+ question.options.forEach((option, i) => {
948
+ const here = !custom && i === ask.cursor;
949
+ if (here) caret = rows.length;
950
+ const taken = !custom && picked.has(option.label);
951
+ const lead = pad(`${here ? g.cursor : g.blank} ${taken ? on : off} `, gutter);
952
+ const badge = shortcuts && i < SHORTCUTS ? String(i + 1) : "";
953
+ // What is left for the description, once the row has paid for its gutter, its label column and
954
+ // the shortcut hanging off the right edge.
955
+ const forDetail = room - gutter - labelWidth - 2 - (badgeWidth ? badgeWidth + 2 : 0);
956
+ const detail = option.description.trim();
957
+
958
+ // The row under the cursor says all of itself, on as many lines as that takes, and folds back
959
+ // to one the moment the cursor leaves.
960
+ //
961
+ // This is the whole point of the panel. An option's description is the *only* thing that says
962
+ // what taking it would mean — "the primary write database" against "the read replica" — and it
963
+ // is the first thing a two-column row inside a bordered float runs out of room for. Clipped, an
964
+ // agent asking which of four things you want is a list of four sentences that all stop at the
965
+ // same word, and the answer is a guess. It goes under the row rather than in a float over it
966
+ // because there is nowhere else to put it: this panel is already a float over the composer, and
967
+ // a second one on top would cover the options it is describing.
968
+ //
969
+ // Only the cursor's row, and only ever one of them, so the list is still a list you can read
970
+ // down. The columns hold their places on the continuation lines — a description that reflowed
971
+ // under the label would read as a new option.
972
+ const labelLines = here ? wrapCols(option.label, labelWidth) : [clip(option.label, labelWidth)];
973
+ const detailLines = detail === "" || forDetail < 8
974
+ ? []
975
+ : here
976
+ ? wrapCols(detail, forDetail)
977
+ : [clip(detail, forDetail)];
978
+
979
+ const lines = Math.max(labelLines.length, detailLines.length, 1);
980
+ for (let n = 0; n < lines; n++) {
981
+ const label = pad(labelLines[n] ?? "", labelWidth);
982
+ const shown = detailLines[n] ?? "";
983
+ const body = shown ? `${label} ${shown}` : label.trimEnd();
984
+ // The gutter is paid for on every line, and drawn on the first: the marker and the box are
985
+ // what the row *is*, and repeating them down the side would read as one option per line.
986
+ const run = n === 0 ? lead : " ".repeat(gutter);
987
+ const tail = n === 0 ? badge : "";
988
+ // Right-aligned against the panel's own edge, so the shortcuts read as a column rather than
989
+ // as punctuation trailing each row by a different amount.
990
+ const filler = tail ? " ".repeat(Math.max(1, room - gutter - cols(body) - cols(tail))) : "";
991
+ const text = ` ${run}${body}${filler}${tail}`;
992
+
993
+ const marks: DrawnMark[] = [];
994
+ // The band goes on first and everything else is patched over it, so a highlighted row keeps
995
+ // saying what its parts are instead of becoming one flat colour. See the `line_hl_group`
996
+ // rule. Every line of an unfolded row wears it, or the rest of the description reads as
997
+ // something that fell out from under the row rather than as part of it.
998
+ if (here) marks.push({ col: 0, opts: { lineHlGroup: "Question.Cursor" } });
999
+ const leadAt = LEFT;
1000
+ if (n === 0) {
1001
+ marks.push(mark(leadAt, leadAt + byteLength(lead), taken ? "Question.Taken" : "Question.Box", 100));
1002
+ }
1003
+ const labelAt = leadAt + byteLength(run);
1004
+ if (label.trimEnd() !== "") {
1005
+ marks.push(mark(
1006
+ labelAt,
1007
+ labelAt + byteLength(label.trimEnd()),
1008
+ custom ? "Question.Muted" : "Question.Option",
1009
+ 100,
1010
+ ));
1011
+ }
1012
+ if (shown) {
1013
+ const detailAt = labelAt + byteLength(label) + 2;
1014
+ marks.push(mark(detailAt, detailAt + byteLength(shown), "Question.Detail", 100));
1015
+ }
1016
+ if (tail) {
1017
+ marks.push(mark(byteLength(text) - byteLength(tail), byteLength(text), "Question.Shortcut", 100));
1018
+ }
1019
+ rows.push({ text, marks });
1020
+ }
1021
+ });
1022
+
1023
+ // --- the answer nobody listed ----------------------------------------------
1024
+ //
1025
+ // A row, not a note in the key strip. Typing has always been how you answer a question none of
1026
+ // the options answer, and a capability whose entire advertisement is one phrase in a dim strip at
1027
+ // the bottom is a capability nobody has: there is nothing on screen that looks like a field, so
1028
+ // the panel reads as four options and a dead end. It is the last row because that is where "none
1029
+ // of these" belongs, it takes the cursor like any other row, and `↵` on it starts the answer.
1030
+ //
1031
+ // `0` is its shortcut, which is the digit the option rows deliberately do not use: on a numbered
1032
+ // list `0` already reads as *none of them*, and that is exactly what this row is.
1033
+ {
1034
+ const here = custom || ask.cursor === customAt;
1035
+ const lead = pad(`${here ? g.cursor : g.blank} ${g.pen} `, gutter);
1036
+ const badge = shortcuts ? "0" : "";
1037
+ const forDetail = room - gutter - labelWidth - 2 - (badgeWidth ? badgeWidth + 2 : 0);
1038
+ if (custom) {
1039
+ // Once you are writing, the row *is* the field: what is in it is your answer, it takes the
1040
+ // whole width because a sentence is not a label, and it takes as many lines as the sentence
1041
+ // takes. Clipped to the one row — which is what it did — an answer longer than the panel is
1042
+ // wide became the first sixty characters and an ellipsis, and stayed that way however much
1043
+ // more you typed: the caret stopped moving, the words went nowhere visible, and the only way
1044
+ // to read back what you had written was to send it.
1045
+ //
1046
+ // The *end* is what is kept when there is more of it than [`FIELD_LINES`] can hold, because
1047
+ // the end is where the caret is. A field that scrolled the other way would be one that shows
1048
+ // you everything except the word you are typing.
1049
+ const lines = wrapField(typed, Math.max(8, room - gutter - 2));
1050
+ const shown = lines.slice(Math.max(0, lines.length - FIELD_LINES));
1051
+ shown.forEach((line, n) => {
1052
+ // The gutter is paid for on every line and drawn on the first, as the option rows do it:
1053
+ // repeating the pen down the side would read as one answer per line.
1054
+ const run = n === 0 ? lead : " ".repeat(gutter);
1055
+ const text = ` ${run}${line}`;
1056
+ const marks: DrawnMark[] = [{ col: 0, opts: { lineHlGroup: "Question.Cursor" } }];
1057
+ if (n === 0) marks.push(mark(LEFT, LEFT + byteLength(lead), "Question.Taken", 100));
1058
+ const bodyAt = LEFT + byteLength(run);
1059
+ if (line !== "") marks.push(mark(bodyAt, byteLength(text), "Question.Custom", 100));
1060
+ if (n === shown.length - 1) {
1061
+ // The caret sits at the end of what has been typed, which is the one place on this panel
1062
+ // where a terminal cursor means "the keyboard goes here".
1063
+ caret = rows.length;
1064
+ caretCol = bodyAt + byteLength(line);
1065
+ }
1066
+ rows.push({ text, marks });
1067
+ });
1068
+ } else {
1069
+ const label = pad(clip(OTHER, labelWidth), labelWidth);
1070
+ const hint = forDetail >= 8 ? clip("type an answer of your own", forDetail) : "";
1071
+ const body = hint ? `${label} ${hint}` : label.trimEnd();
1072
+ const filler = badge ? " ".repeat(Math.max(1, room - gutter - cols(body) - cols(badge))) : "";
1073
+ const text = ` ${lead}${body}${filler}${badge}`;
1074
+
1075
+ const marks: DrawnMark[] = [mark(LEFT, LEFT + byteLength(lead), "Question.Box", 100)];
1076
+ if (here) marks.unshift({ col: 0, opts: { lineHlGroup: "Question.Cursor" } });
1077
+ const bodyAt = LEFT + byteLength(lead);
1078
+ marks.push(mark(bodyAt, bodyAt + byteLength(clip(OTHER, labelWidth)), "Question.Option", 100));
1079
+ const hintAt = bodyAt + byteLength(label) + 2;
1080
+ if (byteLength(text) > hintAt) {
1081
+ marks.push(mark(hintAt, byteLength(text), "Question.Detail", 100));
1082
+ }
1083
+ if (here) caret = rows.length;
1084
+ if (badge) {
1085
+ marks.push(
1086
+ mark(byteLength(text) - byteLength(badge), byteLength(text), "Question.Shortcut", 100),
1087
+ );
1088
+ }
1089
+ rows.push({ text, marks });
1090
+ }
1091
+ }
1092
+
1093
+ // --- the keys --------------------------------------------------------------
1094
+ rows.push({ text: "" });
1095
+ const hint = ` ${clip(hints(question, ask, custom, room), room)}`;
1096
+ rows.push({ text: hint, marks: [mark(LEFT, byteLength(hint), "Question.Hint")] });
1097
+ return { rows, caret, caretCol };
1098
+ }
1099
+
1100
+ /** `●●○` — how many questions there are and which one this is, when there is more than one. */
1101
+ function progress(ask: Ask, g: Glyphs): string {
1102
+ if (ask.questions.length < 2) return "";
1103
+ const dots = ask.questions
1104
+ .map((q, i) => (i === ask.at ? g.dotHere : resolve(ask, q).length > 0 ? g.dotDone : g.dotTodo))
1105
+ .join("");
1106
+ return `${dots} ${ask.at + 1}/${ask.questions.length}`;
1107
+ }
1108
+
1109
+ /**
1110
+ * The key strip, which changes with what the question is and what you have done to it.
1111
+ *
1112
+ * Fitted rather than clipped. A strip that runs off the edge loses whichever key happens to be last
1113
+ * — `esc dismiss`, as it turned out, on the one panel you most need to be told how to leave — so
1114
+ * the parts are ranked and the least useful ones drop out until the rest fits. What is left is
1115
+ * always a whole phrase.
1116
+ */
1117
+ function hints(question: UserQuestion, ask: Ask, custom: boolean, room: number): string {
1118
+ /** `[what it says, how readily it goes]`, lower going last. */
1119
+ const onCustom = ask.cursor === question.options.length;
1120
+ const parts: Array<[string, number]> = custom
1121
+ ? [["\u21b5 send this answer", 0], ["\u232b back to the options", 1], ["esc dismiss", 1]]
1122
+ : onCustom
1123
+ // On the row at the foot, the only thing worth saying is what it does. The keys that move
1124
+ // between rows are the ones you just used to get here.
1125
+ ? [
1126
+ ["\u21b5 write your own answer", 0],
1127
+ ["\u2191\u2193 move", 3],
1128
+ ...(ask.at > 0 ? [["\u21e7\u21e5 back", 5] as [string, number]] : []),
1129
+ ["esc dismiss", 1],
1130
+ ]
1131
+ : [
1132
+ [question.multi_select ? "\u21e5 tick" : "\u21b5 choose", 0],
1133
+ ...(question.multi_select ? [["\u21b5 done", 0] as [string, number]] : []),
1134
+ ["\u2191\u2193 move", 3],
1135
+ ...(question.options.length > 1
1136
+ ? [[`1-${Math.min(SHORTCUTS, question.options.length)} pick`, 4] as [string, number]]
1137
+ : []),
1138
+ ["0 write your own", 2],
1139
+ ...(ask.at > 0 ? [["\u21e7\u21e5 back", 5] as [string, number]] : []),
1140
+ ["esc dismiss", 1],
1141
+ ];
1142
+
1143
+ // Widest set first, dropping a whole rank at a time. Rank 0 is what the panel cannot be used
1144
+ // without, so it is what is left when nothing else fits — clipped, if it comes to that, but never
1145
+ // silently missing the key that closes the thing.
1146
+ const ranks = [...new Set(parts.map(([, rank]) => rank))].sort((a, b) => b - a);
1147
+ for (const cut of ranks) {
1148
+ const line = parts.filter(([, rank]) => rank <= cut).map(([text]) => text).join(" ");
1149
+ if (cols(line) <= room || cut === 0) return line;
1150
+ }
1151
+ return parts.map(([text]) => text).join(" ");
1152
+ }
1153
+
1154
+ /** `text`, or as much of it as fits with an ellipsis. By code point, never by byte. */
1155
+ function clip(text: string, room: number): string {
1156
+ const chars = [...text];
1157
+ if (chars.length <= room) return text;
1158
+ return `${chars.slice(0, Math.max(1, room - 1)).join("")}…`;
1159
+ }
1160
+
1161
+ /**
1162
+ * Break `text` onto lines of at most `width`, cutting a word that is wider than the column.
1163
+ *
1164
+ * The difference from [`wrap`] is where the result lands. That one fills the panel, which is as
1165
+ * wide as the conversation, so a long word simply runs to the next line. This one fills a *column*
1166
+ * beside another column — and a word wider than it does not overhang harmlessly, it pushes the
1167
+ * description sideways and takes the alignment of every line below it with it. Option labels are
1168
+ * routinely identifiers, paths or flags, which is to say routinely longer than 28 columns.
1169
+ */
1170
+ function wrapCols(text: string, width: number): string[] {
1171
+ const limit = Math.max(1, width);
1172
+ const out: string[] = [];
1173
+ let line = "";
1174
+ for (const word of text.trim().split(/\s+/).filter(Boolean)) {
1175
+ const next = line ? `${line} ${word}` : word;
1176
+ if (cols(next) <= limit) {
1177
+ line = next;
1178
+ continue;
1179
+ }
1180
+ if (line) {
1181
+ out.push(line);
1182
+ line = "";
1183
+ }
1184
+ let rest = word;
1185
+ while (cols(rest) > limit) {
1186
+ const chars = [...rest];
1187
+ out.push(chars.slice(0, limit).join(""));
1188
+ rest = chars.slice(limit).join("");
1189
+ }
1190
+ line = rest;
1191
+ }
1192
+ if (line) out.push(line);
1193
+ return out.length > 0 ? out : [""];
1194
+ }
1195
+
1196
+ /**
1197
+ * Break what is being *typed* onto lines of at most `width`.
1198
+ *
1199
+ * The third of these, and it exists because the other two rewrite what they wrap. [`wrap`] and
1200
+ * [`wrapCols`] split on whitespace and join with single spaces, which is right for a label or a
1201
+ * sentence that arrived finished and wrong for a field: the two spaces somebody typed would come
1202
+ * back as one, a trailing space would vanish the moment it was typed and reappear with the next
1203
+ * word, and the caret — which is placed at the end of the last line — would sit somewhere other
1204
+ * than where the next character is going to land.
1205
+ *
1206
+ * So every character survives, and the space a line breaks at stays on the end of the line it broke
1207
+ * from: the lines joined back together are exactly what was typed. Words are still kept whole where
1208
+ * one fits, and one that does not is cut, because a word wider than the field has to go somewhere.
1209
+ */
1210
+ function wrapField(text: string, width: number): string[] {
1211
+ const limit = Math.max(1, width);
1212
+ const out: string[] = [];
1213
+ let rest = text;
1214
+ while (cols(rest) > limit) {
1215
+ const chars = [...rest];
1216
+ // The last space that would still leave something before it — a break at the very front would
1217
+ // push the whole line down and make no progress.
1218
+ let at = -1;
1219
+ for (let i = limit - 1; i > 0; i--) {
1220
+ if (chars[i] === " ") {
1221
+ at = i;
1222
+ break;
1223
+ }
1224
+ }
1225
+ const cut = at > 0 ? at + 1 : limit;
1226
+ out.push(chars.slice(0, cut).join(""));
1227
+ rest = chars.slice(cut).join("");
1228
+ }
1229
+ out.push(rest);
1230
+ return out;
1231
+ }
1232
+
1233
+ /**
1234
+ * Break a sentence onto lines of at most `width`.
1235
+ *
1236
+ * By code point, which is an approximation of display width and is the only one available here —
1237
+ * the frontend is the thing that can measure. Erring narrow is the safe direction: a question that
1238
+ * wraps a column early still reads, and one clipped at the right edge loses its last word.
1239
+ */
1240
+ function wrap(text: string, width: number): string[] {
1241
+ const out: string[] = [];
1242
+ for (const paragraph of text.split("\n")) {
1243
+ let line = "";
1244
+ for (const word of paragraph.split(/\s+/).filter(Boolean)) {
1245
+ if (line && [...line].length + 1 + [...word].length > width) {
1246
+ out.push(line);
1247
+ line = word;
1248
+ } else {
1249
+ line = line ? `${line} ${word}` : word;
1250
+ }
1251
+ }
1252
+ out.push(line);
1253
+ }
1254
+ return out.length > 0 ? out : [""];
1255
+ }
1256
+
1257
+ /**
1258
+ * The panel's colours, as links.
1259
+ *
1260
+ * Every one of them points at a group the palette already defines, so the panel follows the theme
1261
+ * without owning a single colour — and every one is a name an `init.ts` can redefine, which is what
1262
+ * makes the look of this replaceable without replacing the plugin.
1263
+ */
1264
+ async function defineGroups(neosh: Neosh): Promise<void> {
1265
+ const groups: Array<[string, string]> = [
1266
+ // Shimmers: the agent has stopped, and this is what is stopping it.
1267
+ ["Question.Header", "Agent.ToolLive"],
1268
+ ["Question.Rail", "Question.Header"],
1269
+ ["Question.Border", "Float.Border"],
1270
+ ["Question.Text", "Agent.Assistant"],
1271
+ ["Question.Option", "Agent.Assistant"],
1272
+ ["Question.Detail", "Picker.Detail"],
1273
+ ["Question.Cursor", "Picker.Selected"],
1274
+ ["Question.Box", "Comment"],
1275
+ ["Question.Taken", "Diagnostic.Ok"],
1276
+ ["Question.Shortcut", "Composer.HintKey"],
1277
+ ["Question.Custom", "Status.Input"],
1278
+ ["Question.Muted", "Comment"],
1279
+ ["Question.Hint", "Composer.Hint"],
1280
+ ["Question.Waiting", "Status.Pending"],
1281
+ ];
1282
+ for (const [name, to] of groups) {
1283
+ await neosh.hl.define(name, { link: to }).catch(() => {});
1284
+ }
1285
+ }
1286
+
1287
+ /** Re-exported so the type-checker keeps the hook signature honest. */
1288
+ export type { Neosh };
package/package.json ADDED
@@ -0,0 +1,21 @@
1
+ {
2
+ "name": "@neosh/questions",
3
+ "version": "0.1.0",
4
+ "description": "Draws the question an agent asks you, and sends back what you said.",
5
+ "license": "MIT",
6
+ "type": "module",
7
+ "keywords": [
8
+ "neosh",
9
+ "neosh-plugin"
10
+ ],
11
+ "repository": {
12
+ "type": "git",
13
+ "url": "git+https://github.com/neoswarm/neosh.git",
14
+ "directory": "plugins/builtin/questions"
15
+ },
16
+ "files": [
17
+ "*.ts",
18
+ "plugin.toml",
19
+ "!._*"
20
+ ]
21
+ }
package/plugin.toml ADDED
@@ -0,0 +1,5 @@
1
+ name = "questions"
2
+ version = "0.1.0"
3
+ entry = "main.ts"
4
+ description = "Draws the question an agent asks you, and sends back what you said."
5
+ permissions = ["hooks_blocking"]