@pi9/ask 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/src/context.ts ADDED
@@ -0,0 +1,209 @@
1
+ import type { ContextEvent } from "@earendil-works/pi-coding-agent";
2
+ import { Check } from "typebox/value";
3
+
4
+ import { AskAnsweredDetailsSchema } from "./schema.js";
5
+ import {
6
+ ASK_REPLAY_CUSTOM_TYPE,
7
+ parseAskReplayDetails,
8
+ validateStoredArgs,
9
+ } from "./replay.js";
10
+ import type { AskAnswer, AskReplayDetails, ValidatedAskParams } from "./types.js";
11
+
12
+ type AgentMessage = ContextEvent["messages"][number];
13
+ type AssistantMessage = Extract<AgentMessage, { role: "assistant" }>;
14
+ type ToolResultMessage = Extract<AgentMessage, { role: "toolResult" }>;
15
+ type CustomMessage = Extract<AgentMessage, { role: "custom" }>;
16
+ type AskCall = {
17
+ args: ValidatedAskParams;
18
+ message: AssistantMessage;
19
+ standalone: boolean;
20
+ };
21
+ type ReplayRecord = {
22
+ message: CustomMessage;
23
+ details?: AskReplayDetails;
24
+ };
25
+
26
+ const ASK_SUMMARY_CUSTOM_TYPE = "ask:summary" as const;
27
+
28
+ type AskSummaryPayload = {
29
+ type: "ask_response";
30
+ question: string;
31
+ context?: string;
32
+ selectionMode: "single" | "multi";
33
+ answer: AskAnswer;
34
+ };
35
+
36
+ /**
37
+ * Replace completed Ask exchanges with the small custom message that is useful
38
+ * to the model. The session still contains the original protocol messages; the
39
+ * projection must not leave a tool call without its result.
40
+ */
41
+ export function rewriteAskContext(messages: readonly AgentMessage[]): AgentMessage[] {
42
+ const calls = collectCalls(messages);
43
+ const nativeResults = collectNativeResults(messages);
44
+ const replayRecords = collectReplayRecords(messages);
45
+
46
+ const summaries = new Map<AgentMessage, AgentMessage>();
47
+ const removals = new Set<AgentMessage>();
48
+ for (const [toolCallId, call] of calls) {
49
+ if (!call || !call.standalone) continue;
50
+
51
+ const results = nativeResults.get(toolCallId) ?? [];
52
+ if (results.length > 1) continue;
53
+ const result = results[0];
54
+
55
+ const replays = replayRecords.get(toolCallId) ?? [];
56
+ let answer: AskAnswer | undefined;
57
+ let replay: ReplayRecord | undefined;
58
+ if (replays.length > 0) {
59
+ // A replay is a revision only when it is the one well-formed marker for
60
+ // this call. In particular, do not hide malformed or duplicate markers.
61
+ if (replays.length !== 1) continue;
62
+ const candidate = replays[0]!;
63
+ if (!candidate.details
64
+ || !matchesCall(candidate.details, call.args)
65
+ || !answerMatchesCall(candidate.details.answer, call.args)) continue;
66
+ replay = candidate;
67
+ answer = candidate.details.answer;
68
+ }
69
+
70
+ if (result) {
71
+ if (result.toolName !== "ask") continue;
72
+ // A valid replay is the latest answer and takes precedence regardless
73
+ // of whether the earlier native attempt completed successfully.
74
+ if (!replay) {
75
+ if (result.isError) continue;
76
+ const details = parseNativeDetails(result.details);
77
+ if (!details
78
+ || details.question !== call.args.question
79
+ || !answerMatchesCall(details.answer, call.args)) continue;
80
+ answer = details.answer;
81
+ }
82
+ } else if (!replay) {
83
+ continue;
84
+ }
85
+
86
+ if (!answer) continue;
87
+ const timestamp = replay ? replay.message.timestamp : result!.timestamp;
88
+ const summary = makeSummary(call, answer, timestamp);
89
+ summaries.set(call.message, summary);
90
+ removals.add(call.message);
91
+ if (result) removals.add(result);
92
+ if (replay) removals.add(replay.message);
93
+ }
94
+
95
+ const rewritten: AgentMessage[] = [];
96
+ for (const message of messages) {
97
+ const summary = summaries.get(message);
98
+ if (summary) {
99
+ rewritten.push(summary);
100
+ continue;
101
+ }
102
+ if (!removals.has(message)) rewritten.push(message);
103
+ }
104
+ return rewritten;
105
+ }
106
+
107
+ function collectCalls(messages: readonly AgentMessage[]): Map<string, AskCall | undefined> {
108
+ const calls = new Map<string, AskCall | undefined>();
109
+ for (const message of messages) {
110
+ if (message.role !== "assistant") continue;
111
+ const toolCalls = message.content.filter(block => block.type === "toolCall");
112
+ for (const block of toolCalls) {
113
+ if (block.name !== "ask") continue;
114
+ const args = validateStoredArgs(block.arguments);
115
+ const call = args
116
+ ? { args, message, standalone: toolCalls.length === 1 }
117
+ : undefined;
118
+ // Keep an invalid entry as ambiguous too. A later valid call with the
119
+ // same ID must not make an otherwise malformed exchange transformable.
120
+ calls.set(block.id, calls.has(block.id) ? undefined : call);
121
+ }
122
+ }
123
+ return calls;
124
+ }
125
+
126
+ function collectNativeResults(messages: readonly AgentMessage[]): Map<string, ToolResultMessage[]> {
127
+ const results = new Map<string, ToolResultMessage[]>();
128
+ for (const message of messages) {
129
+ if (message.role !== "toolResult") continue;
130
+ const matching = results.get(message.toolCallId) ?? [];
131
+ matching.push(message);
132
+ results.set(message.toolCallId, matching);
133
+ }
134
+ return results;
135
+ }
136
+
137
+ function collectReplayRecords(messages: readonly AgentMessage[]): Map<string, ReplayRecord[]> {
138
+ const records = new Map<string, ReplayRecord[]>();
139
+ for (const message of messages) {
140
+ if (message.role !== "custom" || message.customType !== ASK_REPLAY_CUSTOM_TYPE) continue;
141
+ const rawDetails = isRecord(message.details) && typeof message.details.toolCallId === "string"
142
+ ? message.details.toolCallId
143
+ : undefined;
144
+ const details = parseAskReplayDetails(message.details);
145
+ const toolCallId = details?.toolCallId ?? rawDetails;
146
+ if (toolCallId === undefined) continue;
147
+ const matching = records.get(toolCallId) ?? [];
148
+ matching.push({ message, ...(details ? { details } : {}) });
149
+ records.set(toolCallId, matching);
150
+ }
151
+ return records;
152
+ }
153
+
154
+ function parseNativeDetails(value: unknown): { question: string; answer: AskAnswer } | undefined {
155
+ return Check(AskAnsweredDetailsSchema, value) ? value : undefined;
156
+ }
157
+
158
+ function matchesCall(replay: AskReplayDetails, args: ValidatedAskParams): boolean {
159
+ return replay.question === args.question
160
+ && replay.context === args.context
161
+ && replay.allowMultiple === args.allowMultiple;
162
+ }
163
+
164
+ function answerMatchesCall(answer: AskAnswer, args: ValidatedAskParams): boolean {
165
+ if (answer.selections.length > 1 && !args.allowMultiple) return false;
166
+ if (answer.freeform !== undefined && !args.allowFreeform) return false;
167
+
168
+ const labels = new Set(args.options.map(option => option.label));
169
+ const selected = new Set<string>();
170
+ for (const selection of answer.selections) {
171
+ if (!labels.has(selection.label) || selected.has(selection.label)) return false;
172
+ selected.add(selection.label);
173
+ }
174
+ return true;
175
+ }
176
+
177
+ function makeSummary(call: AskCall, answer: AskAnswer, timestamp: number): AgentMessage {
178
+ const payload: AskSummaryPayload = {
179
+ type: "ask_response",
180
+ question: call.args.question,
181
+ ...(call.args.context !== undefined ? { context: call.args.context } : {}),
182
+ selectionMode: call.args.allowMultiple ? "multi" : "single",
183
+ answer: {
184
+ selections: answer.selections.map(selection => selectedOption(selection, call.args)),
185
+ ...(answer.freeform !== undefined ? { freeform: answer.freeform } : {}),
186
+ },
187
+ };
188
+ return {
189
+ role: "custom",
190
+ customType: ASK_SUMMARY_CUSTOM_TYPE,
191
+ display: false,
192
+ content: JSON.stringify(payload),
193
+ timestamp,
194
+ };
195
+ }
196
+
197
+ function selectedOption(selection: AskAnswer["selections"][number], args: ValidatedAskParams): AskAnswer["selections"][number] {
198
+ const original = args.options.find(option => option.label === selection.label);
199
+ const description = selection.description ?? original?.description;
200
+ return {
201
+ label: selection.label,
202
+ ...(description !== undefined ? { description } : {}),
203
+ ...(selection.comment !== undefined ? { comment: selection.comment } : {}),
204
+ };
205
+ }
206
+
207
+ function isRecord(value: unknown): value is Record<string, unknown> {
208
+ return typeof value === "object" && value !== null && !Array.isArray(value);
209
+ }
@@ -0,0 +1,78 @@
1
+ export interface DeadlineSignal {
2
+ signal: AbortSignal | undefined;
3
+ readonly timedOut: boolean;
4
+ dispose(): void;
5
+ }
6
+
7
+ export function createDeadlineSignal(
8
+ parent: AbortSignal | undefined,
9
+ timeoutMs: number | undefined,
10
+ ): DeadlineSignal {
11
+ const hasTimeout = timeoutMs !== undefined
12
+ && Number.isFinite(timeoutMs)
13
+ && timeoutMs > 0;
14
+
15
+ if (parent === undefined && !hasTimeout) {
16
+ return {
17
+ signal: undefined,
18
+ timedOut: false,
19
+ dispose() {},
20
+ };
21
+ }
22
+
23
+ const controller = new AbortController();
24
+ let disposed = false;
25
+ let timedOut = false;
26
+ let timer: ReturnType<typeof setTimeout> | undefined;
27
+ let parentListener: (() => void) | undefined;
28
+
29
+ const cleanup = (): void => {
30
+ if (timer !== undefined) {
31
+ clearTimeout(timer);
32
+ timer = undefined;
33
+ }
34
+ if (parent !== undefined && parentListener !== undefined) {
35
+ parent.removeEventListener("abort", parentListener);
36
+ parentListener = undefined;
37
+ }
38
+ };
39
+
40
+ const abort = (reason?: unknown): void => {
41
+ if (disposed || controller.signal.aborted) return;
42
+ cleanup();
43
+ controller.abort(reason);
44
+ };
45
+
46
+ if (parent?.aborted) {
47
+ abort(parent.reason);
48
+ return {
49
+ signal: controller.signal,
50
+ timedOut: false,
51
+ dispose() {},
52
+ };
53
+ }
54
+
55
+ if (parent !== undefined) {
56
+ parentListener = () => abort(parent.reason);
57
+ parent.addEventListener("abort", parentListener, { once: true });
58
+ }
59
+
60
+ if (hasTimeout) {
61
+ timer = setTimeout(() => {
62
+ timedOut = true;
63
+ abort();
64
+ }, timeoutMs);
65
+ }
66
+
67
+ return {
68
+ signal: controller.signal,
69
+ get timedOut() {
70
+ return timedOut;
71
+ },
72
+ dispose() {
73
+ if (disposed) return;
74
+ disposed = true;
75
+ cleanup();
76
+ },
77
+ };
78
+ }
package/src/glyphs.ts ADDED
@@ -0,0 +1,4 @@
1
+ export const EMPTY_CIRCLE = "󰄰";
2
+ export const CHECKED_CIRCLE = "󰄴";
3
+ export const EMPTY_BOX = "󰄱";
4
+ export const CHECKED_BOX = "󰄵";
package/src/index.ts ADDED
@@ -0,0 +1,231 @@
1
+ import type { ExtensionAPI, SessionEntry, Theme } from "@earendil-works/pi-coding-agent";
2
+ import { Text } from "@earendil-works/pi-tui";
3
+
4
+ import { rewriteAskContext } from "./context.js";
5
+ import { resolveTimeoutMs } from "./config.js";
6
+ import { createDeadlineSignal } from "./deadline.js";
7
+ import { CHECKED_BOX, CHECKED_CIRCLE, EMPTY_BOX, EMPTY_CIRCLE } from "./glyphs.js";
8
+ import { launchQuestionnaire } from "./questionnaire.js";
9
+ import { renderAskReanswerMessage } from "./replay-renderer.js";
10
+ import { ASK_REPLAY_CUSTOM_TYPE, buildAskReplayMessage, parseAskReplayDetails, resolveAskReplayTarget } from "./replay.js";
11
+ import { buildAnsweredResponse, buildCancelledResponse, buildUiUnavailableResponse, buildUnansweredResponse } from "./response.js";
12
+ import { askWithRpc } from "./rpc.js";
13
+ import { AskParamsSchema } from "./schema.js";
14
+ import type { AskAnswer, AskParams, AskToolDetails } from "./types.js";
15
+ import { validateAskParams } from "./validation.js";
16
+
17
+ const TREE_EDITOR_GUARD = "\u200B";
18
+
19
+ type AskRendererState = {
20
+ callComponent?: Text;
21
+ status?: "answered" | "settled";
22
+ invalidate?: () => void;
23
+ };
24
+
25
+ type AskReplayState =
26
+ | { status: "idle" }
27
+ | { status: "prompting" }
28
+ | {
29
+ status: "dispatched";
30
+ details: ReturnType<typeof buildAskReplayMessage>["details"];
31
+ clearEditor: boolean;
32
+ };
33
+
34
+ function renderAskCall(args: AskParams, theme: Theme, state: AskRendererState): string {
35
+ const questionColor = state.status === "answered" ? "text" : "dim";
36
+ const title = `${theme.fg("toolTitle", "ask")} ${theme.fg(questionColor, args.question)}`;
37
+ if (state.status) return title;
38
+
39
+ const optionCount = args.options.length;
40
+ const mode = args.allowMultiple === true ? "multi · " : "";
41
+ const timeout = args.timeout !== undefined && args.timeout > 0
42
+ ? ` · timeout:${formatTimeout(args.timeout)}`
43
+ : "";
44
+ return `${title}\n${theme.fg("dim", `╰ ${mode}options:${optionCount}${timeout}`)}`;
45
+ }
46
+
47
+ function formatTimeout(timeoutMs: number): string {
48
+ if (timeoutMs < 1000) return `${timeoutMs}ms`;
49
+ const seconds = timeoutMs / 1000;
50
+ return `${Number.isInteger(seconds) ? seconds : Number(seconds.toFixed(2))}s`;
51
+ }
52
+
53
+ function renderAnsweredOptions(args: AskParams, answer: AskAnswer, theme: Theme): string {
54
+ const selections = new Map(answer.selections.map(selection => [selection.label, selection]));
55
+ const checkedGlyph = args.allowMultiple === true ? CHECKED_BOX : CHECKED_CIRCLE;
56
+ const emptyGlyph = args.allowMultiple === true ? EMPTY_BOX : EMPTY_CIRCLE;
57
+ const lines = args.options.map(option => {
58
+ const label = option.label.trim();
59
+ const selection = selections.get(label);
60
+ const comment = selection?.comment ? ` (${selection.comment})` : "";
61
+ const text = `${label}${comment}`;
62
+ return selection
63
+ ? `${theme.fg("success", checkedGlyph)} ${theme.fg("text", text)}`
64
+ : theme.fg("dim", `${emptyGlyph} ${text}`);
65
+ });
66
+ if (answer.freeform) lines.push(`${theme.fg("success", checkedGlyph)} ${theme.fg("text", answer.freeform)}`);
67
+ return lines.map((line, index) => `${index === 0 ? `${theme.fg("dim", "╰")} ` : " "}${line}`).join("\n");
68
+ }
69
+
70
+ export default function askExtension(pi: ExtensionAPI) {
71
+ let replayState: AskReplayState = { status: "idle" };
72
+ let replayTreeSelection = false;
73
+ const revisedAnswers = new Map<string, AskAnswer>();
74
+ const rendererStates = new Map<string, AskRendererState>();
75
+
76
+ const applyRevision = (toolCallId: string, answer: AskAnswer) => {
77
+ revisedAnswers.set(toolCallId, answer);
78
+ rendererStates.get(toolCallId)?.invalidate?.();
79
+ };
80
+ const restoreRevisions = (entries: readonly SessionEntry[]) => {
81
+ revisedAnswers.clear();
82
+ for (const entry of entries) {
83
+ if (entry.type !== "custom_message" || entry.customType !== ASK_REPLAY_CUSTOM_TYPE) continue;
84
+ const details = parseAskReplayDetails(entry.details);
85
+ if (details) revisedAnswers.set(details.toolCallId, details.answer);
86
+ }
87
+ for (const state of rendererStates.values()) state.invalidate?.();
88
+ };
89
+
90
+ const reconcileAskTool = (hasUI: boolean) => {
91
+ if (hasUI) return;
92
+ const activeTools = pi.getActiveTools();
93
+ if (!activeTools.includes("ask")) return;
94
+ pi.setActiveTools(activeTools.filter(name => name !== "ask"));
95
+ };
96
+
97
+ pi.on("session_start", (_event, ctx) => {
98
+ reconcileAskTool(ctx.hasUI);
99
+ restoreRevisions(ctx.sessionManager.getBranch());
100
+ });
101
+ pi.on("before_agent_start", (_event, ctx) => reconcileAskTool(ctx.hasUI));
102
+ pi.on("context", (event) => ({ messages: rewriteAskContext(event.messages) }));
103
+ pi.on("agent_settled", (_event, ctx) => {
104
+ if (replayState.status !== "dispatched") return;
105
+ if (replayState.clearEditor) {
106
+ replayState = { ...replayState, clearEditor: false };
107
+ setTimeout(() => ctx.ui.setEditorText(""), 0);
108
+ }
109
+ pi.events.emit("ask:reanswered", replayState.details);
110
+ replayState = { status: "idle" };
111
+ });
112
+ pi.on("session_shutdown", () => {
113
+ replayState = { status: "idle" };
114
+ replayTreeSelection = false;
115
+ revisedAnswers.clear();
116
+ rendererStates.clear();
117
+ });
118
+ pi.registerMessageRenderer(ASK_REPLAY_CUSTOM_TYPE, renderAskReanswerMessage);
119
+ pi.on("session_before_tree", (event, ctx) => {
120
+ const target = ctx.sessionManager.getEntry(event.preparation.targetId);
121
+ replayTreeSelection = target?.type === "custom_message" && target.customType === ASK_REPLAY_CUSTOM_TYPE;
122
+ });
123
+ pi.on("session_tree", async (event, ctx) => {
124
+ const suppressEditorRestore = replayTreeSelection;
125
+ replayTreeSelection = false;
126
+ if (ctx.mode !== "tui" || replayState.status !== "idle") return;
127
+
128
+ const entries = ctx.sessionManager.getBranch();
129
+ restoreRevisions(entries);
130
+ const byId = new Map(entries.map(entry => [entry.id, entry]));
131
+ if (event.summaryEntry) byId.set(event.summaryEntry.id, event.summaryEntry);
132
+ const resolution = resolveAskReplayTarget(event, id => byId.get(id));
133
+ if (resolution.status !== "resolved") {
134
+ if (resolution.reason === "mixed-tools" || resolution.reason === "multiple-tool-calls" || resolution.reason === "invalid-arguments") {
135
+ ctx.ui.notify("This Ask cannot be re-answered because its original tool call is mixed or invalid.", "warning");
136
+ }
137
+ return;
138
+ }
139
+
140
+ // Pi restores selected custom-message content after this hook returns. Keep
141
+ // the editor temporarily non-empty until the replay turn settles so the
142
+ // selected marker text cannot replace the guard.
143
+ const guardEditor = suppressEditorRestore && !ctx.ui.getEditorText().trim();
144
+ if (guardEditor) ctx.ui.setEditorText(TREE_EDITOR_GUARD);
145
+
146
+ replayState = { status: "prompting" };
147
+ const deadline = createDeadlineSignal(
148
+ undefined,
149
+ resolveTimeoutMs(resolution.params.timeout, process.env),
150
+ );
151
+ try {
152
+ const answer = await launchQuestionnaire(ctx, resolution.params, deadline.signal);
153
+ if (!answer) return;
154
+ const message = buildAskReplayMessage(resolution.toolCallId, resolution.params, answer);
155
+ pi.sendMessage(message, { triggerTurn: true, deliverAs: "followUp" });
156
+ applyRevision(message.details.toolCallId, message.details.answer);
157
+ replayState = { status: "dispatched", details: message.details, clearEditor: guardEditor };
158
+ } finally {
159
+ deadline.dispose();
160
+ if (replayState.status !== "dispatched") {
161
+ replayState = { status: "idle" };
162
+ if (guardEditor) setTimeout(() => ctx.ui.setEditorText(""), 0);
163
+ }
164
+ }
165
+ });
166
+
167
+ pi.registerTool<typeof AskParamsSchema, AskToolDetails, AskRendererState>({
168
+ name: "ask",
169
+ label: "Ask",
170
+ description: "Ask the user one focused question with selectable options. Blocks until answered, cancelled, or timed out.",
171
+ promptSnippet: "Ask the user a focused question with selectable options when input is required",
172
+ promptGuidelines: [
173
+ "Use ask only when you can offer a short list of useful options; put open-ended questions in your normal response instead.",
174
+ "An ask_response is a completed ask whose tool call was removed from the context; treat its answer as final and do not re-ask.",
175
+ ],
176
+ parameters: AskParamsSchema,
177
+ executionMode: "sequential",
178
+
179
+ async execute(_toolCallId, rawParams, signal, _onUpdate, ctx) {
180
+ const params = validateAskParams(rawParams as AskParams);
181
+ if (!ctx.hasUI) return buildUiUnavailableResponse(params.question);
182
+
183
+ const deadline = createDeadlineSignal(
184
+ signal,
185
+ resolveTimeoutMs(params.timeout, process.env),
186
+ );
187
+ try {
188
+ const answer = ctx.mode === "tui"
189
+ ? await launchQuestionnaire(ctx, params, deadline.signal)
190
+ : await askWithRpc(ctx.ui, params, deadline.signal);
191
+ if (answer === null) {
192
+ if (deadline.timedOut) {
193
+ const result = buildUnansweredResponse(params.question);
194
+ pi.events.emit("ask:unanswered", result.details);
195
+ return result;
196
+ }
197
+ const result = buildCancelledResponse(params.question);
198
+ pi.events.emit("ask:cancelled", result.details);
199
+ return result;
200
+ }
201
+
202
+ const result = buildAnsweredResponse(params.question, answer);
203
+ pi.events.emit("ask:answered", result.details);
204
+ return result;
205
+ } finally {
206
+ deadline.dispose();
207
+ }
208
+ },
209
+
210
+ renderCall(args, theme, context) {
211
+ const text = context.lastComponent instanceof Text ? context.lastComponent : new Text("", 0, 0);
212
+ context.state.callComponent = text;
213
+ text.setText(renderAskCall(args, theme, context.state));
214
+ return text;
215
+ },
216
+ renderResult(result, _options, theme, context) {
217
+ context.state.invalidate = context.invalidate;
218
+ rendererStates.set(context.toolCallId, context.state);
219
+ const answer = revisedAnswers.get(context.toolCallId)
220
+ ?? (result.details?.status === "answered" ? result.details.answer : undefined);
221
+ context.state.status = answer === undefined ? "settled" : "answered";
222
+ context.state.callComponent?.setText(renderAskCall(context.args, theme, context.state));
223
+ if (answer) return new Text(renderAnsweredOptions(context.args, answer, theme), 0, 0);
224
+ const text = result.content.find((item) => item.type === "text")?.text ?? "Ask completed.";
225
+ const color = result.details?.status === "cancelled" || result.details?.status === "unanswered"
226
+ ? "muted"
227
+ : "text";
228
+ return new Text(theme.fg(color, text), 0, 0);
229
+ },
230
+ });
231
+ }
package/src/preview.ts ADDED
@@ -0,0 +1,55 @@
1
+ import { getMarkdownTheme } from "@earendil-works/pi-coding-agent";
2
+ import { Markdown, truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
3
+
4
+ /** Terminal width at which an authored preview gets its own pane. */
5
+ export const PREVIEW_WIDE_THRESHOLD = 88;
6
+
7
+ export interface PreviewPaneLayout {
8
+ leftWidth: number;
9
+ rightWidth: number;
10
+ }
11
+
12
+ /**
13
+ * Choose the option/preview pane sizes without allowing either pane to become
14
+ * unusably narrow. The split is intentionally opt-in at a comfortable width.
15
+ */
16
+ export function getPreviewPaneLayout(width: number): PreviewPaneLayout | undefined {
17
+ const renderWidth = safeWidth(width);
18
+ if (renderWidth < PREVIEW_WIDE_THRESHOLD) return undefined;
19
+
20
+ const available = renderWidth - 1; // one column for the separator
21
+ const leftWidth = Math.max(32, Math.floor(available * 0.4));
22
+ const rightWidth = available - leftWidth;
23
+ if (rightWidth < 40) return undefined;
24
+ return { leftWidth, rightWidth };
25
+ }
26
+
27
+ /** Render authored option text using Pi's normal markdown theme. */
28
+ export function renderPreviewMarkdown(preview: string, width: number): string[] {
29
+ if (!preview.trim()) return [];
30
+ const lines = new Markdown(preview, 0, 0, getMarkdownTheme()).render(safeWidth(width));
31
+ return lines.map(line => fitAndPad(line, safeWidth(width)));
32
+ }
33
+
34
+ /** Compose one visible option row with its corresponding preview row. */
35
+ export function composePreviewRow(
36
+ left: string,
37
+ preview: string,
38
+ layout: PreviewPaneLayout,
39
+ separator: string,
40
+ ): string {
41
+ return [
42
+ fitAndPad(left, layout.leftWidth),
43
+ fitAndPad(separator, 1),
44
+ fitAndPad(preview, layout.rightWidth),
45
+ ].join("");
46
+ }
47
+
48
+ function safeWidth(width: number): number {
49
+ return Math.max(1, Number.isFinite(width) ? Math.floor(width) : 1);
50
+ }
51
+
52
+ function fitAndPad(line: string, width: number): string {
53
+ const clipped = visibleWidth(line) > width ? truncateToWidth(line, width, "") : line;
54
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
55
+ }
@@ -0,0 +1,37 @@
1
+ import type { ExtensionUIContext } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { AskComponent } from "./component.js";
4
+ import type { AskAnswer, ValidatedAskParams } from "./types.js";
5
+
6
+ interface QuestionnaireLaunchContext {
7
+ ui: Pick<ExtensionUIContext, "custom">;
8
+ }
9
+
10
+ export async function launchQuestionnaire(
11
+ ctx: QuestionnaireLaunchContext,
12
+ params: ValidatedAskParams,
13
+ signal?: AbortSignal,
14
+ ): Promise<AskAnswer | null> {
15
+ let abortListener: (() => void) | undefined;
16
+ try {
17
+ const answer = await ctx.ui.custom<AskAnswer | null>((tui, theme, keybindings, done) => {
18
+ const component = new AskComponent({
19
+ ...params,
20
+ tui,
21
+ theme,
22
+ keybindings,
23
+ onSubmit: done,
24
+ onCancel: () => done(null),
25
+ });
26
+
27
+ abortListener = () => component.cancel();
28
+ if (signal?.aborted) abortListener();
29
+ else if (signal) signal.addEventListener("abort", abortListener, { once: true });
30
+
31
+ return component;
32
+ });
33
+ return answer ?? null;
34
+ } finally {
35
+ if (abortListener) signal?.removeEventListener("abort", abortListener);
36
+ }
37
+ }