pi-umbra 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 (66) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +27 -0
  3. package/node_modules/pi-umbra-ask/LICENSE +21 -0
  4. package/node_modules/pi-umbra-ask/README.md +21 -0
  5. package/node_modules/pi-umbra-ask/checks/umbra-ask.check.ts +132 -0
  6. package/node_modules/pi-umbra-ask/extensions/umbra-ask.ts +484 -0
  7. package/node_modules/pi-umbra-ask/lib/clean.ts +25 -0
  8. package/node_modules/pi-umbra-ask/package.json +37 -0
  9. package/node_modules/pi-umbra-copy-chat/LICENSE +21 -0
  10. package/node_modules/pi-umbra-copy-chat/README.md +19 -0
  11. package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +154 -0
  12. package/node_modules/pi-umbra-copy-chat/package.json +32 -0
  13. package/node_modules/pi-umbra-help/LICENSE +21 -0
  14. package/node_modules/pi-umbra-help/README.md +21 -0
  15. package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +108 -0
  16. package/node_modules/pi-umbra-help/extensions/umbra-help.ts +223 -0
  17. package/node_modules/pi-umbra-help/lib/umbra-settings.ts +52 -0
  18. package/node_modules/pi-umbra-help/package.json +34 -0
  19. package/node_modules/pi-umbra-inputbar/LICENSE +21 -0
  20. package/node_modules/pi-umbra-inputbar/README.md +19 -0
  21. package/node_modules/pi-umbra-inputbar/checks/umbra-inputbar.check.ts +53 -0
  22. package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +130 -0
  23. package/node_modules/pi-umbra-inputbar/package.json +33 -0
  24. package/node_modules/pi-umbra-preview/LICENSE +21 -0
  25. package/node_modules/pi-umbra-preview/README.md +32 -0
  26. package/node_modules/pi-umbra-preview/checks/umbra-preview.check.ts +38 -0
  27. package/node_modules/pi-umbra-preview/extensions/umbra-preview.ts +147 -0
  28. package/node_modules/pi-umbra-preview/package.json +33 -0
  29. package/node_modules/pi-umbra-rename/LICENSE +21 -0
  30. package/node_modules/pi-umbra-rename/README.md +19 -0
  31. package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +55 -0
  32. package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +22 -0
  33. package/node_modules/pi-umbra-rename/package.json +33 -0
  34. package/node_modules/pi-umbra-shimmer/LICENSE +21 -0
  35. package/node_modules/pi-umbra-shimmer/README.md +19 -0
  36. package/node_modules/pi-umbra-shimmer/checks/umbra-shimmer.check.ts +89 -0
  37. package/node_modules/pi-umbra-shimmer/extensions/umbra-shimmer.ts +183 -0
  38. package/node_modules/pi-umbra-shimmer/package.json +33 -0
  39. package/node_modules/pi-umbra-skill-matcher/LICENSE +21 -0
  40. package/node_modules/pi-umbra-skill-matcher/README.md +19 -0
  41. package/node_modules/pi-umbra-skill-matcher/checks/umbra-skill-matcher.check.ts +94 -0
  42. package/node_modules/pi-umbra-skill-matcher/extensions/umbra-skill-matcher.ts +124 -0
  43. package/node_modules/pi-umbra-skill-matcher/package.json +35 -0
  44. package/node_modules/pi-umbra-theme/LICENSE +21 -0
  45. package/node_modules/pi-umbra-theme/README.md +38 -0
  46. package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +158 -0
  47. package/node_modules/pi-umbra-theme/checks/umbra-footer.check.ts +72 -0
  48. package/node_modules/pi-umbra-theme/checks/umbra-gutter.check.ts +59 -0
  49. package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +71 -0
  50. package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +120 -0
  51. package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +155 -0
  52. package/node_modules/pi-umbra-theme/extensions/umbra-footer.ts +150 -0
  53. package/node_modules/pi-umbra-theme/extensions/umbra-gutter.ts +35 -0
  54. package/node_modules/pi-umbra-theme/extensions/umbra-toolbox.ts +129 -0
  55. package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +209 -0
  56. package/node_modules/pi-umbra-theme/lib/umbra-format.ts +16 -0
  57. package/node_modules/pi-umbra-theme/lib/umbra-settings.ts +52 -0
  58. package/node_modules/pi-umbra-theme/package.json +45 -0
  59. package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +88 -0
  60. package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +88 -0
  61. package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +88 -0
  62. package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +88 -0
  63. package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +88 -0
  64. package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +88 -0
  65. package/node_modules/pi-umbra-theme/themes/umbra-violet-forge.json +88 -0
  66. package/package.json +68 -0
@@ -0,0 +1,484 @@
1
+ import { Type } from "@earendil-works/pi-ai";
2
+ import { defineTool, type ExtensionAPI, type ExtensionContext } from "@earendil-works/pi-coding-agent";
3
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
4
+ import { clean, cleanBlock } from "../lib/clean.ts";
5
+
6
+ // A questionnaire the model can open. The shipped rpiv package draws the same thing in
7
+ // 5204 lines across 39 files; this is the part actually used here.
8
+ //
9
+ // Layout, one column per element so nothing drifts when a glyph is selected:
10
+ // "3 " (2) + "[x] " (4) = label starts at column 6, the consequence line indents to match.
11
+ // Every glyph is single-cell ASCII, so a checked row is exactly as wide as an unchecked one,
12
+ // and "( )" costs the same four columns as "[ ]".
13
+ // There is no cursor glyph column: the row under the cursor is coloured instead, which
14
+ // costs no width and cannot fall out of alignment.
15
+ const NUMBER_W = 2;
16
+ const BOX_W = 4;
17
+ const INDENT = NUMBER_W + BOX_W;
18
+
19
+ // Every question grows one row the model does not author, so the user is never trapped
20
+ // inside a list of wrong answers. It sits last, in single and multi alike. Left empty it
21
+ // reads "None of these" and answers that; type into it and the text is the answer.
22
+ const NONE_LABEL = "None of these";
23
+ // Shown on the open row while it is empty. The hint is display only: the answer sent for
24
+ // an empty open row is NONE_LABEL, never the parenthetical.
25
+ const NONE_HINT = `${NONE_LABEL}. (Type it)`;
26
+
27
+ const KEY = {
28
+ up: "\x1b[A",
29
+ down: "\x1b[B",
30
+ right: "\x1b[C",
31
+ left: "\x1b[D",
32
+ enter: "\r",
33
+ escape: "\x1b",
34
+ tab: "\t",
35
+ del: "\x7f",
36
+ backspace: "\b",
37
+ };
38
+
39
+ type Option = { label: string; consequence?: string; preview?: string };
40
+ type Question = {
41
+ question: string;
42
+ header: string;
43
+ recommendedWord?: string;
44
+ multiSelect?: boolean;
45
+ recommended?: number;
46
+ options: Option[];
47
+ };
48
+
49
+ // What the user has done to one question. One record rather than two arrays indexed in
50
+ // parallel, so an answer set and the draft text behind it cannot drift out of step.
51
+ type Draft = { picked: Set<number>; typed: string };
52
+
53
+ const TOOL_NAME = "ask_user_question";
54
+
55
+ const pad = (count: number) => " ".repeat(Math.max(0, count));
56
+
57
+ // A preview sits beside the options rather than under them, so the eye compares samples instead
58
+ // of scrolling between them. It only earns a column when there is room for both: under this width
59
+ // the options themselves would be truncated to make space for a sample of something not yet
60
+ // chosen, which is the wrong trade, so the list goes back to full width and the preview is dropped.
61
+ const PREVIEW_MIN_WIDTH = 76;
62
+ const PREVIEW_SHARE = 0.45;
63
+ const GAP = " \u2502 ";
64
+
65
+ export const padTo = (line: string, columns: number) => line + pad(columns - visibleWidth(line));
66
+
67
+ // English is the fallback, not the assumption: the model overrides it with the word its own
68
+ // answer is written in, so a Turkish conversation does not sprout one English parenthesis.
69
+ const RECOMMENDED_WORD = "Recommended";
70
+
71
+ /**
72
+ * Greedy word wrap over PLAIN text. The colour is applied to each returned line afterwards, which
73
+ * is what keeps an ANSI escape from ever being split down the middle. A word wider than the column
74
+ * is broken rather than allowed to push the preview out of alignment.
75
+ */
76
+ export const wrap = (text: string, columns: number): string[] => {
77
+ if (columns < 1) return [text];
78
+ const lines: string[] = [];
79
+ let line = "";
80
+ const flush = () => {
81
+ if (line !== "") lines.push(line);
82
+ line = "";
83
+ };
84
+ for (let word of text.split(" ")) {
85
+ while (visibleWidth(word) > columns) {
86
+ flush();
87
+ lines.push(word.slice(0, columns));
88
+ word = word.slice(columns);
89
+ }
90
+ if (line === "") line = word;
91
+ else if (visibleWidth(line) + 1 + visibleWidth(word) <= columns) line += ` ${word}`;
92
+ else {
93
+ flush();
94
+ line = word;
95
+ }
96
+ }
97
+ flush();
98
+ return lines.length > 0 ? lines : [""];
99
+ };
100
+
101
+ /** Two blocks side by side. The shorter one runs out and leaves its column blank underneath. */
102
+ export const beside = (
103
+ left: string[],
104
+ right: string[],
105
+ leftWidth: number,
106
+ paintGap: (text: string) => string,
107
+ ): string[] => {
108
+ const height = Math.max(left.length, right.length);
109
+ const merged: string[] = [];
110
+ for (let row = 0; row < height; row++) {
111
+ const sample = right[row];
112
+ // Under the preview there is nothing left to separate, so the rule stops there too. Run it
113
+ // to the bottom of the taller column and the last rows carry a bar with empty space after
114
+ // it, which reads as a stray mark rather than as the edge of a column.
115
+ merged.push(
116
+ sample === undefined
117
+ ? (left[row] ?? "")
118
+ : padTo(left[row] ?? "", leftWidth) + paintGap(GAP) + sample,
119
+ );
120
+ }
121
+ return merged;
122
+ };
123
+
124
+ export default function (pi: ExtensionAPI) {
125
+ // A questionnaire nobody can answer is 405 prompt tokens of dead weight. pi assembles the
126
+ // system prompt from the ACTIVE tool names, so dropping the name here removes the schema and
127
+ // the promptSnippet together - a tool left registered but inactive costs nothing.
128
+ pi.on("before_agent_start", (_event, ctx) => {
129
+ if (ctx.hasUI) return;
130
+ const active = pi.getActiveTools();
131
+ if (active.includes(TOOL_NAME)) pi.setActiveTools(active.filter((name) => name !== TOOL_NAME));
132
+ });
133
+
134
+ pi.registerTool(defineTool({
135
+ name: TOOL_NAME,
136
+ label: "Ask User",
137
+ description:
138
+ "Ask the user one or more multiple-choice questions and wait for their answer. Use this only when the answer genuinely changes what you do next and you cannot resolve it from the code or a sensible default. Always set `recommended` unless you genuinely have no lean, so the user reviews a filled answer instead of building one.",
139
+ promptSnippet: "Ask the user a multiple-choice question when a decision is genuinely theirs.",
140
+ parameters: Type.Object(
141
+ {
142
+ questions: Type.Array(
143
+ Type.Object(
144
+ {
145
+ // No prose on these two: measured, the model already ends `question` with a
146
+ // question mark and already writes `header` as the axis the options differ on.
147
+ question: Type.String(),
148
+ header: Type.String(),
149
+ multiSelect: Type.Optional(Type.Boolean({ description: "Allow more than one answer." })),
150
+ // The one fact here that cannot be inferred from the name or the type. Counting
151
+ // from 1 would silently pre-check the wrong row, and `minimum` cannot catch that.
152
+ recommended: Type.Optional(Type.Integer({ minimum: 0, description: "0-based index." })),
153
+ // Not a translation table in the code: the set of languages a conversation can
154
+ // be in is open, and the model is already writing every other string here.
155
+ recommendedWord: Type.Optional(Type.String({
156
+ description: "The word for \"Recommended\" in the language you are answering in, e.g. `Recommended`, `Önerilen`, `Recomendado`. It is appended to the recommended option in parentheses. Send it whenever `recommended` is set and you are not writing in English.",
157
+ })),
158
+ options: Type.Array(
159
+ Type.Object(
160
+ {
161
+ label: Type.String({ description: "The choice itself, 1-5 words. Not 'recommended', not 'better'." }),
162
+ // The field name is the prompt. Called `description` the model writes prose and
163
+ // sells; called `consequence` there is nowhere to put an adjective.
164
+ consequence: Type.Optional(Type.String({
165
+ description: "One line naming what the label leaves out: the exact value, path, flag or line that will exist if this is picked. Drop it when the label already says everything, and never restate the label or argue for the option. Good: `PI_AUTOCOMPACT_PERCENT=50`. Bad: `Ankara is selected`.",
166
+ })),
167
+ preview: Type.Optional(Type.String({
168
+ description: "The artifact this option produces, drawn beside the list so the options can be compared by eye: an ASCII mockup, a config block, a diff, a snippet. Multi-line, and raw - never wrapped in Markdown fences, since this column is a terminal panel and a fence renders as three literal backticks. Send it only when the options differ in something visible and only when every option has one, since a half-filled column is worse than none. Never prose, never a restatement of the label, never an argument for it.",
169
+ })),
170
+ },
171
+ { additionalProperties: false },
172
+ ),
173
+ // Eight, not nine: the open row added below pushes the last number to 9, the
174
+ // last one that is a single digit to type and one column wide to draw.
175
+ { minItems: 2, maxItems: 8 },
176
+ ),
177
+ },
178
+ { additionalProperties: false },
179
+ ),
180
+ { minItems: 1, maxItems: 4 },
181
+ ),
182
+ },
183
+ { additionalProperties: false },
184
+ ),
185
+ async execute(_toolCallId, params, _signal, _onUpdate, ctx: ExtensionContext) {
186
+ // SAFETY: pi validates params against `parameters` above before calling execute, so
187
+ // every field the schema declares is present and of the declared type.
188
+ const raw = params.questions as Question[];
189
+ const questions = raw.map((q) => ({
190
+ ...q,
191
+ question: clean(q.question),
192
+ header: clean(q.header),
193
+ // An out-of-range index would check a row that is not there, so it is dropped here
194
+ // rather than guarded at every read site.
195
+ recommended:
196
+ q.recommended !== undefined && q.recommended >= 0 && q.recommended < q.options.length
197
+ ? q.recommended
198
+ : undefined,
199
+ options: q.options.map((o) => ({
200
+ label: clean(o.label),
201
+ consequence: o.consequence === undefined ? undefined : clean(o.consequence),
202
+ // A whitelist, so a field added to the schema and to the renderer but not to
203
+ // this line arrives as undefined and silently does nothing. That is what
204
+ // happened to `preview`.
205
+ preview: o.preview === undefined ? undefined : cleanBlock(o.preview),
206
+ })),
207
+ }));
208
+
209
+ if (ctx.mode !== "tui") {
210
+ return { content: [{ type: "text" as const, text: "No interactive UI available; the question was not asked." }], details: undefined };
211
+ }
212
+
213
+ const answers = await ctx.ui.custom<string[][] | undefined>((tui, theme, _keybindings, done) => {
214
+ // The last tab is Submit, always, even for a single question. That keeps one code
215
+ // path and leaves enter free to mean "choose" everywhere else.
216
+ const SUBMIT = questions.length;
217
+ const ACTIONS = ["Send", "Cancel"];
218
+ const drafts: Draft[] = questions.map((q) => ({
219
+ picked: new Set<number>(q.recommended === undefined ? [] : [q.recommended]),
220
+ typed: "",
221
+ }));
222
+
223
+ // SAFETY for both: `drafts` is built from `questions` and neither is ever resized,
224
+ // so any index that reaches here came from iterating `questions` or from `tab`,
225
+ // which only `goTab` moves and only within 0..SUBMIT. Callers that can be sitting
226
+ // on SUBMIT check for it first.
227
+ const questionAt = (index: number) => questions[index] as Question;
228
+ const draftAt = (index: number) => drafts[index] as Draft;
229
+
230
+ // Enter confirms whatever the cursor is on, so the cursor has to start on the
231
+ // recommended row. Starting at 0 meant one enter silently replaced the model's
232
+ // recommendation with option 1.
233
+ const startCursor = (index: number) => questionAt(index).recommended ?? 0;
234
+
235
+ let tab = 0;
236
+ let cursor = startCursor(0);
237
+
238
+ const current = () => questionAt(tab);
239
+ // The one row past the model's own options.
240
+ const openRow = (index: number) => questionAt(index).options.length;
241
+ const rowCount = (index: number) => openRow(index) + 1;
242
+
243
+ const labelsFor = (index: number) => {
244
+ const question = questionAt(index);
245
+ const draft = draftAt(index);
246
+ return [...draft.picked].sort((a, b) => a - b).map((row) => {
247
+ if (row === question.options.length) return draft.typed.trim() || NONE_LABEL;
248
+ // SAFETY: `picked` only ever holds a row index this question drew.
249
+ return (question.options[row] as Option).label;
250
+ });
251
+ };
252
+
253
+ const goTab = (next: number) => {
254
+ if (next < 0 || next > SUBMIT) return;
255
+ tab = next;
256
+ cursor = next === SUBMIT ? 0 : startCursor(next);
257
+ };
258
+
259
+ // Typing into the open row turns it on too, so the rules for turning a row on live
260
+ // here rather than inside toggle().
261
+ const select = (row: number) => {
262
+ const { picked, typed } = draftAt(tab);
263
+ // While the open row is empty it means "none of these", which cannot be true
264
+ // beside a real choice, in either direction. Once it holds text it stops meaning
265
+ // that and coexists. Single-select holds one answer either way.
266
+ const meansNone = typed.trim() === "";
267
+ if (!current().multiSelect || (row === openRow(tab) && meansNone)) picked.clear();
268
+ else if (meansNone) picked.delete(openRow(tab));
269
+ picked.add(row);
270
+ };
271
+
272
+ const toggle = () => {
273
+ const { picked } = draftAt(tab);
274
+ if (current().multiSelect && picked.has(cursor)) picked.delete(cursor);
275
+ else select(cursor);
276
+ };
277
+
278
+ return {
279
+ render(width: number): string[] {
280
+ const lines: string[] = [];
281
+
282
+ const tabs = [...questions.map((q) => q.header), "Submit"].map((header, index) => {
283
+ if (index === tab) return theme.fg("accent", header);
284
+ if (index === SUBMIT) return theme.fg("dim", header);
285
+ return theme.fg(draftAt(index).picked.size > 0 ? "text" : "dim", header);
286
+ });
287
+ // A rule on top. pi draws its own tool header directly above this component and
288
+ // there is no hook to suppress it, so the next best thing is a line that says
289
+ // where the header stops and the question starts.
290
+ lines.push(theme.fg("dim", "─".repeat(width)));
291
+ lines.push(truncateToWidth(tabs.join(theme.fg("dim", " | ")), width));
292
+ lines.push("");
293
+
294
+ if (tab === SUBMIT) {
295
+ lines.push(truncateToWidth(theme.fg("text", "Send these answers?"), width));
296
+ lines.push("");
297
+ const widest = Math.max(...questions.map((q) => q.header.length));
298
+ // One answer per line. A comma cannot separate these: the labels hold slashes
299
+ // and spaces of their own. The dash appears only when there is more than one,
300
+ // so three lines cannot be misread as one wrapped sentence.
301
+ const gutter = pad(NUMBER_W + widest + ": ".length);
302
+ questions.forEach((q, index) => {
303
+ const head = pad(NUMBER_W) + theme.fg("muted", `${q.header}:${pad(widest - q.header.length)} `);
304
+ const answers = labelsFor(index);
305
+ if (answers.length === 0) {
306
+ lines.push(truncateToWidth(head + theme.fg("dim", "(no answer)"), width));
307
+ return;
308
+ }
309
+ const bullet = answers.length > 1 ? "- " : "";
310
+ answers.forEach((answer, row) => {
311
+ lines.push(truncateToWidth((row === 0 ? head : gutter) + theme.fg("text", bullet + answer), width));
312
+ });
313
+ });
314
+ lines.push("");
315
+ ACTIONS.forEach((action, index) => {
316
+ lines.push(
317
+ truncateToWidth(
318
+ theme.fg(index === cursor ? "accent" : "dim", `${index + 1} `) +
319
+ theme.fg(index === cursor ? "accent" : "text", action),
320
+ width,
321
+ ),
322
+ );
323
+ });
324
+ lines.push("");
325
+ lines.push(truncateToWidth(theme.fg("dim", "↵ confirm · ←/→ question · esc to cancel"), width));
326
+ return lines;
327
+ }
328
+
329
+ const question = current();
330
+ const { picked, typed } = draftAt(tab);
331
+ const OPEN = openRow(tab);
332
+ lines.push(truncateToWidth(theme.fg("text", question.question), width));
333
+ lines.push("");
334
+
335
+ // Round takes one answer, square takes many. The shape carries the rule on
336
+ // every row, so it is still there once the question has scrolled out of view.
337
+ const [emptyBox, checkedBox] = question.multiSelect ? ["[ ]", "[x]"] : ["( )", "(x)"];
338
+
339
+ const shown: Option[] = question.options.concat([{ label: "" }]);
340
+ // Every option, or none: one filled column beside seven empty ones reads as a
341
+ // bug rather than as a comparison.
342
+ const previewed = question.options.every((option) => option.preview !== undefined);
343
+ const split = previewed && width >= PREVIEW_MIN_WIDTH;
344
+ // A ceiling for wrapping, not the column's final width. The options are usually
345
+ // far shorter than their share, and padding them out to it pushed the preview
346
+ // across the terminal with dead space in between.
347
+ const bodyWidth = split ? width - Math.floor((width - GAP.length) * PREVIEW_SHARE) - GAP.length : width;
348
+ const rows: string[] = [];
349
+ shown.forEach((option, index) => {
350
+ const on = picked.has(index);
351
+ const here = index === cursor;
352
+ const isOpen = index === OPEN;
353
+ // The cursor is the caret on the open row: land there and you are already
354
+ // typing, and the first keystroke replaces the whole hint.
355
+ const caret = here ? "_" : "";
356
+ const label = isOpen ? (typed || NONE_HINT) + caret : option.label;
357
+ const placeholder = isOpen && !here && !typed;
358
+ // The note is drawn, never stored: `labelsFor` still returns the bare label, so
359
+ // the answer that reaches the model is the option, not the option plus a word
360
+ // about it.
361
+ const noted = index === question.recommended
362
+ ? `${label} (${question.recommendedWord ?? RECOMMENDED_WORD})`
363
+ : label;
364
+ // Wrapped, not truncated. With a preview beside it the column is narrow enough
365
+ // that cutting would hide the end of the option being compared.
366
+ const paintLabel = (text: string) =>
367
+ theme.fg(placeholder ? "dim" : here ? "accent" : "text", text);
368
+ const [first = "", ...rest] = wrap(noted, bodyWidth - INDENT);
369
+ rows.push(
370
+ theme.fg(here ? "accent" : "dim", `${index + 1} `) +
371
+ theme.fg(on ? "accent" : "muted", `${on ? checkedBox : emptyBox} `) +
372
+ paintLabel(first),
373
+ );
374
+ rest.forEach((line) => rows.push(pad(INDENT) + paintLabel(line)));
375
+ if (option.consequence) {
376
+ wrap(option.consequence, bodyWidth - INDENT).forEach((line) => {
377
+ rows.push(pad(INDENT) + theme.fg("muted", line));
378
+ });
379
+ }
380
+ });
381
+ // The preview follows the cursor, not the answer: it shows what you are looking
382
+ // at, so moving through the list is what compares them.
383
+ // Trimmed while still plain: theme.fg("muted", "") returns a pair of ANSI codes,
384
+ // which is not an empty string, so a blank line painted first survives every
385
+ // emptiness test and buys a whole row of separator with nothing beside it.
386
+ // The option under the cursor is often the "none of these" row, which has no
387
+ // sample at all, and that is exactly the row this was drawing a bar on.
388
+ // The options are measured once they exist, and everything they did not use goes
389
+ // to the preview. The rule then sits just past the longest option instead of on
390
+ // a share of the terminal that nothing reaches.
391
+ const leftWidth = Math.min(bodyWidth, Math.max(0, ...rows.map(visibleWidth)));
392
+ const previewWidth = split ? width - leftWidth - GAP.length : 0;
393
+ const sample = split ? (shown[cursor]?.preview ?? "").split("\n") : [];
394
+ while (sample.length > 0 && (sample[sample.length - 1] ?? "").trim() === "") sample.pop();
395
+ const preview = sample.map((line) => truncateToWidth(theme.fg("muted", line), previewWidth));
396
+ lines.push(...(split ? beside(rows, preview, leftWidth, (gap) => theme.fg("dim", gap)) : rows));
397
+ lines.push("");
398
+ lines.push(truncateToWidth(theme.fg("dim", "↵ choose · ←/→ question · esc to cancel"), width));
399
+ return lines;
400
+ },
401
+
402
+ handleInput(data: string): void {
403
+ const OPEN = tab === SUBMIT ? -1 : openRow(tab);
404
+ const count = tab === SUBMIT ? ACTIONS.length : rowCount(tab);
405
+ // There is no typing mode to enter or leave. Every key that is not a movement,
406
+ // a digit shortcut, enter or escape is text, and text always lands in the open
407
+ // row. So no letter can be a shortcut, and the arrows keep working because they
408
+ // arrive as escape sequences rather than as characters.
409
+ const writing = cursor === OPEN;
410
+
411
+ if (data === KEY.up) cursor = (cursor - 1 + count) % count;
412
+ else if (data === KEY.down) cursor = (cursor + 1) % count;
413
+ else if (data === KEY.left) goTab(tab - 1);
414
+ else if (data === KEY.right || data === KEY.tab) goTab(tab + 1);
415
+ else if (data === KEY.enter) {
416
+ if (tab === SUBMIT) {
417
+ if (cursor === 0) return done(questions.map((_q, index) => labelsFor(index)));
418
+ return done(undefined);
419
+ }
420
+ // Once the open row holds text, enter confirms rather than toggles, so the
421
+ // key that submits an answer can never be the key that clears it.
422
+ if (writing && draftAt(tab).typed.trim()) select(OPEN);
423
+ else toggle();
424
+ // One answer settles a single-select question, so enter moves on. A
425
+ // multi-select one stays put; you may still want another box.
426
+ if (!current().multiSelect) goTab(tab + 1);
427
+ } else if (writing && (data === KEY.del || data === KEY.backspace)) {
428
+ const draft = draftAt(tab);
429
+ draft.typed = draft.typed.slice(0, -1);
430
+ if (!draft.typed) draft.picked.delete(OPEN);
431
+ } else if (data === KEY.escape) {
432
+ // Escape undoes the smallest thing first, so one stray keypress cannot
433
+ // throw away a typed answer and the whole dialog at once.
434
+ if (!writing) return done(undefined);
435
+ const draft = draftAt(tab);
436
+ if (!draft.typed) return done(undefined);
437
+ draft.typed = "";
438
+ draft.picked.delete(OPEN);
439
+ }
440
+ // A digit is a row shortcut only while you are not already writing, so a number
441
+ // can still be typed into an answer. The length check matters: a paste like
442
+ // "1abc" passes the range test, and Number() then makes the cursor NaN, which no
443
+ // later keypress can move.
444
+ else if (!writing && data.length === 1 && data >= "1" && data <= "9") {
445
+ const index = Number(data) - 1;
446
+ if (index >= count) return;
447
+ cursor = index;
448
+ // On Submit a digit only moves the cursor; sending stays behind enter.
449
+ if (tab !== SUBMIT && index !== OPEN) toggle();
450
+ } else if (tab !== SUBMIT && !data.startsWith(KEY.escape)) {
451
+ // Everything left is text, and it lands in the open row from wherever the
452
+ // cursor was, so you never walk down to start writing. Jumping is not choosing:
453
+ // the row stays unchecked until enter. clean() rather than an ASCII range check,
454
+ // so Turkish and every other non-ASCII letter types while control bytes cannot.
455
+ const text = clean(data);
456
+ if (!text) return;
457
+ cursor = OPEN;
458
+ draftAt(tab).typed += text;
459
+ } else return;
460
+
461
+ tui.requestRender();
462
+ },
463
+
464
+ // Required by Component. TUI.invalidate() walks every mounted root and calls this
465
+ // unconditionally (pi-tui/dist/tui.js:530-535), so a component without it throws
466
+ // "root.invalidate is not a function" the moment anything invalidates the screen -
467
+ // a theme change, per the interface docs. Nothing here is cached between frames:
468
+ // render() rebuilds from drafts, tab and cursor every time. So the body is empty
469
+ // by design, not by omission.
470
+ invalidate() {},
471
+ };
472
+ });
473
+
474
+ if (!answers) {
475
+ return { content: [{ type: "text" as const, text: "The user cancelled without answering." }], details: undefined };
476
+ }
477
+
478
+ const text = questions
479
+ .map((q, index) => `${q.header}: ${(answers[index] ?? []).join(", ") || "(no answer)"}`)
480
+ .join("\n");
481
+ return { content: [{ type: "text" as const, text }], details: undefined };
482
+ },
483
+ }));
484
+ }
@@ -0,0 +1,25 @@
1
+ // The model writes every question, label and description an extension shows, so they are
2
+ // untrusted bytes headed for a terminal - a parser, not a canvas. Strip at ingest, so the raw
3
+ // string is never stored and no renderer can ever see it.
4
+ //
5
+ // The C0/C1 range kills ESC, BEL and the 8-bit CSI/OSC introducers. No escape-sequence grammar
6
+ // is needed: remove the introducer and the residue ("]52;c;aGk=") is inert text the emulator
7
+ // never enters a parser state for.
8
+ //
9
+ // The bidi set is enumerated, not swept: ZWNJ and ZWJ sit right beside it at U+200C/U+200D and
10
+ // must survive, or emoji families split and Arabic, Persian and Hindi shaping breaks. Both
11
+ // patterns are built from escapes so the file stays pure ASCII and readable in a diff.
12
+ //
13
+ // This lives outside extensions/ for two reasons: pi loads every .ts in that folder as an
14
+ // extension, and keeping the helper free of imports is what lets tests/ask-clean.test.ts run
15
+ // without resolving pi's own packages.
16
+ const CONTROL = new RegExp("[\u0000-\u001f\u007f-\u009f]", "g");
17
+ const BIDI = new RegExp("[\u200e\u200f\u202a-\u202e\u2066-\u2069]", "g");
18
+
19
+ export const clean = (text: string) => text.replace(CONTROL, "").replace(BIDI, "");
20
+
21
+ // A preview is a block, not a line. The newline is the one control character it needs, and
22
+ // `clean` is right to take it everywhere else: folding an ASCII mockup onto one row is exactly
23
+ // what happened before this existed. Cleaning line by line keeps \n and still drops the rest of
24
+ // the C0/C1 range, carriage return included - that one moves the cursor, not the line.
25
+ export const cleanBlock = (text: string) => text.split("\n").map(clean).join("\n");
@@ -0,0 +1,37 @@
1
+ {
2
+ "name": "pi-umbra-ask",
3
+ "version": "0.1.0",
4
+ "description": "A tool the model can call to ask you a question mid-turn. Answers are stripped of control and bidi characters before any renderer sees them.",
5
+ "keywords": [
6
+ "pi-package",
7
+ "pi-extension",
8
+ "tool",
9
+ "prompt"
10
+ ],
11
+ "author": "grkn",
12
+ "license": "MIT",
13
+ "repository": {
14
+ "type": "git",
15
+ "url": "git+https://github.com/grknbyk/pi-umbra.git",
16
+ "directory": "pi-umbra-ask"
17
+ },
18
+ "homepage": "https://github.com/grknbyk/pi-umbra/tree/main/pi-umbra-ask#readme",
19
+ "bugs": "https://github.com/grknbyk/pi-umbra/issues",
20
+ "type": "module",
21
+ "files": [
22
+ "extensions",
23
+ "lib",
24
+ "checks",
25
+ "README.md"
26
+ ],
27
+ "pi": {
28
+ "extensions": [
29
+ "./extensions"
30
+ ]
31
+ },
32
+ "peerDependencies": {
33
+ "@earendil-works/pi-ai": "*",
34
+ "@earendil-works/pi-coding-agent": "*",
35
+ "@earendil-works/pi-tui": "*"
36
+ }
37
+ }
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 grkn
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1,19 @@
1
+ # pi-umbra-copy-chat
2
+
3
+ /copy-chat puts the conversation on the clipboard.
4
+
5
+ ```
6
+ pi-umbra-copy-chat/
7
+ ├── checks
8
+ │ └── umbra-copy-chat.check.ts
9
+ └── extensions
10
+ └── umbra-copy-chat.ts
11
+ ```
12
+
13
+ ## Install
14
+
15
+ ```sh
16
+ pi install npm:pi-umbra-copy-chat
17
+ ```
18
+
19
+ MIT.