@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.
- package/CHANGELOG.md +80 -0
- package/README.md +35 -8
- package/package.json +1 -1
- package/src/bash-block.ts +33 -137
- package/src/container-hooks.ts +6 -0
- package/src/index.ts +108 -164
- package/src/info-visibility-state.ts +16 -2
- package/src/info-visibility.ts +15 -3
- package/src/muted.ts +31 -16
- package/src/prompt-shell.ts +174 -0
- package/src/question-block.ts +328 -0
- package/src/sanitize.ts +20 -0
- package/src/thinking-block-merger.ts +141 -64
|
@@ -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
|
+
}
|
package/src/sanitize.ts
ADDED
|
@@ -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
|
+
}
|