@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.
- package/main.ts +1288 -0
- package/package.json +21 -0
- 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
|
+
}
|