@a-t-h-i/bot-lobby 0.6.3 → 0.6.5

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/README.md +180 -17
  2. package/package.json +12 -6
  3. package/prompts/backend.md +3 -1
  4. package/prompts/designer.md +24 -1
  5. package/prompts/master.md +98 -19
  6. package/prompts/quickfix.md +16 -3
  7. package/prompts/researcher.md +8 -2
  8. package/prompts/reviewer.md +42 -0
  9. package/prompts/worker.md +7 -0
  10. package/src/agents/backend.ts +2 -2
  11. package/src/agents/designer.ts +2 -2
  12. package/src/ask/dialog.ts +167 -0
  13. package/src/ask/image.ts +202 -0
  14. package/src/ask/png.ts +179 -0
  15. package/src/ask/relay.ts +89 -0
  16. package/src/ask/state.ts +160 -0
  17. package/src/ask/tool.ts +126 -0
  18. package/src/ask/types.ts +55 -0
  19. package/src/ask/view.ts +159 -0
  20. package/src/classifier/triage.ts +24 -7
  21. package/src/execution/agent-runner.ts +103 -11
  22. package/src/execution/git.ts +111 -14
  23. package/src/execution/pi-runner.ts +164 -11
  24. package/src/index.ts +9 -0
  25. package/src/lobby/ask.ts +10 -120
  26. package/src/lobby/feed.ts +6 -1
  27. package/src/lobby/layout.ts +27 -8
  28. package/src/lobby/markdown.ts +36 -6
  29. package/src/lobby/planner.ts +1 -1
  30. package/src/lobby/quickfix.ts +50 -3
  31. package/src/lobby/runtime.ts +41 -40
  32. package/src/lobby/tabs/home.ts +112 -48
  33. package/src/lobby/tabs/issues.ts +4 -3
  34. package/src/lobby/tabs/plan.ts +4 -4
  35. package/src/lobby/tabs/quickfix.ts +8 -2
  36. package/src/lobby/tabs/tasks.ts +16 -5
  37. package/src/lobby/theme.ts +30 -0
  38. package/src/lobby/view.ts +36 -5
  39. package/src/master/decisions.ts +10 -2
  40. package/src/master/master.ts +41 -4
  41. package/src/master/research.ts +5 -2
  42. package/src/pi/commands.ts +108 -13
  43. package/src/pi/events.ts +48 -10
  44. package/src/pi/quiet.ts +22 -4
  45. package/src/pi/route.ts +180 -0
  46. package/src/pi/start-task.ts +56 -4
  47. package/src/pi/tools.ts +32 -9
  48. package/src/pi/ui.ts +6 -1
  49. package/src/pi/zen-metrics.ts +13 -3
  50. package/src/pi/zen.ts +16 -9
  51. package/src/roles/reviewer.ts +23 -4
  52. package/src/roles/worker.ts +28 -2
  53. package/src/schemas/configuration.ts +15 -0
  54. package/src/schemas/findings.ts +14 -0
  55. package/src/schemas/task.ts +52 -0
  56. package/src/state/budget.ts +274 -0
  57. package/src/state/changes.ts +231 -0
  58. package/src/text.ts +28 -2
  59. package/src/web/extract.ts +332 -0
  60. package/src/web/fetch.ts +232 -0
  61. package/src/web/html.ts +183 -0
  62. package/src/web/read.ts +113 -0
  63. package/src/web/search.ts +202 -0
  64. package/src/web/tools.ts +279 -0
  65. package/src/workflow/track.ts +436 -0
  66. package/src/workflow/workflow.ts +734 -46
@@ -0,0 +1,89 @@
1
+ /**
2
+ * Asking the user from a subagent. A subagent runs as `pi --mode rpc` with no
3
+ * terminal of its own, so its `ask_user_question` goes out as an RPC editor
4
+ * dialog carrying the questions (marked with `RELAY_TITLE`); the runner in the
5
+ * master process answers it by putting the questionnaire to the user and
6
+ * sends the answers back the same way. Only runs the master lets ask (the
7
+ * designer's) get the tool: it sets `ASK_ENV` for them.
8
+ */
9
+ import { tmpdir } from "node:os";
10
+ import { isAbsolute, join, resolve } from "node:path";
11
+ import type { ExtensionContext } from "@earendil-works/pi-coding-agent";
12
+ import type { Asker } from "./dialog.ts";
13
+ import { MAX_OPTIONS, MAX_QUESTIONS, type AskQuestion, type AskResult } from "./types.ts";
14
+
15
+ /** Env flag the master sets for a subagent that may ask the user. */
16
+ export const ASK_ENV = "BOT_LOBBY_ASK";
17
+ /** Title of the RPC dialog that carries a relayed questionnaire. */
18
+ export const RELAY_TITLE = "bot-lobby:ask_user_question";
19
+
20
+ export function relayEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
21
+ return env[ASK_ENV] === "1";
22
+ }
23
+
24
+ /** Where an agent saves the images it shows the user for a task: outside the repository, so they are never part of its change. */
25
+ export function previewDir(taskId: string): string {
26
+ return join(tmpdir(), "bot-lobby-previews", taskId.replace(/[^\w.-]/g, "_"));
27
+ }
28
+
29
+ /** Image paths made absolute, so the master finds them whatever its cwd. */
30
+ function withAbsoluteImages(questions: readonly AskQuestion[], cwd: string): AskQuestion[] {
31
+ return questions.map((question) => ({
32
+ ...question,
33
+ options: question.options.map((option) => (option.image && !isAbsolute(option.image) ? { ...option, image: resolve(cwd, option.image) } : option)),
34
+ }));
35
+ }
36
+
37
+ /** The answers the master sent back, or undefined when they do not read as answers. */
38
+ export function readRelayAnswer(value: string | undefined): AskResult | undefined {
39
+ if (value === undefined) return undefined;
40
+ try {
41
+ const parsed = JSON.parse(value) as Partial<AskResult>;
42
+ if (!Array.isArray(parsed.answers) || typeof parsed.cancelled !== "boolean") return undefined;
43
+ return { answers: parsed.answers, cancelled: parsed.cancelled, ...(typeof parsed.globalNote === "string" ? { globalNote: parsed.globalNote } : {}) };
44
+ } catch {
45
+ return undefined;
46
+ }
47
+ }
48
+
49
+ const text = (value: unknown): value is string => typeof value === "string";
50
+
51
+ /** One relayed question as the questionnaire can show it, or undefined when it is not one. */
52
+ function asQuestion(value: unknown): AskQuestion | undefined {
53
+ const raw = value as Partial<AskQuestion> | null;
54
+ if (!raw || !text(raw.question) || !text(raw.header) || !Array.isArray(raw.options)) return undefined;
55
+ const options = raw.options
56
+ .filter((option) => option && text(option.label))
57
+ .slice(0, MAX_OPTIONS)
58
+ .map((option) => ({
59
+ label: option.label,
60
+ ...(text(option.description) ? { description: option.description } : {}),
61
+ ...(text(option.preview) ? { preview: option.preview } : {}),
62
+ ...(text(option.image) ? { image: option.image } : {}),
63
+ }));
64
+ if (options.length === 0) return undefined;
65
+ return { question: raw.question, header: raw.header, options, ...(raw.multiSelect === true ? { multiSelect: true } : {}) };
66
+ }
67
+
68
+ /**
69
+ * The questions a relayed dialog carries, or undefined when it is not one.
70
+ * The master takes only what the questionnaire can show from them: the
71
+ * subagent checked them, but its process is not trusted to have.
72
+ */
73
+ export function readRelayRequest(title: string | undefined, prefill: string | undefined): AskQuestion[] | undefined {
74
+ if (title !== RELAY_TITLE || !prefill) return undefined;
75
+ try {
76
+ const parsed = JSON.parse(prefill) as { questions?: unknown };
77
+ if (!Array.isArray(parsed.questions)) return undefined;
78
+ const questions = parsed.questions.slice(0, MAX_QUESTIONS).map(asQuestion);
79
+ return questions.length > 0 && questions.every(Boolean) ? (questions as AskQuestion[]) : undefined;
80
+ } catch {
81
+ return undefined;
82
+ }
83
+ }
84
+
85
+ /** Inside a subagent: the questions go to the master as an RPC dialog, and its answers come back. */
86
+ export const relayAsker: Asker = async (questions, ctx: ExtensionContext) => {
87
+ const value = await ctx.ui.editor(RELAY_TITLE, JSON.stringify({ questions: withAbsoluteImages(questions, ctx.cwd) }));
88
+ return readRelayAnswer(value) ?? { answers: [], cancelled: true };
89
+ };
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The questionnaire as state and keys, with no terminal in sight: each key
3
+ * press is one pure step, so every path (pick, toggle, type your own answer,
4
+ * move between questions, submit, cancel) is tested directly.
5
+ *
6
+ * Every question lists its options, then a row for the user's own answer.
7
+ * Enter on an option answers a single-choice question and moves on; space
8
+ * toggles options of a multi-choice one and enter moves on. Answering the
9
+ * last question submits; ←/→ move between questions first. Esc stops typing,
10
+ * or puts the questions away.
11
+ */
12
+ import type { AskAnswer, AskQuestion, AskResult } from "./types.ts";
13
+
14
+ export type AskKey =
15
+ | { type: "up" }
16
+ | { type: "down" }
17
+ | { type: "left" }
18
+ | { type: "right" }
19
+ | { type: "enter" }
20
+ | { type: "space" }
21
+ | { type: "escape" }
22
+ | { type: "backspace" }
23
+ /** 1-9: pick (or toggle) that option. */
24
+ | { type: "digit"; value: number }
25
+ /** Typed or pasted text, only used while writing an answer. */
26
+ | { type: "text"; value: string };
27
+
28
+ export interface AskState {
29
+ questions: readonly AskQuestion[];
30
+ /** The question in view. */
31
+ tab: number;
32
+ /** The focused row of each question: an option index, or `options.length` for the own-answer row. */
33
+ cursor: number[];
34
+ /** Options picked in each question (one for a single-choice question). */
35
+ picked: number[][];
36
+ /** The user's own answer to each question, when they wrote one. */
37
+ typed: Array<string | undefined>;
38
+ /** Writing the own answer of the question in view. */
39
+ editing: boolean;
40
+ draft: string;
41
+ /** Set once the user submits or puts the questions away. */
42
+ result?: AskResult;
43
+ }
44
+
45
+ export function initialState(questions: readonly AskQuestion[]): AskState {
46
+ return {
47
+ questions,
48
+ tab: 0,
49
+ cursor: questions.map(() => 0),
50
+ picked: questions.map(() => []),
51
+ typed: questions.map(() => undefined),
52
+ editing: false,
53
+ draft: "",
54
+ };
55
+ }
56
+
57
+ /** Whether a question has an answer yet. */
58
+ export function isAnswered(state: AskState, index: number): boolean {
59
+ return (state.picked[index]?.length ?? 0) > 0 || Boolean(state.typed[index]?.trim());
60
+ }
61
+
62
+ /** The answers given, in the order asked; unanswered questions are left out. */
63
+ export function answersOf(state: AskState): AskAnswer[] {
64
+ const answers: AskAnswer[] = [];
65
+ state.questions.forEach((question, index) => {
66
+ const picked = (state.picked[index] ?? []).map((option) => question.options[option]!.label);
67
+ const own = state.typed[index]?.trim();
68
+ if (question.multiSelect) {
69
+ const selected = own ? [...picked, own] : picked;
70
+ if (selected.length > 0) answers.push({ questionIndex: index, question: question.question, kind: "multi", answer: selected.join(", "), selected });
71
+ } else if (own) answers.push({ questionIndex: index, question: question.question, kind: "custom", answer: own });
72
+ else if (picked[0]) answers.push({ questionIndex: index, question: question.question, kind: "option", answer: picked[0] });
73
+ });
74
+ return answers;
75
+ }
76
+
77
+ function set<T>(list: readonly T[], index: number, value: T): T[] {
78
+ const next = [...list];
79
+ next[index] = value;
80
+ return next;
81
+ }
82
+
83
+ /** The next question, or the submitted result after the last one. */
84
+ function advance(state: AskState): AskState {
85
+ if (state.tab < state.questions.length - 1) return { ...state, tab: state.tab + 1, editing: false, draft: "" };
86
+ return { ...state, editing: false, draft: "", result: { answers: answersOf(state), cancelled: false } };
87
+ }
88
+
89
+ function choose(state: AskState, option: number): AskState {
90
+ const question = state.questions[state.tab]!;
91
+ if (option < 0 || option >= question.options.length) return state;
92
+ if (question.multiSelect) {
93
+ const picked = state.picked[state.tab] ?? [];
94
+ const next = picked.includes(option) ? picked.filter((entry) => entry !== option) : [...picked, option].sort((a, b) => a - b);
95
+ return { ...state, cursor: set(state.cursor, state.tab, option), picked: set(state.picked, state.tab, next) };
96
+ }
97
+ // One answer to a single-choice question: picking an option replaces any words of the user's own.
98
+ return advance({ ...state, cursor: set(state.cursor, state.tab, option), picked: set(state.picked, state.tab, [option]), typed: set(state.typed, state.tab, undefined) });
99
+ }
100
+
101
+ function typing(state: AskState, key: AskKey): AskState {
102
+ switch (key.type) {
103
+ case "text":
104
+ return { ...state, draft: state.draft + key.value.replace(/[\r\n\t]+/g, " ") };
105
+ case "space":
106
+ return { ...state, draft: `${state.draft} ` };
107
+ case "digit":
108
+ return { ...state, draft: state.draft + String(key.value) };
109
+ case "backspace":
110
+ return { ...state, draft: [...state.draft].slice(0, -1).join("") };
111
+ case "escape":
112
+ return { ...state, editing: false, draft: "" };
113
+ case "enter": {
114
+ const own = state.draft.trim();
115
+ const question = state.questions[state.tab]!;
116
+ const typed = set(state.typed, state.tab, own || undefined);
117
+ // Your own words answer a single-choice question instead of a picked option.
118
+ const picked = own && !question.multiSelect ? set(state.picked, state.tab, []) : state.picked;
119
+ const next = { ...state, typed, picked, editing: false, draft: "" };
120
+ return own ? advance(next) : next;
121
+ }
122
+ default:
123
+ return state;
124
+ }
125
+ }
126
+
127
+ /** One key press. */
128
+ export function step(state: AskState, key: AskKey): AskState {
129
+ if (state.result) return state;
130
+ if (state.editing) return typing(state, key);
131
+ const question = state.questions[state.tab];
132
+ if (!question) return { ...state, result: { answers: [], cancelled: false } };
133
+ const rows = question.options.length + 1;
134
+ const cursor = state.cursor[state.tab] ?? 0;
135
+ switch (key.type) {
136
+ case "up":
137
+ return { ...state, cursor: set(state.cursor, state.tab, (cursor - 1 + rows) % rows) };
138
+ case "down":
139
+ return { ...state, cursor: set(state.cursor, state.tab, (cursor + 1) % rows) };
140
+ case "left":
141
+ return state.tab > 0 ? { ...state, tab: state.tab - 1 } : state;
142
+ case "right":
143
+ return state.tab < state.questions.length - 1 ? { ...state, tab: state.tab + 1 } : state;
144
+ case "digit":
145
+ return choose(state, key.value - 1);
146
+ case "space":
147
+ return cursor < question.options.length && question.multiSelect ? choose(state, cursor) : state;
148
+ case "enter": {
149
+ if (cursor === question.options.length) return { ...state, editing: true, draft: state.typed[state.tab] ?? "" };
150
+ if (!question.multiSelect) return choose(state, cursor);
151
+ // A multi-choice question moves on with what is picked; with nothing picked, enter picks the focused option.
152
+ if (!isAnswered(state, state.tab)) return advance(choose(state, cursor));
153
+ return advance(state);
154
+ }
155
+ case "escape":
156
+ return { ...state, result: { answers: answersOf(state), cancelled: true } };
157
+ default:
158
+ return state;
159
+ }
160
+ }
@@ -0,0 +1,126 @@
1
+ /**
2
+ * `ask_user_question`: the model asks instead of guessing, with typed options
3
+ * the user picks from (or answers in their own words). Registered by
4
+ * bot-lobby in every pi session it loads in, the lobby's or not, so pi needs
5
+ * no separate questionnaire extension. A subagent has no terminal: it gets
6
+ * the tool only when the master lets it ask (the designer), and its questions
7
+ * are relayed through the master (see relay.ts).
8
+ */
9
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
10
+ import { Container, Text } from "@earendil-works/pi-tui";
11
+ import { Type } from "typebox";
12
+ import { isSubagentProcess } from "../pi/quiet.ts";
13
+ import { askUser, type Asker } from "./dialog.ts";
14
+ import { isImagePath } from "./image.ts";
15
+ import { relayAsker, relayEnabled } from "./relay.ts";
16
+ import { ASK_TOOL, MAX_HEADER, MAX_LABEL, MAX_OPTIONS, MAX_QUESTIONS, MIN_OPTIONS, RESERVED, type AskQuestion, type AskResult } from "./types.ts";
17
+
18
+ export { ASK_TOOL };
19
+
20
+ const OptionSchema = Type.Object({
21
+ label: Type.String({ maxLength: MAX_LABEL, description: `The option as the user sees and picks it: 1-5 words, at most ${MAX_LABEL} characters.` }),
22
+ description: Type.Optional(Type.String({ description: "What choosing it means: its trade-offs or consequences. Markdown." })),
23
+ preview: Type.Optional(Type.String({ description: "Markdown shown beside the options while this one is focused: a mockup, a code snippet, a diagram, a config. Only when seeing it helps the user compare." })),
24
+ image: Type.Optional(Type.String({ description: "Path to a PNG (or JPEG, GIF, WebP) shown with the preview: a screenshot or a rendered mockup of this option. Drawn as the image in terminals that can, as coloured blocks (PNG) elsewhere." })),
25
+ });
26
+
27
+ const QuestionSchema = Type.Object({
28
+ question: Type.String({ description: "The whole question, clear and specific, ending with a question mark. Markdown." }),
29
+ header: Type.String({ maxLength: MAX_HEADER, description: `A short chip naming the question (at most ${MAX_HEADER} characters), e.g. "Auth" or "Layout".` }),
30
+ options: Type.Array(OptionSchema, { minItems: MIN_OPTIONS, maxItems: MAX_OPTIONS, description: `${MIN_OPTIONS}-${MAX_OPTIONS} distinct options. Put the one you recommend first and end its label with "(Recommended)". The user can always answer in their own words instead.` }),
31
+ multiSelect: Type.Optional(Type.Boolean({ description: "True when several answers can apply together." })),
32
+ });
33
+
34
+ export const AskParams = Type.Object({
35
+ questions: Type.Array(QuestionSchema, { minItems: 1, maxItems: MAX_QUESTIONS, description: `1-${MAX_QUESTIONS} questions asked together.` }),
36
+ });
37
+
38
+ const DESCRIPTION = [
39
+ "Ask the user one to four questions with options to pick from, when the answer would change what you do and you would otherwise guess.",
40
+ "Each question has 2-4 options (the one you recommend first, its label ending in \"(Recommended)\"); the user can pick one (or several with multiSelect), or answer in their own words.",
41
+ "Questions, descriptions and previews are Markdown. Give options a `preview` when the user needs to see them to choose: a UI mockup, a layout sketch, a code snippet, a config; the focused option's preview shows beside the list. An option can also carry an `image` file (a screenshot, a rendered mockup).",
42
+ "Do not use it for yes/no confirmations of what you were already told to do, or for questions the conversation already answers.",
43
+ ].join(" ");
44
+
45
+ const RELAYED = "Your questions reach the user through the oracle, and your clock stops while they answer. Ask only what is theirs to decide (a visual direction, a layout, a trade-off they care about), all at once; decide the rest yourself.";
46
+
47
+ /** Why a set of questions cannot be asked as given, or undefined when it can. */
48
+ export function invalidQuestions(questions: readonly AskQuestion[]): string | undefined {
49
+ const asked = new Set<string>();
50
+ for (const [index, question] of questions.entries()) {
51
+ const at = `question ${index + 1}`;
52
+ if (!question.question.trim()) return `${at} is empty`;
53
+ if (asked.has(question.question.trim().toLowerCase())) return `${at} repeats an earlier question`;
54
+ asked.add(question.question.trim().toLowerCase());
55
+ const labels = new Set<string>();
56
+ for (const option of question.options) {
57
+ const label = option.label.trim().toLowerCase();
58
+ if (!label) return `${at} has an option without a label`;
59
+ if (RESERVED.has(label)) return `${at}: "${option.label}" is kept for the user's own answer; leave it out`;
60
+ if (labels.has(label)) return `${at} has two options labelled "${option.label}"`;
61
+ if (option.image?.trim() && !isImagePath(option.image)) return `${at}: the image for "${option.label}" must be a .png, .jpg, .gif or .webp file`;
62
+ labels.add(label);
63
+ }
64
+ }
65
+ return undefined;
66
+ }
67
+
68
+ /** What the model reads back: each question with its answer, or that it was skipped. */
69
+ export function answerSummary(questions: readonly AskQuestion[], result: AskResult): string {
70
+ if (result.cancelled && result.answers.length === 0) {
71
+ // A relay that could not ask anyone (auto mode) says why.
72
+ if (result.globalNote) return result.globalNote;
73
+ return "The user put the questions away without answering. Do not ask the same again right away: go on with your best judgement and say what you assumed, or ask something narrower.";
74
+ }
75
+ const lines = questions.map((question, index) => {
76
+ const answer = result.answers.find((entry) => entry.questionIndex === index);
77
+ const head = `${index + 1}. [${question.header}] ${question.question.replace(/\s+/g, " ").trim()}`;
78
+ if (!answer) return `${head}\n → (not answered)`;
79
+ const text = answer.kind === "multi" ? (answer.selected ?? []).join(", ") : answer.answer ?? "";
80
+ return `${head}\n → ${text}${answer.kind === "custom" ? " (in the user's own words)" : ""}${answer.notes ? `\n note: ${answer.notes}` : ""}`;
81
+ });
82
+ return [
83
+ result.cancelled ? "The user answered some questions, then put the rest away:" : "The user answered:",
84
+ ...lines,
85
+ ...(result.globalNote ? ["", `Note: ${result.globalNote}`] : []),
86
+ ].join("\n");
87
+ }
88
+
89
+ /**
90
+ * Register `ask_user_question` in this pi session; in a subagent only when
91
+ * the master relays its questions. `ask` is swappable for tests.
92
+ */
93
+ export function registerAskTool(pi: ExtensionAPI, ask?: Asker): void {
94
+ const subagent = isSubagentProcess();
95
+ if (subagent && !relayEnabled()) return;
96
+ const asker = ask ?? (subagent ? relayAsker : askUser);
97
+ pi.registerTool({
98
+ name: ASK_TOOL,
99
+ label: "Ask",
100
+ description: subagent ? `${DESCRIPTION} ${RELAYED}` : DESCRIPTION,
101
+ promptSnippet: "Ask the user structured questions with options (and previews) instead of guessing",
102
+ promptGuidelines: [
103
+ "Use ask_user_question when a real decision is the user's and the answer changes your work; batch related questions (at most four) into one call.",
104
+ ],
105
+ parameters: AskParams,
106
+ async execute(_toolCallId, params, signal, _onUpdate, ctx) {
107
+ const questions = (params as { questions: AskQuestion[] }).questions;
108
+ const invalid = invalidQuestions(questions);
109
+ if (invalid) throw new Error(`${invalid}.`);
110
+ const result = await asker(questions, ctx, signal);
111
+ return { content: [{ type: "text", text: answerSummary(questions, result) }], details: result };
112
+ },
113
+ renderCall(args, theme) {
114
+ const questions = (args as { questions?: AskQuestion[] }).questions ?? [];
115
+ const heads = questions.map((question) => question.header).filter(Boolean).join(", ");
116
+ return new Text(`${theme.fg("toolTitle", theme.bold("ask"))} ${theme.fg("accent", `${questions.length} question${questions.length === 1 ? "" : "s"}`)}${heads ? ` ${theme.fg("muted", heads)}` : ""}`, 0, 0);
117
+ },
118
+ renderResult(result, _options, theme) {
119
+ const details = result.details as AskResult | undefined;
120
+ if (!details) return new Container();
121
+ if (details.cancelled && details.answers.length === 0) return new Text(theme.fg("warning", "put away without answering"), 0, 0);
122
+ const lines = details.answers.map((answer) => `${theme.fg("success", "✓")} ${theme.fg("muted", answer.question.replace(/\s+/g, " ").slice(0, 60))} ${theme.fg("dim", "→")} ${theme.fg("accent", answer.kind === "multi" ? (answer.selected ?? []).join(", ") : answer.answer ?? "")}`);
123
+ return new Text(lines.join("\n"), 0, 0);
124
+ },
125
+ });
126
+ }
@@ -0,0 +1,55 @@
1
+ /**
2
+ * The questionnaire's shape: what a model (or the planning panel) asks, and
3
+ * what comes back. The same shape `ask_user_question` has had in pi, so a
4
+ * model's habits carry over: 1-4 questions, 2-4 options each, a short header,
5
+ * multi-select, and a per-option preview (Markdown: a mockup, a snippet, a
6
+ * diagram) shown beside the options.
7
+ */
8
+
9
+ /** The questionnaire tool's name, in this session and in the agents that may ask. */
10
+ export const ASK_TOOL = "ask_user_question";
11
+
12
+ export const MAX_QUESTIONS = 4;
13
+ export const MIN_OPTIONS = 2;
14
+ export const MAX_OPTIONS = 4;
15
+ export const MAX_HEADER = 16;
16
+ export const MAX_LABEL = 60;
17
+
18
+ /** The row under every question's options where the user writes their own answer. */
19
+ export const OWN_ANSWER = "Type something.";
20
+
21
+ /** Labels the questionnaire keeps for its own rows. */
22
+ export const RESERVED = new Set(["other", OWN_ANSWER.toLowerCase(), "next"]);
23
+
24
+ export interface AskOption {
25
+ label: string;
26
+ description?: string;
27
+ /** Markdown shown beside the options while this one is focused. */
28
+ preview?: string;
29
+ /** An image file (PNG, JPEG, GIF or WebP) shown with the preview: a screenshot, a rendered mockup. */
30
+ image?: string;
31
+ }
32
+
33
+ export interface AskQuestion {
34
+ question: string;
35
+ /** A short chip naming the question (a topic, or who asks it). */
36
+ header: string;
37
+ options: AskOption[];
38
+ multiSelect?: boolean;
39
+ }
40
+
41
+ export interface AskAnswer {
42
+ questionIndex: number;
43
+ question: string;
44
+ /** A picked option, the user's own words, or several picked options. */
45
+ kind: "option" | "custom" | "multi";
46
+ answer: string | null;
47
+ selected?: string[];
48
+ notes?: string;
49
+ }
50
+
51
+ export interface AskResult {
52
+ answers: AskAnswer[];
53
+ cancelled: boolean;
54
+ globalNote?: string;
55
+ }
@@ -0,0 +1,159 @@
1
+ /**
2
+ * The questionnaire drawn: a chip per question, the question in Markdown, its
3
+ * options (each with its description in Markdown), the row for your own
4
+ * answer, and — when options carry one — the focused option's preview in a
5
+ * box beside them (or under them in a narrow terminal). Pure: state in, lines
6
+ * out, exactly `width` columns and at most `height` rows.
7
+ */
8
+ import { basename } from "node:path";
9
+ import { textWidth } from "../width.ts";
10
+ import { beside, bold, box, fill, fit, markdownLines, paint, wrap, type LobbyTheme } from "../lobby/layout.ts";
11
+ import { imageLines, type ImagePreview } from "./image.ts";
12
+ import { isAnswered, type AskState } from "./state.ts";
13
+ import { OWN_ANSWER, type AskQuestion } from "./types.ts";
14
+
15
+ /** Terminals at least this wide show the preview beside the options. */
16
+ export const SIDE_BY_SIDE_MIN = 96;
17
+ /** Rows the preview box keeps at least, and at most. */
18
+ const PREVIEW_MIN = 6;
19
+ const PREVIEW_MAX = 40;
20
+ /** A preview box needs this many inner rows to draw an image in; smaller ones name the file. */
21
+ const IMAGE_MIN_ROWS = 6;
22
+ /** Option descriptions sit under their label, past the marker and number. */
23
+ const DESCRIPTION_INDENT = 7;
24
+
25
+ const RECOMMENDED = /\s*\(recommended\)\s*$/i;
26
+
27
+ function chips(state: AskState, theme?: LobbyTheme): string {
28
+ if (state.questions.length === 1) return paint(theme, "muted", state.questions[0]!.header);
29
+ return state.questions
30
+ .map((question, index) => {
31
+ const label = ` ${index + 1} ${question.header}${isAnswered(state, index) ? " ✓" : ""} `;
32
+ if (index === state.tab) return bold(theme, paint(theme, "accent", `[${label.trim()}]`));
33
+ return paint(theme, isAnswered(state, index) ? "success" : "muted", label.trim());
34
+ })
35
+ .join(paint(theme, "dim", " · "));
36
+ }
37
+
38
+ function optionLabel(label: string, focused: boolean, theme?: LobbyTheme): string {
39
+ const recommended = RECOMMENDED.test(label);
40
+ const plain = label.replace(RECOMMENDED, "");
41
+ const text = focused ? bold(theme, plain) : plain;
42
+ return recommended ? `${text} ${paint(theme, "success", "(Recommended)")}` : text;
43
+ }
44
+
45
+ /** The options and the own-answer row, each option with its description beneath. */
46
+ function optionLines(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
47
+ const cursor = state.cursor[state.tab] ?? 0;
48
+ const picked = state.picked[state.tab] ?? [];
49
+ const lines: string[] = [];
50
+ question.options.forEach((option, index) => {
51
+ const focused = cursor === index && !state.editing;
52
+ const pointer = focused ? paint(theme, "accent", "›") : " ";
53
+ const mark = question.multiSelect ? (picked.includes(index) ? "☑" : "☐") : picked.includes(index) ? "●" : "○";
54
+ const lead = `${pointer} ${paint(theme, picked.includes(index) ? "accent" : "dim", mark)} ${paint(theme, "dim", `${index + 1}.`)} `;
55
+ const labelLines = wrap(optionLabel(option.label, focused, theme), Math.max(1, width - textWidth(lead)));
56
+ lines.push(`${lead}${labelLines[0] ?? ""}`, ...labelLines.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${line}`));
57
+ if (option.description?.trim()) {
58
+ const indent = " ".repeat(DESCRIPTION_INDENT);
59
+ for (const line of markdownLines(option.description.trim(), Math.max(1, width - DESCRIPTION_INDENT), theme)) lines.push(line ? `${indent}${paint(theme, "muted", line)}` : "");
60
+ }
61
+ });
62
+ const ownFocused = cursor === question.options.length;
63
+ const pointer = ownFocused || state.editing ? paint(theme, "accent", "›") : " ";
64
+ const own = state.typed[state.tab];
65
+ const lead = `${pointer} ${paint(theme, own ? "accent" : "dim", "✎")} `;
66
+ const room = Math.max(1, width - textWidth(lead));
67
+ if (state.editing) {
68
+ const draft = wrap(`${state.draft}▏`, room);
69
+ lines.push(`${lead}${paint(theme, "accent", draft[0] ?? "")}`, ...draft.slice(1).map((line) => `${" ".repeat(textWidth(lead))}${paint(theme, "accent", line)}`));
70
+ } else {
71
+ lines.push(`${lead}${own ? paint(theme, "accent", `“${own}”`) : paint(theme, ownFocused ? "text" : "dim", OWN_ANSWER)}`);
72
+ }
73
+ return lines;
74
+ }
75
+
76
+ interface Preview {
77
+ label: string;
78
+ text: string;
79
+ image?: string;
80
+ }
81
+
82
+ /** The preview to show: the focused option's, when any option in the question has one (Markdown or an image). */
83
+ function previewOf(state: AskState, question: AskQuestion): Preview | undefined {
84
+ if (!question.options.some((option) => option.preview?.trim() || option.image?.trim())) return undefined;
85
+ const cursor = state.cursor[state.tab] ?? 0;
86
+ const option = question.options[cursor];
87
+ if (!option) return { label: OWN_ANSWER, text: "" };
88
+ return { label: option.label.replace(RECOMMENDED, ""), text: option.preview?.trim() ?? "", ...(option.image?.trim() ? { image: option.image.trim() } : {}) };
89
+ }
90
+
91
+ /** The image part of a preview: drawn when the box has room, else named. It never takes the box's last rows from the text entirely. */
92
+ function imageBlock(preview: Preview, inner: number, innerRows: number, textRows: number, frame: AskFrame, theme?: LobbyTheme): string[] {
93
+ if (!preview.image) return [];
94
+ const loaded = frame.images?.get(preview.image);
95
+ const textRoom = textRows > 0 ? Math.min(textRows + 1, Math.max(2, Math.floor(innerRows / 3))) : 0;
96
+ const rows = innerRows - textRoom;
97
+ if (!loaded) return [paint(theme, "muted", `Image: ${preview.image}`)];
98
+ if (rows < IMAGE_MIN_ROWS) return [paint(theme, "muted", `Image: ${basename(preview.image)} (the terminal is too small to draw it)`)];
99
+ return imageLines(loaded, inner, rows, theme);
100
+ }
101
+
102
+ /** The preview in a box at most `rows` tall; an image keeps within `fits` rows, which the terminal surely shows. */
103
+ function previewBox(preview: Preview, width: number, rows: number, theme: LobbyTheme | undefined, frame: AskFrame, fits = rows): string[] {
104
+ const inner = Math.max(1, width - 4);
105
+ const text = preview.text ? markdownLines(preview.text, inner, theme) : [];
106
+ const image = imageBlock(preview, inner, Math.min(rows, fits) - 2, text.length, frame, theme);
107
+ const joined = [...image, ...(image.length > 0 && text.length > 0 ? [""] : []), ...text];
108
+ const body = joined.length > 0 ? joined : [paint(theme, "dim", "No preview for this one.")];
109
+ const height = Math.max(3, Math.min(rows, body.length + 2));
110
+ const more = body.length > height - 2 ? `${body.length - (height - 2)} more lines` : "";
111
+ return box(width, height, body, { title: `Preview · ${preview.label}`, ...(more ? { right: more } : {}), theme });
112
+ }
113
+
114
+ function hints(state: AskState, question: AskQuestion, width: number, theme?: LobbyTheme): string[] {
115
+ const parts = state.editing
116
+ ? ["enter keep it", "esc back to the options"]
117
+ : [
118
+ "↑↓ move",
119
+ question.multiSelect ? "space pick · enter next" : "enter choose",
120
+ `1-${question.options.length} pick`,
121
+ ...(state.questions.length > 1 ? ["←→ questions"] : []),
122
+ "esc put away",
123
+ ];
124
+ return wrap(paint(theme, "dim", parts.join(" · ")), width);
125
+ }
126
+
127
+ /** Around the questions: who is asking (an agent the oracle relays for), and the option images, read beforehand. */
128
+ export interface AskFrame {
129
+ from?: string;
130
+ images?: ReadonlyMap<string, ImagePreview>;
131
+ }
132
+
133
+ /** The whole questionnaire in a box, `width` wide and at most `height` rows. */
134
+ export function renderAsk(state: AskState, width: number, height: number, theme?: LobbyTheme, frame: AskFrame = {}): string[] {
135
+ const question = state.questions[state.tab];
136
+ if (!question || width < 20 || height < 6) return [];
137
+ const inner = width - 4;
138
+ const head = [chips(state, theme), "", ...markdownLines(question.question, inner, theme), ""];
139
+ const foot = ["", ...hints(state, question, inner, theme)];
140
+ const preview = previewOf(state, question);
141
+ let body: string[];
142
+ if (preview && inner >= SIDE_BY_SIDE_MIN) {
143
+ const left = Math.floor(inner * 0.42);
144
+ const options = optionLines(state, question, left, theme);
145
+ const rows = Math.max(PREVIEW_MIN, Math.min(PREVIEW_MAX, height - 2 - head.length - foot.length));
146
+ const side = previewBox(preview, inner - left - 2, Math.max(rows, Math.min(options.length, PREVIEW_MAX)), theme, frame, rows);
147
+ body = beside([fill(options, Math.max(options.length, side.length), left), side], " ");
148
+ } else {
149
+ const options = optionLines(state, question, inner, theme);
150
+ const room = height - 2 - head.length - foot.length - options.length - 1;
151
+ body = preview && room >= 3 ? [...options, "", ...previewBox(preview, inner, Math.min(PREVIEW_MAX, room), theme, frame)] : options;
152
+ }
153
+ const content = [...head, ...body, ...foot];
154
+ // Too tall for the terminal: the question and hints stay, the middle is cut.
155
+ const fitted = content.length > height - 2 ? [...content.slice(0, height - 2 - foot.length), ...foot] : content;
156
+ const count = state.questions.length > 1 ? `Question ${state.tab + 1} of ${state.questions.length}` : "Question";
157
+ const title = frame.from ? `${frame.from} asks · ${count}` : count;
158
+ return box(width, fitted.length + 2, fitted.map((line) => fit(line, inner)), { title, focused: true, theme });
159
+ }