@pi-kaush/pi-tool-call-markers 0.3.5 → 0.3.7

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.
@@ -0,0 +1,174 @@
1
+ import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+
3
+ // The submitted-prompt shell, shared by every execution row that should read
4
+ // as user input: user-run `!` bash blocks and asked-question blocks. Pi's own
5
+ // submitted user messages are drawn with the same rail, surface, inset, and
6
+ // body padding by pi-content-layout, which owns the message surface and never
7
+ // tool rows. The geometry here mirrors that package's `renderSubmittedUserLines`
8
+ // and `contentInset`; change the two together.
9
+
10
+ export const PROMPT_RAIL = "▎";
11
+
12
+ export type PromptShellTheme = {
13
+ fg(color: string, text: string): string;
14
+ getBgAnsi?(color: string): string;
15
+ };
16
+
17
+ // The prompt surface paints with the theme's userMessageBg token, matching
18
+ // pi-content-layout's surfaces. userMessageBg is a required pi theme token;
19
+ // the legacy hex remains only as a fallback for theme lookalikes without
20
+ // getBgAnsi.
21
+ const PROMPT_SURFACE_BG_FALLBACK = "\x1b[48;2;7;19;18m"; // #071312
22
+
23
+ const PROMPT_BLOCK_INSET = 2;
24
+
25
+ function promptSurfaceBg(theme: PromptShellTheme): string {
26
+ try {
27
+ return theme.getBgAnsi?.("userMessageBg") ?? PROMPT_SURFACE_BG_FALLBACK;
28
+ } catch {
29
+ return PROMPT_SURFACE_BG_FALLBACK;
30
+ }
31
+ }
32
+
33
+ // Prompt-shell blocks align with the message inset pi-content-layout applies
34
+ // to transcript lines: 2 columns outside the rail for any width above 4.
35
+ export function promptBlockInset(width: number): number {
36
+ return width > PROMPT_BLOCK_INSET * 2 ? PROMPT_BLOCK_INSET : 0;
37
+ }
38
+
39
+ // Inside the rail, a block's body is padded on both sides. Pi's submitted user
40
+ // messages carry one column left and two right, which the shorter right column
41
+ // of the rail glyph's cell makes read as equal. A user `!` block keeps Pi's own
42
+ // body and pads left only.
43
+ export type PromptBlockPadding = { left: number; right: number };
44
+
45
+ export const SUBMITTED_PROMPT_PADDING: PromptBlockPadding = {
46
+ left: 1,
47
+ right: 2,
48
+ };
49
+
50
+ const BASH_BLOCK_PADDING: PromptBlockPadding = { left: 1, right: 0 };
51
+
52
+ // Columns available to pre-wrapped content lines: the inset on both sides, the
53
+ // rail, and the body padding `renderRailedBlockLines` applies.
54
+ export function promptBlockContentWidth(
55
+ width: number,
56
+ inset = promptBlockInset(width),
57
+ padding: PromptBlockPadding = BASH_BLOCK_PADDING,
58
+ ): number {
59
+ if (inset === 0) return Math.max(1, width);
60
+ return Math.max(
61
+ 1,
62
+ width -
63
+ inset * 2 -
64
+ visibleWidth(PROMPT_RAIL) -
65
+ padding.left -
66
+ padding.right,
67
+ );
68
+ }
69
+
70
+ const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
71
+ const BACKGROUND_PATTERN =
72
+ /\x1b\[(?:4[0-8]|10[0-7]|48;5;\d{1,3}|48;2;\d{1,3};\d{1,3};\d{1,3})m/g;
73
+ const DISPLAY_ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
74
+
75
+ function semanticControlEnd(text: string, offset: number): number | undefined {
76
+ if (text[offset] !== "\x1b") return undefined;
77
+ const kind = text[offset + 1];
78
+ if (kind !== "]" && kind !== "_" && kind !== "P" && kind !== "^") {
79
+ return undefined;
80
+ }
81
+ for (let index = offset + 2; index < text.length; index++) {
82
+ if (text[index] === "\x07") return index + 1;
83
+ if (text[index] === "\x1b" && text[index + 1] === "\\") {
84
+ return index + 2;
85
+ }
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ function splitLeadingSemanticControls(line: string): {
91
+ controls: string;
92
+ content: string;
93
+ } {
94
+ let offset = 0;
95
+ while (offset < line.length) {
96
+ const end = semanticControlEnd(line, offset);
97
+ if (end === undefined) break;
98
+ offset = end;
99
+ }
100
+ return { controls: line.slice(0, offset), content: line.slice(offset) };
101
+ }
102
+
103
+ export function fitLine(line: string, width: number): string {
104
+ if (width <= 0) return "";
105
+ const clipped = truncateToWidth(line, width, "");
106
+ return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
107
+ }
108
+
109
+ function reapplyBackgroundAfterReset(
110
+ text: string,
111
+ backgroundAnsi: string,
112
+ ): string {
113
+ return text.replace(SGR_PATTERN, (sequence, rawParameters: string) => {
114
+ const parameters =
115
+ rawParameters === "" ? [0] : rawParameters.split(";").map(Number);
116
+ return parameters.includes(0) || parameters.includes(49)
117
+ ? `${sequence}${backgroundAnsi}`
118
+ : sequence;
119
+ });
120
+ }
121
+
122
+ function paintBackground(
123
+ line: string,
124
+ width: number,
125
+ backgroundAnsi: string,
126
+ ): string {
127
+ const fitted = fitLine(line, width);
128
+ // Mirrors Theme.bg: background, content, then a background-only reset.
129
+ return `${backgroundAnsi}${reapplyBackgroundAfterReset(fitted, backgroundAnsi)}\x1b[49m`;
130
+ }
131
+
132
+ export function stripDisplayAnsi(text: string): string {
133
+ return text.replace(DISPLAY_ANSI_PATTERN, "");
134
+ }
135
+
136
+ function replaceBackground(text: string, backgroundAnsi: string): string {
137
+ return text.replace(BACKGROUND_PATTERN, backgroundAnsi);
138
+ }
139
+
140
+ // Renders lines inside a railed, dark-surfaced block (the submitted-prompt
141
+ // shell): optional leading semantic controls stay outside the inset, the rail
142
+ // is recolored, and content backgrounds are normalized to the prompt surface.
143
+ // A blank input line becomes a fully painted body row, which is how the shell
144
+ // gets its background padding rows above and below.
145
+ export function renderRailedBlockLines(
146
+ lines: string[],
147
+ width: number,
148
+ theme: PromptShellTheme,
149
+ inset: number,
150
+ railColor: string,
151
+ padding: PromptBlockPadding = BASH_BLOCK_PADDING,
152
+ ): string[] {
153
+ const blockWidth = width - inset * 2;
154
+ if (inset === 0 || blockWidth <= visibleWidth(PROMPT_RAIL)) {
155
+ return lines.map((line) => fitLine(line, width));
156
+ }
157
+ const bodyWidth = blockWidth - visibleWidth(PROMPT_RAIL);
158
+ const margin = " ".repeat(inset);
159
+ const leftPad = " ".repeat(padding.left);
160
+ const rightPad = " ".repeat(padding.right);
161
+ const contentWidth = Math.max(1, bodyWidth - padding.left - padding.right);
162
+ const surfaceBg = promptSurfaceBg(theme);
163
+ return lines.map((line) => {
164
+ const { controls, content } = splitLeadingSemanticControls(line);
165
+ const recolored = replaceBackground(content, surfaceBg);
166
+ const rail = theme.fg(railColor, PROMPT_RAIL);
167
+ const body = paintBackground(
168
+ `${leftPad}${fitLine(recolored, contentWidth)}${rightPad}`,
169
+ bodyWidth,
170
+ surfaceBg,
171
+ );
172
+ return `${controls}${margin}${rail}${body}${margin}`;
173
+ });
174
+ }
@@ -0,0 +1,328 @@
1
+ import { visibleWidth } from "@earendil-works/pi-tui";
2
+ import {
3
+ SUBMITTED_PROMPT_PADDING,
4
+ promptBlockContentWidth,
5
+ promptBlockInset,
6
+ renderRailedBlockLines,
7
+ type PromptShellTheme,
8
+ } from "./prompt-shell.ts";
9
+ import { sanitizeInline } from "./sanitize.ts";
10
+
11
+ // An asked question is a user-input moment inside the transcript, not an
12
+ // execution row, so it takes the submitted-prompt shell instead of the
13
+ // tool-call rail: the questions read as quoted prose and the answers as the
14
+ // user's own text. The data comes entirely from what Pi already hands the
15
+ // tool row — `args.questions` and `result.details.answers` — so no coupling to
16
+ // any particular question-tool implementation is needed.
17
+
18
+ type QuestionTheme = PromptShellTheme & {
19
+ fg(color: string, text: string): string;
20
+ italic?(text: string): string;
21
+ };
22
+
23
+ type QuestionRow = {
24
+ toolName?: string;
25
+ args?: unknown;
26
+ isPartial?: boolean;
27
+ result?: {
28
+ isError?: boolean;
29
+ details?: Record<string, unknown>;
30
+ };
31
+ };
32
+
33
+ type AnswerEntry = {
34
+ question: string;
35
+ questionIndex?: number | undefined;
36
+ kind?: string | undefined;
37
+ answer?: string | null | undefined;
38
+ selected?: string[] | undefined;
39
+ };
40
+
41
+ type QuestionLine =
42
+ | { kind: "question"; text: string }
43
+ | { kind: "answer"; text: string }
44
+ | { kind: "status"; text: string; tone: "muted" | "warning" };
45
+
46
+ // rpiv's canonical decline text, reused verbatim so the transcript says what
47
+ // the model was told.
48
+ const DECLINE_TEXT = "User declined to answer questions";
49
+ const AWAITING_TEXT = "awaiting your answer…";
50
+ const NO_ANSWER_TEXT = "(no answer)";
51
+ const QUESTION_PREFIX = "> ";
52
+ const ANSWER_PREFIX = "User: ";
53
+
54
+ // Question tools keep the ask-tool vocabulary in their name even when they
55
+ // invent their own result shape. The result-shape check is the primary signal;
56
+ // this pattern is the fallback for calls that never reached an answer.
57
+ const QUESTION_TOOL_NAME_RE =
58
+ /(^|_)(ask_?user_?question|ask_?user|user_?question|questionnaire)($|_)/;
59
+
60
+ function isRecord(value: unknown): value is Record<string, unknown> {
61
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
62
+ }
63
+
64
+ function normalizeToolName(name: string): string {
65
+ return name
66
+ .toLowerCase()
67
+ .replace(/[^a-z0-9]+/g, "_")
68
+ .replace(/^_+|_+$/g, "");
69
+ }
70
+
71
+ function isQuestionToolName(name: string | undefined): boolean {
72
+ if (!name) return false;
73
+ return QUESTION_TOOL_NAME_RE.test(normalizeToolName(name));
74
+ }
75
+
76
+ // The rpiv result contract: `details.answers` is always an array of entries
77
+ // carrying the question text, and each entry's answer arrives as `answer`
78
+ // (option label or typed text) or `selected` (multi-select labels). Any
79
+ // deviation fails the shape check so an unrelated tool is never reshaped.
80
+ function parseAnswerEntries(details: unknown): AnswerEntry[] | undefined {
81
+ if (!isRecord(details)) return undefined;
82
+ const raw = details.answers;
83
+ if (!Array.isArray(raw)) return undefined;
84
+ const entries: AnswerEntry[] = [];
85
+ for (const item of raw) {
86
+ if (!isRecord(item) || typeof item.question !== "string") return undefined;
87
+ const answer = item.answer;
88
+ if (answer !== undefined && answer !== null && typeof answer !== "string") {
89
+ return undefined;
90
+ }
91
+ const selected = item.selected;
92
+ if (
93
+ selected !== undefined &&
94
+ !(
95
+ Array.isArray(selected) &&
96
+ selected.every((label) => typeof label === "string")
97
+ )
98
+ ) {
99
+ return undefined;
100
+ }
101
+ entries.push({
102
+ question: item.question,
103
+ questionIndex:
104
+ typeof item.questionIndex === "number" ? item.questionIndex : undefined,
105
+ kind: typeof item.kind === "string" ? item.kind : undefined,
106
+ answer,
107
+ selected: selected as string[] | undefined,
108
+ });
109
+ }
110
+ return entries;
111
+ }
112
+
113
+ function hasQuestionDetails(details: unknown): boolean {
114
+ const entries = parseAnswerEntries(details);
115
+ if (entries === undefined) return false;
116
+ if (entries.length > 0) return true;
117
+ if (!isRecord(details)) return false;
118
+ // An empty `answers` array only means a questionnaire when the details also
119
+ // carry the outcome that emptied it: a decline, or a validation failure.
120
+ return details.cancelled === true || typeof details.error === "string";
121
+ }
122
+
123
+ function parseAskedQuestions(args: unknown): string[] | undefined {
124
+ if (!isRecord(args) || !Array.isArray(args.questions)) return undefined;
125
+ const questions: string[] = [];
126
+ for (const item of args.questions) {
127
+ if (!isRecord(item) || typeof item.question !== "string") return undefined;
128
+ questions.push(item.question);
129
+ }
130
+ return questions.length > 0 ? questions : undefined;
131
+ }
132
+
133
+ function answerText(entry: AnswerEntry | undefined): string | undefined {
134
+ if (!entry) return undefined;
135
+ if (entry.kind === "multi" || entry.selected !== undefined) {
136
+ return entry.selected && entry.selected.length > 0
137
+ ? entry.selected.join(", ")
138
+ : undefined;
139
+ }
140
+ return entry.answer && entry.answer.length > 0 ? entry.answer : undefined;
141
+ }
142
+
143
+ function entryForIndex(
144
+ entries: AnswerEntry[],
145
+ index: number,
146
+ questionCount: number,
147
+ ): AnswerEntry | undefined {
148
+ const indexed = entries.find((entry) => entry.questionIndex === index);
149
+ if (indexed) return indexed;
150
+ // Entries without a `questionIndex` still arrive in ask order.
151
+ return entries.some((entry) => entry.questionIndex !== undefined) ||
152
+ entries.length !== questionCount
153
+ ? undefined
154
+ : entries[index];
155
+ }
156
+
157
+ function questionLines(
158
+ row: QuestionRow,
159
+ details: unknown,
160
+ ): QuestionLine[] | undefined {
161
+ const entries = parseAnswerEntries(details) ?? [];
162
+ const asked = parseAskedQuestions(row.args);
163
+ const pairs: Array<{ question: string; answer: string | undefined }> = [];
164
+ if (asked) {
165
+ asked.forEach((question, index) => {
166
+ const entry = entryForIndex(entries, index, asked.length);
167
+ pairs.push({ question, answer: answerText(entry) });
168
+ });
169
+ } else {
170
+ for (const entry of entries) {
171
+ pairs.push({ question: entry.question, answer: answerText(entry) });
172
+ }
173
+ }
174
+ if (pairs.length === 0) return undefined;
175
+
176
+ const lines: QuestionLine[] = [];
177
+ const questions = pairs.map(
178
+ (pair): QuestionLine => ({ kind: "question", text: pair.question }),
179
+ );
180
+ if (row.isPartial === true) {
181
+ return [
182
+ ...questions,
183
+ { kind: "status", text: AWAITING_TEXT, tone: "warning" },
184
+ ];
185
+ }
186
+ if (
187
+ isRecord(details) &&
188
+ details.cancelled === true &&
189
+ pairs.every((pair) => pair.answer === undefined)
190
+ ) {
191
+ return [
192
+ ...questions,
193
+ { kind: "status", text: DECLINE_TEXT, tone: "muted" },
194
+ ];
195
+ }
196
+ for (const pair of pairs) {
197
+ lines.push({ kind: "question", text: pair.question });
198
+ lines.push({ kind: "answer", text: pair.answer ?? NO_ANSWER_TEXT });
199
+ }
200
+ return lines;
201
+ }
202
+
203
+ function fg(theme: QuestionTheme, token: string, text: string): string {
204
+ if (text === "") return "";
205
+ try {
206
+ return theme.fg(token, text);
207
+ } catch {
208
+ return text;
209
+ }
210
+ }
211
+
212
+ // The question is the model's text read inside a user-input shell, so it takes
213
+ // the quotation's italics; the answer is the user's own words and stays upright.
214
+ // A theme without italics simply renders the question plainly.
215
+ function italics(theme: QuestionTheme, text: string): string {
216
+ if (text === "" || typeof theme.italic !== "function") return text;
217
+ try {
218
+ return theme.italic(text);
219
+ } catch {
220
+ return text;
221
+ }
222
+ }
223
+
224
+ // Plain-text word wrap with a hanging indent, so a wrapped question stays
225
+ // visually attached to its `> ` prefix. Wrapping before styling keeps the
226
+ // width math exact and lets the shell's own fit pass stay a no-op.
227
+ function wrapPlain(text: string, width: number): string[] {
228
+ const max = Math.max(1, width);
229
+ const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
230
+ if (words.length === 0) return [""];
231
+
232
+ const lines: string[] = [];
233
+ let current = "";
234
+ const push = (word: string) => {
235
+ const candidate = current === "" ? word : `${current} ${word}`;
236
+ if (visibleWidth(candidate) <= max) {
237
+ current = candidate;
238
+ return;
239
+ }
240
+ if (current !== "") lines.push(current);
241
+ // A single token wider than the line is hard-split rather than truncated.
242
+ let rest = word;
243
+ while (visibleWidth(rest) > max) {
244
+ let cut = rest.length;
245
+ while (cut > 1 && visibleWidth(rest.slice(0, cut)) > max) cut--;
246
+ lines.push(rest.slice(0, cut));
247
+ rest = rest.slice(cut);
248
+ }
249
+ current = rest;
250
+ };
251
+ for (const word of words) push(word);
252
+ if (current !== "") lines.push(current);
253
+ return lines;
254
+ }
255
+
256
+ function styleLine(
257
+ line: QuestionLine,
258
+ width: number,
259
+ theme: QuestionTheme,
260
+ ): string[] {
261
+ const prefix =
262
+ line.kind === "question"
263
+ ? QUESTION_PREFIX
264
+ : line.kind === "answer"
265
+ ? ANSWER_PREFIX
266
+ : "";
267
+ const prefixToken = line.kind === "question" ? "borderAccent" : "muted";
268
+ const textToken =
269
+ line.kind === "question"
270
+ ? "text"
271
+ : line.kind === "answer"
272
+ ? "userMessageText"
273
+ : line.tone;
274
+ const hang = " ".repeat(visibleWidth(prefix));
275
+ const body = wrapPlain(line.text, Math.max(1, width - visibleWidth(prefix)));
276
+ return body.map(
277
+ (piece, index) =>
278
+ fg(theme, prefixToken, index === 0 ? prefix : hang) +
279
+ (line.kind === "question"
280
+ ? italics(theme, fg(theme, textToken, piece))
281
+ : fg(theme, textToken, piece)),
282
+ );
283
+ }
284
+
285
+ /**
286
+ * True when the row should read as an asked question rather than an execution
287
+ * row. Shape first, so another author's question tool works unedited; the tool
288
+ * name is the fallback for calls that failed before answering.
289
+ */
290
+ export function isQuestionToolCall(row: QuestionRow): boolean {
291
+ if (hasQuestionDetails(row.result?.details)) return true;
292
+ return isQuestionToolName(row.toolName);
293
+ }
294
+
295
+ /**
296
+ * Renders the question/answer block in the submitted-prompt shell, or
297
+ * `undefined` when the row should keep the generic collapsed presentation.
298
+ * A failed questionnaire is one such case: its details carry an error code
299
+ * rather than a state worth a block, and the generic failure row already
300
+ * reports it in the error tone.
301
+ */
302
+ export function renderQuestionBlock(
303
+ row: QuestionRow,
304
+ width: number,
305
+ theme: QuestionTheme,
306
+ ): string[] | undefined {
307
+ const details = row.result?.details;
308
+ if (!isQuestionToolCall(row)) return undefined;
309
+ if (isRecord(details) && typeof details.error === "string") return undefined;
310
+
311
+ const lines = questionLines(row, details);
312
+ if (!lines) return undefined;
313
+
314
+ const inset = promptBlockInset(width);
315
+ const padding = SUBMITTED_PROMPT_PADDING;
316
+ const contentWidth = promptBlockContentWidth(width, inset, padding);
317
+ const styled = lines.flatMap((line) => styleLine(line, contentWidth, theme));
318
+ // The blank rows top and bottom are the shell's background padding, matching
319
+ // a submitted user message; they become fully painted body rows.
320
+ return renderRailedBlockLines(
321
+ ["", ...styled, ""],
322
+ width,
323
+ theme,
324
+ inset,
325
+ "borderAccent",
326
+ padding,
327
+ );
328
+ }
@@ -0,0 +1,20 @@
1
+ // Command output and model-supplied text can carry cursor-moving control
2
+ // bytes, raw terminal sequences, line feeds, and tabs. Collapsed rows and
3
+ // prompt blocks are width-fitted single lines, so scraped text is stripped of
4
+ // display sequences and all C0 controls before it can reach them; otherwise
5
+ // control bytes could split or overwrite a row.
6
+
7
+ const INLINE_CONTROL_RE = /[\x00-\x1f\x7f]/g;
8
+
9
+ // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
10
+ // leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
11
+ // hyperlinks, titles) end at their first BEL/ST terminator; a payload match
12
+ // must stop there too, because the visible text sits BETWEEN two OSC
13
+ // sequences — Pi wraps read paths as `ESC]8;;url ESC\ <path> ESC]8;; ESC\`,
14
+ // and a greedy `[^\x07]*` would swallow that path along with the sequences.
15
+ const DISPLAY_ANSI_RE =
16
+ /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
17
+
18
+ export function sanitizeInline(text: string): string {
19
+ return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
20
+ }