@pi-kaush/pi-tool-call-markers 0.3.6 → 0.3.8

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/CHANGELOG.md CHANGED
@@ -2,6 +2,37 @@
2
2
 
3
3
  ## Unreleased
4
4
 
5
+ - Render an asked question as a user-input moment instead of a raw tool row.
6
+ `ask_user_question` used to collapse to `│ * {"questions":…`, which is a tool
7
+ call marker doing the work of a prompt. An asked question now takes the
8
+ submitted-prompt shell: `▎ > <question>` followed by `▎ User: <answer>`, one
9
+ pair per question, with multi-select labels joined, a decline reading
10
+ `User declined to answer questions`, and a live row showing
11
+ `awaiting your answer…`. Recognition is shape-first off
12
+ `details.answers`, so another author's question tool renders the same without
13
+ a change here; the tool name is the fallback for calls that never answered.
14
+ The block reuses pi-content-layout's submitted-prompt geometry — rail,
15
+ surface, inset, one-column left and two-column right body padding, and
16
+ background padding rows — and italicizes the question while the answer stays
17
+ upright. Question blocks never join a tool group, and `Ctrl+O` still restores
18
+ Pi's native expanded rendering.
19
+
20
+ - Make the railed shell's body padding explicit: `renderRailedBlockLines` takes
21
+ a `{ left, right }` padding, so the submitted-prompt shape (1/2) and the
22
+ existing `!`-block shape (1/0) come from one code path instead of the body
23
+ width being hardcoded.
24
+
25
+ - Separate asked questions with one painted blank row. Adjacent questions ran
26
+ together as one wall of text; each question/answer pair now gets a blank body
27
+ row between it and the next. A lone question keeps its outcome on the
28
+ following line, and a global outcome (still awaiting, or declined) is
29
+ separated from a multi-question ask so it cannot read as answering only the
30
+ last question.
31
+
32
+ - Extract the railed prompt shell into `src/prompt-shell.ts`. User `!` blocks
33
+ and asked-question blocks share one implementation of the inset, rail,
34
+ surface background, and background repaint instead of two.
35
+
5
36
  - Read colors from the live palette instead of caching them against the
6
37
  theme object. Pi swaps the palette behind a stable theme Proxy, so every
7
38
  identity-keyed cache survived a theme switch: collapsed rows, the `+ Thought`
package/README.md CHANGED
@@ -47,6 +47,29 @@ While a subagent runs, the plan headline's tail shows live progress from the str
47
47
 
48
48
  Malformed, ambiguous, future, or too-narrow shapes fall back to the generic `↪ subagent …` collapsed row rather than dropping information, and `Ctrl+O` still exposes the native subagent renderer.
49
49
 
50
+ ## Asked questions
51
+
52
+ A call that asks the user something is a user-input moment, not an execution row, so it renders in the submitted-prompt shell instead of the tool-call rail:
53
+
54
+ ```text
55
+ ▎
56
+ ▎ > Which call-site shape should `notifications:` use?
57
+ ▎ User: Nested within subagent (Recommended)
58
+ ▎
59
+ ▎ > Which tier should the flag read at runtime?
60
+ ▎ User: Client only, Client and server
61
+ ▎
62
+ ```
63
+
64
+ The block reuses `pi-content-layout`'s submitted-prompt geometry — the same rail, surface, inset, one-column left padding, two-column right padding, and background padding rows — so an asked question and a submitted prompt are the same shape in the transcript.
65
+
66
+ - **Shape-first recognition.** Any tool whose result carries an `answers` array of `{ question, answer | selected }` entries renders as a question block, so another author's question tool works without a change here. The tool name (`ask_user_question` and close variants) is the fallback for a call that failed before answering.
67
+ - **Answers, not options.** Each question shows its answer: an option label, typed custom text, or multi-select labels joined with commas. A question left blank reads `User: (no answer)`, a decline reads `User declined to answer questions`, and a validation failure keeps Pi's native failure row.
68
+ - **Quoted questions.** The question is the model's text read inside a user-input shell, so it renders italic under a `>` marker and the answer stays upright: the answer is the user's own words. A theme without italics renders the question plainly.
69
+ - **Prompt-shell tones.** The rail uses `borderAccent` and the body paints on `userMessageBg`, with the question in `text`, the `User:` label in `muted`, the answer in `userMessageText`, and a live row's `awaiting your answer…` in `warning`.
70
+ - **One blank row per question.** Questions are separated by a painted blank row so a multi-question ask stays scannable. A lone question keeps its outcome on the following line.
71
+ - **No grouping.** Question blocks never join an adjacent tool group and carry no tool glyph. `Ctrl+O` still restores Pi's native expanded rendering.
72
+
50
73
  ## Edit diffs
51
74
 
52
75
  Settled `edit` calls collapse like every other row: the call line keeps its path and a `+added/-removed` outcome stat, and the hunk itself stays out of the collapsed row. `Ctrl+O` restores Pi's full native diff, including line numbers and intra-line word highlights. To keep edits expanded without pressing `Ctrl+O`, list `edit` in `PI_ALWAYS_EXPANDED_TOOL_CALL_MARKERS` (see Configuration).
@@ -55,9 +78,9 @@ Files and paths in collapsed rows are preserved as displayed by Pi: hyperlink-wr
55
78
 
56
79
  ## Scope boundary
57
80
 
58
- This package owns every **execution row** in the transcript: collapsed tool calls, tool grouping, subagent plans, thinking labels, and user-run `!` bash blocks (reshaped into the railed prompt shell). Transcript _surface_ layout — message insets, system-text and status-rule alignment, the editor surface, and the submitted-prompt shell for user messages — belongs to `@pi-kaush/pi-content-layout`, which never renders tool rows.
81
+ This package owns every **execution row** in the transcript: collapsed tool calls, tool grouping, subagent plans, asked-question blocks, thinking labels, and user-run `!` bash blocks (reshaped into the railed prompt shell). Transcript _surface_ layout — message insets, system-text and status-rule alignment, the editor surface, and the submitted-prompt shell for user messages — belongs to `@pi-kaush/pi-content-layout`, which never renders tool rows.
59
82
 
60
- One shared visual contract crosses the line: `!` blocks align with message text, so their inset mirrors `pi-content-layout`'s message `contentInset` (see `src/bash-block.ts`). Change indentation in both packages together.
83
+ One shared visual contract crosses the line: `!` blocks and asked-question blocks align with message text, so their inset mirrors `pi-content-layout`'s message `contentInset` (see `src/prompt-shell.ts`, which owns the railed shell both use). Change indentation in both packages together.
61
84
 
62
85
  ## Hiding info entirely: /toggle-info
63
86
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@pi-kaush/pi-tool-call-markers",
3
- "version": "0.3.6",
3
+ "version": "0.3.8",
4
4
  "description": "Compact and group Pi tool calls, with a bundled extension for adjacent thinking blocks.",
5
5
  "license": "MIT",
6
6
  "author": "Kaushik Gopal",
package/src/bash-block.ts CHANGED
@@ -1,142 +1,21 @@
1
1
  import { BashExecutionComponent } from "@earendil-works/pi-coding-agent";
2
- import { truncateToWidth, visibleWidth } from "@earendil-works/pi-tui";
2
+ import { visibleWidth } from "@earendil-works/pi-tui";
3
3
  import { paletteSample } from "./muted.ts";
4
-
5
- type ThemeLike = {
6
- bold(text: string): string;
4
+ import {
5
+ PROMPT_RAIL,
6
+ fitLine,
7
+ promptBlockInset,
8
+ renderRailedBlockLines,
9
+ stripDisplayAnsi,
10
+ type PromptShellTheme,
11
+ } from "./prompt-shell.ts";
12
+
13
+ type ThemeLike = PromptShellTheme & {
7
14
  fg(color: string, text: string): string;
8
- getBgAnsi?(color: string): string;
9
15
  };
10
16
 
11
17
  const BASH_BLOCK_PATCHED = Symbol.for("kg.pi.bashBlock.v1");
12
18
 
13
- // The prompt surface paints with the theme's userMessageBg token, matching
14
- // pi-content-layout's surfaces. userMessageBg is a required pi theme token;
15
- // the legacy hex remains only as a fallback for theme lookalikes without
16
- // getBgAnsi.
17
- const PROMPT_SURFACE_BG_FALLBACK = "\x1b[48;2;7;19;18m"; // #071312
18
-
19
- function promptSurfaceBg(theme: ThemeLike): string {
20
- try {
21
- return theme.getBgAnsi?.("userMessageBg") ?? PROMPT_SURFACE_BG_FALLBACK;
22
- } catch {
23
- return PROMPT_SURFACE_BG_FALLBACK;
24
- }
25
- }
26
- const PROMPT_RAIL = "▎";
27
-
28
- // User-run bash blocks align with the message inset pi-content-layout
29
- // applies to transcript lines. Keep this in sync with that package's
30
- // contentInset (2 columns outside the rail for any width above 4); the
31
- // conversation surface has one shared visual contract even though the two
32
- // packages otherwise own disjoint components.
33
- function bashBlockInset(width: number): number {
34
- return width > BASH_BLOCK_INSET * 2 ? BASH_BLOCK_INSET : 0;
35
- }
36
- const BASH_BLOCK_INSET = 2;
37
-
38
- const SGR_PATTERN = /\x1b\[([0-9;]*)m/g;
39
- const BACKGROUND_PATTERN =
40
- /\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;
41
- const DISPLAY_ANSI_PATTERN = /\x1b(?:[@-Z\\-_]|\[[0-?]*[ -/]*[@-~])/g;
42
-
43
- function semanticControlEnd(text: string, offset: number): number | undefined {
44
- if (text[offset] !== "\x1b") return undefined;
45
- const kind = text[offset + 1];
46
- if (kind !== "]" && kind !== "_" && kind !== "P" && kind !== "^") {
47
- return undefined;
48
- }
49
- for (let index = offset + 2; index < text.length; index++) {
50
- if (text[index] === "\x07") return index + 1;
51
- if (text[index] === "\x1b" && text[index + 1] === "\\") {
52
- return index + 2;
53
- }
54
- }
55
- return undefined;
56
- }
57
-
58
- function splitLeadingSemanticControls(line: string): {
59
- controls: string;
60
- content: string;
61
- } {
62
- let offset = 0;
63
- while (offset < line.length) {
64
- const end = semanticControlEnd(line, offset);
65
- if (end === undefined) break;
66
- offset = end;
67
- }
68
- return { controls: line.slice(0, offset), content: line.slice(offset) };
69
- }
70
-
71
- function fitLine(line: string, width: number): string {
72
- if (width <= 0) return "";
73
- const clipped = truncateToWidth(line, width, "");
74
- return clipped + " ".repeat(Math.max(0, width - visibleWidth(clipped)));
75
- }
76
-
77
- function reapplyBackgroundAfterReset(
78
- text: string,
79
- backgroundAnsi: string,
80
- ): string {
81
- return text.replace(SGR_PATTERN, (sequence, rawParameters: string) => {
82
- const parameters =
83
- rawParameters === "" ? [0] : rawParameters.split(";").map(Number);
84
- return parameters.includes(0) || parameters.includes(49)
85
- ? `${sequence}${backgroundAnsi}`
86
- : sequence;
87
- });
88
- }
89
-
90
- function paintBackground(
91
- line: string,
92
- width: number,
93
- backgroundAnsi: string,
94
- ): string {
95
- const fitted = fitLine(line, width);
96
- // Mirrors Theme.bg: background, content, then a background-only reset.
97
- return `${backgroundAnsi}${reapplyBackgroundAfterReset(fitted, backgroundAnsi)}\x1b[49m`;
98
- }
99
-
100
- function stripDisplayAnsi(text: string): string {
101
- return text.replace(DISPLAY_ANSI_PATTERN, "");
102
- }
103
-
104
- function replaceBackground(text: string, backgroundAnsi: string): string {
105
- return text.replace(BACKGROUND_PATTERN, backgroundAnsi);
106
- }
107
-
108
- // Renders lines inside a railed, dark-surfaced block (the submitted-prompt
109
- // shell): optional leading semantic controls stay outside the inset, the rail
110
- // is recolored, and content backgrounds are normalized to the prompt surface.
111
- function renderRailedBlockLines(
112
- lines: string[],
113
- width: number,
114
- theme: ThemeLike,
115
- inset: number,
116
- railColor: string,
117
- ): string[] {
118
- const blockWidth = width - inset * 2;
119
- if (inset === 0 || blockWidth <= visibleWidth(PROMPT_RAIL)) {
120
- return lines.map((line) => fitLine(line, width));
121
- }
122
- const bodyWidth = blockWidth - visibleWidth(PROMPT_RAIL);
123
- const margin = " ".repeat(inset);
124
- const surfaceBg = promptSurfaceBg(theme);
125
- return lines.map((line) => {
126
- const { controls, content } = splitLeadingSemanticControls(line);
127
- const recolored = replaceBackground(content, surfaceBg);
128
- const rail = theme.fg(railColor, PROMPT_RAIL);
129
- // One extra leading space inside the body, so submitted text sits two
130
- // columns right of the rail instead of one.
131
- const body = paintBackground(
132
- ` ${fitLine(recolored, Math.max(1, bodyWidth - 1))}`,
133
- bodyWidth,
134
- surfaceBg,
135
- );
136
- return `${controls}${margin}${rail}${body}${margin}`;
137
- });
138
- }
139
-
140
19
  const RULE_LINE_RE = /^─+$/;
141
20
 
142
21
  function isRuleLine(line: string | undefined): line is string {
@@ -179,7 +58,7 @@ function renderBashBlockLines(
179
58
  width: number,
180
59
  theme: ThemeLike,
181
60
  status: string | undefined,
182
- inset = bashBlockInset(width),
61
+ inset = promptBlockInset(width),
183
62
  ): string[] {
184
63
  const [spacer, ...rest] = lines;
185
64
  const topRule = isRuleLine(rest[0]) ? rest[0] : undefined;
@@ -221,7 +100,6 @@ type BashBlockPatchState = {
221
100
  // TODO: Replace prototype patching with a public Pi rendering API when available.
222
101
  function installBashBlockPatch(): BashBlockPatchState | undefined {
223
102
  try {
224
- console.error("BB new state");
225
103
  const proto = BashExecutionComponent?.prototype as unknown as BashRow & {
226
104
  [BASH_BLOCK_PATCHED]?: BashBlockPatchState;
227
105
  render?: (width: number) => string[];
@@ -231,7 +109,6 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
231
109
  const existing = proto[BASH_BLOCK_PATCHED];
232
110
  if (existing) {
233
111
  existing.owners++;
234
- console.error("BB reuse owners", existing.owners);
235
112
  existing.disabled = false;
236
113
  return existing;
237
114
  }
@@ -249,7 +126,7 @@ function installBashBlockPatch(): BashBlockPatchState | undefined {
249
126
  if (state.disabled || !theme) {
250
127
  return state.originalRender.call(this, width);
251
128
  }
252
- const inset = bashBlockInset(width);
129
+ const inset = promptBlockInset(width);
253
130
  const bodyWidth = width - inset * 2 - visibleWidth(PROMPT_RAIL);
254
131
  if (inset === 0 || bodyWidth < 8) {
255
132
  return state.originalRender.call(this, width);
package/src/index.ts CHANGED
@@ -20,6 +20,8 @@ import {
20
20
  uninstallInfoVisibility,
21
21
  } from "./info-visibility.ts";
22
22
  import { fgCollapsed, fgCollapsedRail, paletteSample } from "./muted.ts";
23
+ import { isQuestionToolCall, renderQuestionBlock } from "./question-block.ts";
24
+ import { sanitizeInline } from "./sanitize.ts";
23
25
 
24
26
  const OUTER_INSET = 2;
25
27
  const SUBAGENT_MARKER = "↪";
@@ -160,24 +162,6 @@ function stripAnsi(text: string): string {
160
162
  return text.replace(ANSI_RE, "");
161
163
  }
162
164
 
163
- // Command output can carry cursor-moving control bytes, raw terminal
164
- // sequences, line feeds, and tabs. Collapsed rows are single-line, so result
165
- // and scraped text is stripped of display sequences and all C0 controls before
166
- // it can reach a row; otherwise control bytes could split or overwrite it.
167
- const INLINE_CONTROL_RE = /[\x00-\x1f\x7f]/g;
168
- // OSC first: the two-byte alternative would otherwise consume `\x1b]` and
169
- // leave the hyperlink payload behind as visible text. OSC sequences (OSC 8
170
- // hyperlinks, titles) end at their first BEL/ST terminator; a payload match
171
- // must stop there too, because the visible text sits BETWEEN two OSC
172
- // sequences — Pi wraps read paths as `ESC]8;;url ESC\ <path> ESC]8;; ESC\`,
173
- // and a greedy `[^\x07]*` would swallow that path along with the sequences.
174
- const DISPLAY_ANSI_RE =
175
- /\x1b(?:\][^\x07\x1b]*(?:\x07|\x1b\\)|\[[0-?]*[ -/]*[@-~]|[@-Z\\-_])/g;
176
-
177
- function sanitizeInline(text: string): string {
178
- return text.replace(DISPLAY_ANSI_RE, "").replace(INLINE_CONTROL_RE, " ");
179
- }
180
-
181
165
  function hasVisibleContent(line: string): boolean {
182
166
  return stripAnsi(line).trim().length > 0;
183
167
  }
@@ -569,6 +553,7 @@ function isGroupableToolRow(
569
553
  state: PresentationPatchState,
570
554
  ): boolean {
571
555
  if (row.toolName === "subagent") return false;
556
+ if (isQuestionToolCall(row)) return false;
572
557
  if (drawsNoRowLines(row, renderAt(index))) return false;
573
558
  if (
574
559
  !isLiveRow(row) &&
@@ -1246,6 +1231,10 @@ function renderCollapsedToolRow(
1246
1231
  width: number,
1247
1232
  theme: ThemeLike,
1248
1233
  ): string[] {
1234
+ // An asked question is a user-input moment, not an execution row: it takes
1235
+ // the submitted-prompt shell, which carries its own inset.
1236
+ const questionBlock = renderQuestionBlock(row, width, theme);
1237
+ if (questionBlock) return ["", ...questionBlock];
1249
1238
  const layout = insetLayout(width);
1250
1239
  const plan =
1251
1240
  row.toolName === "subagent"
package/src/muted.ts CHANGED
@@ -42,6 +42,12 @@ const PALETTE_TOKENS = [
42
42
  "accent",
43
43
  "warning",
44
44
  "error",
45
+ // Asked-question blocks paint with the prompt-shell tokens: the rail, the
46
+ // question text, and the surface behind both.
47
+ "borderAccent",
48
+ "text",
49
+ "userMessageBg",
50
+ "userMessageText",
45
51
  ] as const;
46
52
 
47
53
  // Revision key for rendered output that bakes resolved colors in. Compares
@@ -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,339 @@
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
+ // A painted blank body row; the shell gives it the prompt surface.
46
+ | { kind: "spacer" };
47
+
48
+ // rpiv's canonical decline text, reused verbatim so the transcript says what
49
+ // the model was told.
50
+ const DECLINE_TEXT = "User declined to answer questions";
51
+ const AWAITING_TEXT = "awaiting your answer…";
52
+ const NO_ANSWER_TEXT = "(no answer)";
53
+ const QUESTION_PREFIX = "> ";
54
+ const ANSWER_PREFIX = "User: ";
55
+
56
+ // Question tools keep the ask-tool vocabulary in their name even when they
57
+ // invent their own result shape. The result-shape check is the primary signal;
58
+ // this pattern is the fallback for calls that never reached an answer.
59
+ const QUESTION_TOOL_NAME_RE =
60
+ /(^|_)(ask_?user_?question|ask_?user|user_?question|questionnaire)($|_)/;
61
+
62
+ function isRecord(value: unknown): value is Record<string, unknown> {
63
+ return Boolean(value && typeof value === "object" && !Array.isArray(value));
64
+ }
65
+
66
+ function normalizeToolName(name: string): string {
67
+ return name
68
+ .toLowerCase()
69
+ .replace(/[^a-z0-9]+/g, "_")
70
+ .replace(/^_+|_+$/g, "");
71
+ }
72
+
73
+ function isQuestionToolName(name: string | undefined): boolean {
74
+ if (!name) return false;
75
+ return QUESTION_TOOL_NAME_RE.test(normalizeToolName(name));
76
+ }
77
+
78
+ // The rpiv result contract: `details.answers` is always an array of entries
79
+ // carrying the question text, and each entry's answer arrives as `answer`
80
+ // (option label or typed text) or `selected` (multi-select labels). Any
81
+ // deviation fails the shape check so an unrelated tool is never reshaped.
82
+ function parseAnswerEntries(details: unknown): AnswerEntry[] | undefined {
83
+ if (!isRecord(details)) return undefined;
84
+ const raw = details.answers;
85
+ if (!Array.isArray(raw)) return undefined;
86
+ const entries: AnswerEntry[] = [];
87
+ for (const item of raw) {
88
+ if (!isRecord(item) || typeof item.question !== "string") return undefined;
89
+ const answer = item.answer;
90
+ if (answer !== undefined && answer !== null && typeof answer !== "string") {
91
+ return undefined;
92
+ }
93
+ const selected = item.selected;
94
+ if (
95
+ selected !== undefined &&
96
+ !(
97
+ Array.isArray(selected) &&
98
+ selected.every((label) => typeof label === "string")
99
+ )
100
+ ) {
101
+ return undefined;
102
+ }
103
+ entries.push({
104
+ question: item.question,
105
+ questionIndex:
106
+ typeof item.questionIndex === "number" ? item.questionIndex : undefined,
107
+ kind: typeof item.kind === "string" ? item.kind : undefined,
108
+ answer,
109
+ selected: selected as string[] | undefined,
110
+ });
111
+ }
112
+ return entries;
113
+ }
114
+
115
+ function hasQuestionDetails(details: unknown): boolean {
116
+ const entries = parseAnswerEntries(details);
117
+ if (entries === undefined) return false;
118
+ if (entries.length > 0) return true;
119
+ if (!isRecord(details)) return false;
120
+ // An empty `answers` array only means a questionnaire when the details also
121
+ // carry the outcome that emptied it: a decline, or a validation failure.
122
+ return details.cancelled === true || typeof details.error === "string";
123
+ }
124
+
125
+ function parseAskedQuestions(args: unknown): string[] | undefined {
126
+ if (!isRecord(args) || !Array.isArray(args.questions)) return undefined;
127
+ const questions: string[] = [];
128
+ for (const item of args.questions) {
129
+ if (!isRecord(item) || typeof item.question !== "string") return undefined;
130
+ questions.push(item.question);
131
+ }
132
+ return questions.length > 0 ? questions : undefined;
133
+ }
134
+
135
+ function answerText(entry: AnswerEntry | undefined): string | undefined {
136
+ if (!entry) return undefined;
137
+ if (entry.kind === "multi" || entry.selected !== undefined) {
138
+ return entry.selected && entry.selected.length > 0
139
+ ? entry.selected.join(", ")
140
+ : undefined;
141
+ }
142
+ return entry.answer && entry.answer.length > 0 ? entry.answer : undefined;
143
+ }
144
+
145
+ function entryForIndex(
146
+ entries: AnswerEntry[],
147
+ index: number,
148
+ questionCount: number,
149
+ ): AnswerEntry | undefined {
150
+ const indexed = entries.find((entry) => entry.questionIndex === index);
151
+ if (indexed) return indexed;
152
+ // Entries without a `questionIndex` still arrive in ask order.
153
+ return entries.some((entry) => entry.questionIndex !== undefined) ||
154
+ entries.length !== questionCount
155
+ ? undefined
156
+ : entries[index];
157
+ }
158
+
159
+ function questionLines(
160
+ row: QuestionRow,
161
+ details: unknown,
162
+ ): QuestionLine[] | undefined {
163
+ const entries = parseAnswerEntries(details) ?? [];
164
+ const asked = parseAskedQuestions(row.args);
165
+ const pairs: Array<{ question: string; answer: string | undefined }> = [];
166
+ if (asked) {
167
+ asked.forEach((question, index) => {
168
+ const entry = entryForIndex(entries, index, asked.length);
169
+ pairs.push({ question, answer: answerText(entry) });
170
+ });
171
+ } else {
172
+ for (const entry of entries) {
173
+ pairs.push({ question: entry.question, answer: answerText(entry) });
174
+ }
175
+ }
176
+ if (pairs.length === 0) return undefined;
177
+
178
+ const pending = row.isPartial === true;
179
+ const declined =
180
+ !pending &&
181
+ isRecord(details) &&
182
+ details.cancelled === true &&
183
+ pairs.every((pair) => pair.answer === undefined);
184
+
185
+ // One group per question. Groups are separated by a painted blank row so a
186
+ // multi-question ask stays scannable.
187
+ const groups: QuestionLine[][] = pairs.map((pair) => {
188
+ const group: QuestionLine[] = [{ kind: "question", text: pair.question }];
189
+ if (!pending && !declined) {
190
+ group.push({ kind: "answer", text: pair.answer ?? NO_ANSWER_TEXT });
191
+ }
192
+ return group;
193
+ });
194
+ if (pending || declined) {
195
+ const status: QuestionLine = pending
196
+ ? { kind: "status", text: AWAITING_TEXT, tone: "warning" }
197
+ : { kind: "status", text: DECLINE_TEXT, tone: "muted" };
198
+ // A lone question keeps its outcome on the following line; a
199
+ // multi-question ask separates the global outcome so it cannot read as
200
+ // answering only the last question.
201
+ if (groups.length > 1) groups.push([status]);
202
+ else groups[0]!.push(status);
203
+ }
204
+
205
+ const lines: QuestionLine[] = [];
206
+ groups.forEach((group, index) => {
207
+ if (index > 0) lines.push({ kind: "spacer" });
208
+ lines.push(...group);
209
+ });
210
+ return lines;
211
+ }
212
+
213
+ function fg(theme: QuestionTheme, token: string, text: string): string {
214
+ if (text === "") return "";
215
+ try {
216
+ return theme.fg(token, text);
217
+ } catch {
218
+ return text;
219
+ }
220
+ }
221
+
222
+ // The question is the model's text read inside a user-input shell, so it takes
223
+ // the quotation's italics; the answer is the user's own words and stays upright.
224
+ // A theme without italics simply renders the question plainly.
225
+ function italics(theme: QuestionTheme, text: string): string {
226
+ if (text === "" || typeof theme.italic !== "function") return text;
227
+ try {
228
+ return theme.italic(text);
229
+ } catch {
230
+ return text;
231
+ }
232
+ }
233
+
234
+ // Plain-text word wrap with a hanging indent, so a wrapped question stays
235
+ // visually attached to its `> ` prefix. Wrapping before styling keeps the
236
+ // width math exact and lets the shell's own fit pass stay a no-op.
237
+ function wrapPlain(text: string, width: number): string[] {
238
+ const max = Math.max(1, width);
239
+ const words = text.replace(/\s+/g, " ").trim().split(" ").filter(Boolean);
240
+ if (words.length === 0) return [""];
241
+
242
+ const lines: string[] = [];
243
+ let current = "";
244
+ const push = (word: string) => {
245
+ const candidate = current === "" ? word : `${current} ${word}`;
246
+ if (visibleWidth(candidate) <= max) {
247
+ current = candidate;
248
+ return;
249
+ }
250
+ if (current !== "") lines.push(current);
251
+ // A single token wider than the line is hard-split rather than truncated.
252
+ let rest = word;
253
+ while (visibleWidth(rest) > max) {
254
+ let cut = rest.length;
255
+ while (cut > 1 && visibleWidth(rest.slice(0, cut)) > max) cut--;
256
+ lines.push(rest.slice(0, cut));
257
+ rest = rest.slice(cut);
258
+ }
259
+ current = rest;
260
+ };
261
+ for (const word of words) push(word);
262
+ if (current !== "") lines.push(current);
263
+ return lines;
264
+ }
265
+
266
+ function styleLine(
267
+ line: QuestionLine,
268
+ width: number,
269
+ theme: QuestionTheme,
270
+ ): string[] {
271
+ if (line.kind === "spacer") return [""];
272
+ const prefix =
273
+ line.kind === "question"
274
+ ? QUESTION_PREFIX
275
+ : line.kind === "answer"
276
+ ? ANSWER_PREFIX
277
+ : "";
278
+ const prefixToken = line.kind === "question" ? "borderAccent" : "muted";
279
+ const textToken =
280
+ line.kind === "question"
281
+ ? "text"
282
+ : line.kind === "answer"
283
+ ? "userMessageText"
284
+ : line.tone;
285
+ const hang = " ".repeat(visibleWidth(prefix));
286
+ const body = wrapPlain(line.text, Math.max(1, width - visibleWidth(prefix)));
287
+ return body.map(
288
+ (piece, index) =>
289
+ fg(theme, prefixToken, index === 0 ? prefix : hang) +
290
+ (line.kind === "question"
291
+ ? italics(theme, fg(theme, textToken, piece))
292
+ : fg(theme, textToken, piece)),
293
+ );
294
+ }
295
+
296
+ /**
297
+ * True when the row should read as an asked question rather than an execution
298
+ * row. Shape first, so another author's question tool works unedited; the tool
299
+ * name is the fallback for calls that failed before answering.
300
+ */
301
+ export function isQuestionToolCall(row: QuestionRow): boolean {
302
+ if (hasQuestionDetails(row.result?.details)) return true;
303
+ return isQuestionToolName(row.toolName);
304
+ }
305
+
306
+ /**
307
+ * Renders the question/answer block in the submitted-prompt shell, or
308
+ * `undefined` when the row should keep the generic collapsed presentation.
309
+ * A failed questionnaire is one such case: its details carry an error code
310
+ * rather than a state worth a block, and the generic failure row already
311
+ * reports it in the error tone.
312
+ */
313
+ export function renderQuestionBlock(
314
+ row: QuestionRow,
315
+ width: number,
316
+ theme: QuestionTheme,
317
+ ): string[] | undefined {
318
+ const details = row.result?.details;
319
+ if (!isQuestionToolCall(row)) return undefined;
320
+ if (isRecord(details) && typeof details.error === "string") return undefined;
321
+
322
+ const lines = questionLines(row, details);
323
+ if (!lines) return undefined;
324
+
325
+ const inset = promptBlockInset(width);
326
+ const padding = SUBMITTED_PROMPT_PADDING;
327
+ const contentWidth = promptBlockContentWidth(width, inset, padding);
328
+ const styled = lines.flatMap((line) => styleLine(line, contentWidth, theme));
329
+ // The blank rows top and bottom are the shell's background padding, matching
330
+ // a submitted user message; they become fully painted body rows.
331
+ return renderRailedBlockLines(
332
+ ["", ...styled, ""],
333
+ width,
334
+ theme,
335
+ inset,
336
+ "borderAccent",
337
+ padding,
338
+ );
339
+ }
@@ -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
+ }