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.
- package/LICENSE +21 -0
- package/README.md +27 -0
- package/node_modules/pi-umbra-ask/LICENSE +21 -0
- package/node_modules/pi-umbra-ask/README.md +21 -0
- package/node_modules/pi-umbra-ask/checks/umbra-ask.check.ts +132 -0
- package/node_modules/pi-umbra-ask/extensions/umbra-ask.ts +484 -0
- package/node_modules/pi-umbra-ask/lib/clean.ts +25 -0
- package/node_modules/pi-umbra-ask/package.json +37 -0
- package/node_modules/pi-umbra-copy-chat/LICENSE +21 -0
- package/node_modules/pi-umbra-copy-chat/README.md +19 -0
- package/node_modules/pi-umbra-copy-chat/extensions/umbra-copy-chat.ts +154 -0
- package/node_modules/pi-umbra-copy-chat/package.json +32 -0
- package/node_modules/pi-umbra-help/LICENSE +21 -0
- package/node_modules/pi-umbra-help/README.md +21 -0
- package/node_modules/pi-umbra-help/checks/umbra-help.check.ts +108 -0
- package/node_modules/pi-umbra-help/extensions/umbra-help.ts +223 -0
- package/node_modules/pi-umbra-help/lib/umbra-settings.ts +52 -0
- package/node_modules/pi-umbra-help/package.json +34 -0
- package/node_modules/pi-umbra-inputbar/LICENSE +21 -0
- package/node_modules/pi-umbra-inputbar/README.md +19 -0
- package/node_modules/pi-umbra-inputbar/checks/umbra-inputbar.check.ts +53 -0
- package/node_modules/pi-umbra-inputbar/extensions/umbra-inputbar.ts +130 -0
- package/node_modules/pi-umbra-inputbar/package.json +33 -0
- package/node_modules/pi-umbra-preview/LICENSE +21 -0
- package/node_modules/pi-umbra-preview/README.md +32 -0
- package/node_modules/pi-umbra-preview/checks/umbra-preview.check.ts +38 -0
- package/node_modules/pi-umbra-preview/extensions/umbra-preview.ts +147 -0
- package/node_modules/pi-umbra-preview/package.json +33 -0
- package/node_modules/pi-umbra-rename/LICENSE +21 -0
- package/node_modules/pi-umbra-rename/README.md +19 -0
- package/node_modules/pi-umbra-rename/checks/umbra-rename.check.ts +55 -0
- package/node_modules/pi-umbra-rename/extensions/umbra-rename.ts +22 -0
- package/node_modules/pi-umbra-rename/package.json +33 -0
- package/node_modules/pi-umbra-shimmer/LICENSE +21 -0
- package/node_modules/pi-umbra-shimmer/README.md +19 -0
- package/node_modules/pi-umbra-shimmer/checks/umbra-shimmer.check.ts +89 -0
- package/node_modules/pi-umbra-shimmer/extensions/umbra-shimmer.ts +183 -0
- package/node_modules/pi-umbra-shimmer/package.json +33 -0
- package/node_modules/pi-umbra-skill-matcher/LICENSE +21 -0
- package/node_modules/pi-umbra-skill-matcher/README.md +19 -0
- package/node_modules/pi-umbra-skill-matcher/checks/umbra-skill-matcher.check.ts +94 -0
- package/node_modules/pi-umbra-skill-matcher/extensions/umbra-skill-matcher.ts +124 -0
- package/node_modules/pi-umbra-skill-matcher/package.json +35 -0
- package/node_modules/pi-umbra-theme/LICENSE +21 -0
- package/node_modules/pi-umbra-theme/README.md +38 -0
- package/node_modules/pi-umbra-theme/checks/umbra-background.check.ts +158 -0
- package/node_modules/pi-umbra-theme/checks/umbra-footer.check.ts +72 -0
- package/node_modules/pi-umbra-theme/checks/umbra-gutter.check.ts +59 -0
- package/node_modules/pi-umbra-theme/checks/umbra-toolbox.check.ts +71 -0
- package/node_modules/pi-umbra-theme/checks/umbra-working.check.ts +120 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-background.ts +155 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-footer.ts +150 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-gutter.ts +35 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-toolbox.ts +129 -0
- package/node_modules/pi-umbra-theme/extensions/umbra-working.ts +209 -0
- package/node_modules/pi-umbra-theme/lib/umbra-format.ts +16 -0
- package/node_modules/pi-umbra-theme/lib/umbra-settings.ts +52 -0
- package/node_modules/pi-umbra-theme/package.json +45 -0
- package/node_modules/pi-umbra-theme/themes/umbra-astral-veil.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-deep-current.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-ember-ash.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-onyx-slate.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-tidal-drift.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-venom-dusk.json +88 -0
- package/node_modules/pi-umbra-theme/themes/umbra-violet-forge.json +88 -0
- 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.
|