droid-style 1.0.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/README.md ADDED
@@ -0,0 +1,34 @@
1
+ # Droid-style extension
2
+
3
+ A "droid" look for pi:
4
+
5
+ - a boxed input editor
6
+ - custom tool-call badges for the built-in tools
7
+ - compact tool-call spacing (one blank line between consecutive calls)
8
+
9
+ ## Features
10
+
11
+ - Closed rectangular input box (Unicode box drawing)
12
+ - Light gray input border (`#c0c0c0`)
13
+ - Prompt styling:
14
+ - `>` uses the current theme's accent color
15
+ - `!` / `!!` (bash modes) use a bright green prompt
16
+ - Slash-command autocomplete dropdown is rendered in a bordered droid-style panel
17
+ - selected row uses droid orange
18
+ - footer hint shows navigation keys and visible range
19
+ - Droid-style tool-call badges for: `read`, `write`, `edit`, `ls`, `find`, `grep`, `bash` (badge bg: `#feb17f`)
20
+ - Assistant responses are prefixed with `•` in `#a35626`
21
+ - Auto-activates the `droid` theme on session start
22
+
23
+ ## Installation
24
+
25
+ 1. Copy to `~/.pi/agent/extensions/droid-style/`
26
+ 2. Reload extensions (`/reload`) or restart pi
27
+
28
+ The `droid` theme is bundled in this extension (`themes/droid.json`) and is discovered automatically.
29
+
30
+ ## Notes
31
+
32
+ - Tool badges are implemented by **overriding** the built-in tools via `pi.registerTool()` (last registration wins).
33
+ - Assistant/user message styling uses prototype patching to inject `•` / `›` prefixes.
34
+ - Tool block spacing is compacted via prototype patching of `ToolExecutionComponent`.
package/ansi.ts ADDED
@@ -0,0 +1,90 @@
1
+ // Shared ANSI helpers for the droid-style extension
2
+
3
+ // Strip ANSI escape codes
4
+ export function stripAnsi(str: string): string {
5
+ return str
6
+ .replace(/\x1b\[[0-9;]*[a-zA-Z]/g, "")
7
+ .replace(/\x1b\]8;;[^\x07]*\x07/g, "")
8
+ .replace(/\x1b_[^\x07\x1b]*(?:\x07|\x1b\\)/g, "");
9
+ }
10
+
11
+ // ------------------------------------------------------------
12
+ // Color helpers (truecolor + 256color fallback)
13
+ // ------------------------------------------------------------
14
+
15
+ function hexToRgb(hex: string): { r: number; g: number; b: number } {
16
+ const cleaned = hex.replace("#", "");
17
+ const r = Number.parseInt(cleaned.slice(0, 2), 16);
18
+ const g = Number.parseInt(cleaned.slice(2, 4), 16);
19
+ const b = Number.parseInt(cleaned.slice(4, 6), 16);
20
+ return { r, g, b };
21
+ }
22
+
23
+ const CUBE_VALUES = [0, 95, 135, 175, 215, 255];
24
+ const GRAY_VALUES = Array.from({ length: 24 }, (_, i) => 8 + i * 10);
25
+
26
+ function colorDistance(r1: number, g1: number, b1: number, r2: number, g2: number, b2: number): number {
27
+ const dr = r1 - r2;
28
+ const dg = g1 - g2;
29
+ const db = b1 - b2;
30
+ return dr * dr * 0.299 + dg * dg * 0.587 + db * db * 0.114;
31
+ }
32
+
33
+ function findClosestCubeIndex(value: number): number {
34
+ let minDist = Number.POSITIVE_INFINITY;
35
+ let minIdx = 0;
36
+ for (let i = 0; i < CUBE_VALUES.length; i++) {
37
+ const dist = Math.abs(value - CUBE_VALUES[i]!);
38
+ if (dist < minDist) {
39
+ minDist = dist;
40
+ minIdx = i;
41
+ }
42
+ }
43
+ return minIdx;
44
+ }
45
+
46
+ function findClosestGrayIndex(gray: number): number {
47
+ let minDist = Number.POSITIVE_INFINITY;
48
+ let minIdx = 0;
49
+ for (let i = 0; i < GRAY_VALUES.length; i++) {
50
+ const dist = Math.abs(gray - GRAY_VALUES[i]!);
51
+ if (dist < minDist) {
52
+ minDist = dist;
53
+ minIdx = i;
54
+ }
55
+ }
56
+ return minIdx;
57
+ }
58
+
59
+ function rgbTo256(r: number, g: number, b: number): number {
60
+ const rIdx = findClosestCubeIndex(r);
61
+ const gIdx = findClosestCubeIndex(g);
62
+ const bIdx = findClosestCubeIndex(b);
63
+ const cubeR = CUBE_VALUES[rIdx]!;
64
+ const cubeG = CUBE_VALUES[gIdx]!;
65
+ const cubeB = CUBE_VALUES[bIdx]!;
66
+ const cubeIndex = 16 + 36 * rIdx + 6 * gIdx + bIdx;
67
+ const cubeDist = colorDistance(r, g, b, cubeR, cubeG, cubeB);
68
+
69
+ const gray = Math.round(0.299 * r + 0.587 * g + 0.114 * b);
70
+ const grayIdx = findClosestGrayIndex(gray);
71
+ const grayValue = GRAY_VALUES[grayIdx]!;
72
+ const grayIndex = 232 + grayIdx;
73
+ const grayDist = colorDistance(r, g, b, grayValue, grayValue, grayValue);
74
+
75
+ const spread = Math.max(r, g, b) - Math.min(r, g, b);
76
+ if (spread < 10 && grayDist < cubeDist) {
77
+ return grayIndex;
78
+ }
79
+ return cubeIndex;
80
+ }
81
+
82
+ export function fgHex(theme: any, hex: string, text: string): string {
83
+ const { r, g, b } = hexToRgb(hex);
84
+ const mode = typeof theme?.getColorMode === "function" ? theme.getColorMode() : "truecolor";
85
+ if (mode === "256color") {
86
+ const idx = rgbTo256(r, g, b);
87
+ return `\x1b[38;5;${idx}m${text}\x1b[39m`;
88
+ }
89
+ return `\x1b[38;2;${r};${g};${b}m${text}\x1b[39m`;
90
+ }
@@ -0,0 +1,262 @@
1
+ import { CustomEditor } from "@mariozechner/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
+
4
+ import { fgHex, stripAnsi } from "../ansi.js";
5
+
6
+ const INPUT_BORDER_COLOR = "#c0c0c0";
7
+ const BASH_PROMPT_COLOR = "#05ff03";
8
+
9
+ const SLASH_SELECTED_COLOR = "#d26825";
10
+ const SLASH_COMMAND_COLOR = "#e8dcc0";
11
+ const SLASH_DESCRIPTION_COLOR = "#8f8a85";
12
+ const SLASH_HINT_COLOR = "#8f8a85";
13
+
14
+ type SlashAutocompleteItem = {
15
+ value?: string;
16
+ label?: string;
17
+ description?: string;
18
+ };
19
+
20
+ type SlashAutocompleteModel = {
21
+ items: SlashAutocompleteItem[];
22
+ selectedIndex: number;
23
+ maxVisible: number;
24
+ showSlashPrefix: boolean;
25
+ };
26
+
27
+ function isBorderLine(line: string): boolean {
28
+ const clean = stripAnsi(line).replace(/\s/g, "");
29
+ return clean.replace(/─/g, "") === "";
30
+ }
31
+
32
+ function findLastBorderIndex(lines: string[]): number {
33
+ for (let i = lines.length - 1; i >= 0; i--) {
34
+ if (isBorderLine(lines[i] ?? "")) return i;
35
+ }
36
+ return -1;
37
+ }
38
+
39
+ function stripBashPrefix(line: string): string {
40
+ if (line.startsWith("!!")) return line.slice(2);
41
+ if (line.startsWith("!")) return line.slice(1);
42
+ return line;
43
+ }
44
+
45
+ function normalizeSingleLine(text: string): string {
46
+ return text.replace(/[\r\n]+/g, " ").trim();
47
+ }
48
+
49
+ function clamp(value: number, min: number, max: number): number {
50
+ return Math.max(min, Math.min(max, value));
51
+ }
52
+
53
+ export class BoxEditor extends CustomEditor {
54
+ constructor(
55
+ tui: any,
56
+ theme: any,
57
+ kb: any,
58
+ private readonly fullTheme: any,
59
+ ) {
60
+ super(tui, theme, kb);
61
+ }
62
+
63
+ private color(hex: string, text: string): string {
64
+ return this.fullTheme ? fgHex(this.fullTheme, hex, text) : text;
65
+ }
66
+
67
+ private getSlashAutocompleteModel(): SlashAutocompleteModel | null {
68
+ const editorState = (this as any)?.state as
69
+ | {
70
+ lines?: string[];
71
+ cursorLine?: number;
72
+ cursorCol?: number;
73
+ }
74
+ | undefined;
75
+ if (!editorState || !Array.isArray(editorState.lines)) return null;
76
+
77
+ const cursorLine = typeof editorState.cursorLine === "number" ? editorState.cursorLine : 0;
78
+ const cursorCol = typeof editorState.cursorCol === "number" ? editorState.cursorCol : 0;
79
+ const currentLine = editorState.lines[cursorLine] ?? "";
80
+ const textBeforeCursor = currentLine.slice(0, Math.max(0, cursorCol));
81
+
82
+ const trimmedBeforeCursor = textBeforeCursor.trimStart();
83
+ if (cursorLine !== 0 || !trimmedBeforeCursor.startsWith("/")) return null;
84
+
85
+ const autocompleteState = (this as any)?.autocompleteState;
86
+ const autocompleteList = (this as any)?.autocompleteList as
87
+ | {
88
+ filteredItems?: SlashAutocompleteItem[];
89
+ selectedIndex?: number;
90
+ maxVisible?: number;
91
+ }
92
+ | undefined;
93
+
94
+ if (!autocompleteState || !autocompleteList) return null;
95
+
96
+ const items = Array.isArray(autocompleteList.filteredItems) ? autocompleteList.filteredItems : [];
97
+ const selectedIndex = clamp(
98
+ typeof autocompleteList.selectedIndex === "number" ? autocompleteList.selectedIndex : 0,
99
+ 0,
100
+ Math.max(0, items.length - 1),
101
+ );
102
+ const maxVisible = clamp(
103
+ typeof autocompleteList.maxVisible === "number" ? autocompleteList.maxVisible : 6,
104
+ 1,
105
+ 20,
106
+ );
107
+
108
+ return {
109
+ items,
110
+ selectedIndex,
111
+ maxVisible,
112
+ showSlashPrefix: !trimmedBeforeCursor.includes(" "),
113
+ };
114
+ }
115
+
116
+ private formatSlashAutocompleteRow(
117
+ item: SlashAutocompleteItem,
118
+ isSelected: boolean,
119
+ width: number,
120
+ showSlashPrefix: boolean,
121
+ ): string {
122
+ const rawCommand = normalizeSingleLine(item.label || item.value || "");
123
+ const command =
124
+ showSlashPrefix && rawCommand.length > 0 && !rawCommand.startsWith("/") ? `/${rawCommand}` : rawCommand;
125
+ const description = typeof item.description === "string" ? normalizeSingleLine(item.description) : "";
126
+ const prefix = isSelected ? "> " : " ";
127
+ const prefixWidth = visibleWidth(prefix);
128
+
129
+ if (description && width > 40) {
130
+ const maxCommandWidth = Math.min(30, Math.max(8, width - prefixWidth - 10));
131
+ const commandText = truncateToWidth(command, maxCommandWidth, "");
132
+ const spacing = " ".repeat(Math.max(1, 32 - visibleWidth(commandText)));
133
+ const remaining = width - prefixWidth - visibleWidth(commandText) - visibleWidth(spacing);
134
+
135
+ if (remaining > 8) {
136
+ const descriptionText = truncateToWidth(description, remaining, "");
137
+ if (isSelected) {
138
+ return this.color(SLASH_SELECTED_COLOR, `${prefix}${commandText}${spacing}${descriptionText}`);
139
+ }
140
+ const commandColored = this.color(SLASH_COMMAND_COLOR, commandText);
141
+ const descriptionColored = this.color(SLASH_DESCRIPTION_COLOR, `${spacing}${descriptionText}`);
142
+ return `${prefix}${commandColored}${descriptionColored}`;
143
+ }
144
+ }
145
+
146
+ const commandOnly = truncateToWidth(command, Math.max(1, width - prefixWidth), "");
147
+ if (isSelected) return this.color(SLASH_SELECTED_COLOR, `${prefix}${commandOnly}`);
148
+ return `${prefix}${this.color(SLASH_COMMAND_COLOR, commandOnly)}`;
149
+ }
150
+
151
+ private renderSlashAutocomplete(width: number, border: (text: string) => string): string[] | null {
152
+ const model = this.getSlashAutocompleteModel();
153
+ if (!model) return null;
154
+
155
+ const totalItems = model.items.length;
156
+ const innerWidth = Math.max(1, width - 2);
157
+
158
+ const startIndex =
159
+ totalItems > 0
160
+ ? Math.max(
161
+ 0,
162
+ Math.min(
163
+ model.selectedIndex - Math.floor(model.maxVisible / 2),
164
+ Math.max(0, totalItems - model.maxVisible),
165
+ ),
166
+ )
167
+ : 0;
168
+ const endIndex = Math.min(startIndex + model.maxVisible, totalItems);
169
+ const visibleItems = model.items.slice(startIndex, endIndex);
170
+
171
+ const lines: string[] = [];
172
+ lines.push(" ".repeat(width));
173
+
174
+ lines.push(border(`╭${"─".repeat(innerWidth)}╮`));
175
+ if (visibleItems.length === 0) {
176
+ const noMatch = this.color(SLASH_DESCRIPTION_COLOR, " No matching commands");
177
+ const paddedNoMatch = `${noMatch}${" ".repeat(Math.max(0, innerWidth - visibleWidth(noMatch)))}`;
178
+ lines.push(`${border("│")}${paddedNoMatch}${border("│")}`);
179
+ } else {
180
+ for (let i = 0; i < visibleItems.length; i++) {
181
+ const item = visibleItems[i];
182
+ if (!item) continue;
183
+
184
+ const itemIndex = startIndex + i;
185
+ const row = this.formatSlashAutocompleteRow(
186
+ item,
187
+ itemIndex === model.selectedIndex,
188
+ innerWidth,
189
+ model.showSlashPrefix,
190
+ );
191
+ const paddedRow = `${row}${" ".repeat(Math.max(0, innerWidth - visibleWidth(row)))}`;
192
+ lines.push(`${border("│")}${paddedRow}${border("│")}`);
193
+ }
194
+ }
195
+ lines.push(border(`╰${"─".repeat(innerWidth)}╯`));
196
+
197
+ const shownStart = visibleItems.length > 0 ? startIndex + 1 : 0;
198
+ const shownEnd = startIndex + visibleItems.length;
199
+ const hint = ` Use ↑↓ to navigate, Tab/Enter to select, Esc to cancel • Showing ${shownStart}-${shownEnd} of ${totalItems}`;
200
+ const coloredHint = this.color(SLASH_HINT_COLOR, hint);
201
+ const truncatedHint = visibleWidth(coloredHint) > width ? truncateToWidth(coloredHint, width, "") : coloredHint;
202
+ lines.push(`${truncatedHint}${" ".repeat(Math.max(0, width - visibleWidth(truncatedHint)))}`);
203
+
204
+ return lines;
205
+ }
206
+
207
+ render(width: number): string[] {
208
+ const innerWidth = Math.max(1, width - 2);
209
+ const border = this.fullTheme
210
+ ? (text: string) => fgHex(this.fullTheme, INPUT_BORDER_COLOR, text)
211
+ : this.borderColor;
212
+
213
+ const text = this.getText();
214
+ const isBashMode = text.startsWith("!");
215
+ const isDoubleBang = text.startsWith("!!");
216
+
217
+ const promptChar = isDoubleBang ? "!!" : isBashMode ? "!" : ">";
218
+ const prompt = this.fullTheme
219
+ ? isBashMode
220
+ ? fgHex(this.fullTheme, BASH_PROMPT_COLOR, promptChar)
221
+ : this.fullTheme.fg("accent", ">")
222
+ : promptChar;
223
+ const promptPrefix = ` ${prompt} `;
224
+ const prefixWidth = visibleWidth(promptPrefix);
225
+ const contentWidth = Math.max(1, innerWidth - prefixWidth);
226
+
227
+ const parentLines = super.render(contentWidth);
228
+ if (parentLines.length === 0) return parentLines;
229
+
230
+ const bottomBorderIndex = findLastBorderIndex(parentLines);
231
+ const rawContentLines =
232
+ bottomBorderIndex > 0 ? parentLines.slice(1, bottomBorderIndex) : parentLines.slice(1);
233
+ const autocompleteLines = bottomBorderIndex >= 0 ? parentLines.slice(bottomBorderIndex + 1) : [];
234
+
235
+ const displayLines = rawContentLines.length > 0 ? [...rawContentLines] : [""];
236
+ if (isBashMode && displayLines[0]) {
237
+ displayLines[0] = stripBashPrefix(displayLines[0]);
238
+ }
239
+
240
+ const boxedLines = displayLines.map((line, index) => {
241
+ const prefix = index === 0 ? promptPrefix : " ".repeat(prefixWidth);
242
+ const lineWidth = visibleWidth(line);
243
+ const padding = " ".repeat(Math.max(0, contentWidth - lineWidth));
244
+ return `${border("│")}${prefix}${line}${padding}${border("│")}`;
245
+ });
246
+
247
+ const topBorder = border(`╭${"─".repeat(innerWidth)}╮`);
248
+ const bottomBorder = border(`╰${"─".repeat(innerWidth)}╯`);
249
+
250
+ const customSlashAutocomplete = this.renderSlashAutocomplete(width, border);
251
+ if (customSlashAutocomplete) {
252
+ return [topBorder, ...boxedLines, bottomBorder, ...customSlashAutocomplete];
253
+ }
254
+
255
+ const paddedAutocomplete = autocompleteLines.map((line) => {
256
+ const padding = " ".repeat(Math.max(0, width - visibleWidth(line)));
257
+ return `${line}${padding}`;
258
+ });
259
+
260
+ return [topBorder, ...boxedLines, bottomBorder, ...paddedAutocomplete];
261
+ }
262
+ }
package/index.ts ADDED
@@ -0,0 +1,40 @@
1
+ import { dirname, join } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import type { ExtensionAPI } from "@mariozechner/pi-coding-agent";
4
+
5
+ import { BoxEditor } from "./editor/box-editor.js";
6
+ import { installAssistantMessagePrefix } from "./messages/assistant-prefix.js";
7
+ import { installUserMessagePrefix } from "./messages/user-prefix.js";
8
+ import { installCompactToolSpacing } from "./tool-tags/compact-tool-spacing.js";
9
+ import { registerToolCallTags } from "./tool-tags/register-tool-call-tags.js";
10
+
11
+ const baseDir = dirname(fileURLToPath(import.meta.url));
12
+
13
+ export default function (pi: ExtensionAPI) {
14
+ installCompactToolSpacing();
15
+
16
+ pi.on("resources_discover", () => {
17
+ return {
18
+ themePaths: [join(baseDir, "themes", "droid.json")],
19
+ };
20
+ });
21
+
22
+ pi.on("session_start", (_event, ctx) => {
23
+ // Auto-activate droid theme. resources_discover runs after session_start,
24
+ // so retry on next tick if the first switch fails.
25
+ const tryApplyTheme = () => ctx.ui.setTheme("droid").success;
26
+ if (!tryApplyTheme()) {
27
+ setTimeout(() => {
28
+ tryApplyTheme();
29
+ }, 0);
30
+ }
31
+
32
+ registerToolCallTags(pi);
33
+ installAssistantMessagePrefix(ctx.ui.theme);
34
+ installUserMessagePrefix(ctx.ui.theme);
35
+
36
+ ctx.ui.setEditorComponent((tui, theme, kb) => {
37
+ return new BoxEditor(tui, theme, kb, ctx.ui.theme ?? theme);
38
+ });
39
+ });
40
+ }
@@ -0,0 +1,232 @@
1
+ import { AssistantMessageComponent } from "@mariozechner/pi-coding-agent";
2
+ import { truncateToWidth, visibleWidth } from "@mariozechner/pi-tui";
3
+
4
+ import { fgHex, stripAnsi } from "../ansi.js";
5
+
6
+ const ASSISTANT_PREFIX = "•";
7
+ const ASSISTANT_PREFIX_COLOR = "#feb17f";
8
+
9
+ let activeTheme: any = null;
10
+ let isPatched = false;
11
+
12
+ function buildPrefixSegment(): string {
13
+ return activeTheme ? fgHex(activeTheme, ASSISTANT_PREFIX_COLOR, ASSISTANT_PREFIX) : ASSISTANT_PREFIX;
14
+ }
15
+
16
+ function readAnsiToken(text: string, index: number): string | undefined {
17
+ if (text[index] !== "\x1b") return undefined;
18
+ const tail = text.slice(index);
19
+ // CSI sequences: \x1b[...m (colors, cursor, etc.)
20
+ const csi = tail.match(/^\x1b\[[0-9;?]*[ -/]*[@-~]/)?.[0];
21
+ if (csi) return csi;
22
+ // OSC sequences: \x1b]...\x07 (hyperlinks, window title, etc.)
23
+ const osc = tail.match(/^\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)/)?.[0];
24
+ return osc;
25
+ }
26
+
27
+ function dropLeadingColumns(line: string, columns: number): string {
28
+ if (columns <= 0 || line.length === 0) return line;
29
+
30
+ let i = 0;
31
+ let dropped = 0;
32
+ let leadingAnsi = "";
33
+
34
+ while (i < line.length && dropped < columns) {
35
+ const ansi = readAnsiToken(line, i);
36
+ if (ansi) {
37
+ leadingAnsi += ansi;
38
+ i += ansi.length;
39
+ continue;
40
+ }
41
+
42
+ const codePoint = line.codePointAt(i);
43
+ if (codePoint === undefined) break;
44
+ const charLen = codePoint > 0xffff ? 2 : 1;
45
+ const char = line.slice(i, i + charLen);
46
+ i += charLen;
47
+ dropped += Math.max(1, visibleWidth(char));
48
+ }
49
+
50
+ return `${leadingAnsi}${line.slice(i)}`;
51
+ }
52
+
53
+ function startsWithVisibleSpace(line: string): boolean {
54
+ if (!line) return false;
55
+
56
+ let i = 0;
57
+ while (i < line.length) {
58
+ const ansi = readAnsiToken(line, i);
59
+ if (ansi) {
60
+ i += ansi.length;
61
+ continue;
62
+ }
63
+
64
+ const codePoint = line.codePointAt(i);
65
+ if (codePoint === undefined) return false;
66
+ const charLen = codePoint > 0xffff ? 2 : 1;
67
+ const char = line.slice(i, i + charLen);
68
+ return char === " ";
69
+ }
70
+
71
+ return false;
72
+ }
73
+
74
+ function composePrefixedLine(line: string): string {
75
+ const prefix = buildPrefixSegment();
76
+ if (!line) return `${prefix} `;
77
+ return startsWithVisibleSpace(line) ? `${prefix}${line}` : `${prefix} ${line}`;
78
+ }
79
+
80
+ function isVisibleTextBlock(contentBlock: any): boolean {
81
+ return (
82
+ contentBlock?.type === "text" &&
83
+ typeof contentBlock.text === "string" &&
84
+ contentBlock.text.trim().length > 0
85
+ );
86
+ }
87
+
88
+ function isVisibleThinkingBlock(contentBlock: any): boolean {
89
+ return (
90
+ contentBlock?.type === "thinking" &&
91
+ typeof contentBlock.thinking === "string" &&
92
+ contentBlock.thinking.trim().length > 0
93
+ );
94
+ }
95
+
96
+ function hasVisibleAssistantContent(contentBlocks: any[]): boolean {
97
+ return contentBlocks.some((contentBlock) => isVisibleTextBlock(contentBlock) || isVisibleThinkingBlock(contentBlock));
98
+ }
99
+
100
+ function isToolCallOnlyAssistantMessage(message: any): boolean {
101
+ if (!message || !Array.isArray(message.content)) return false;
102
+ const contentBlocks = message.content as any[];
103
+ if (hasVisibleAssistantContent(contentBlocks)) return false;
104
+ return contentBlocks.some((contentBlock) => contentBlock?.type === "toolCall");
105
+ }
106
+
107
+ function prefixFirstNonEmptyLine(lines: string[], width: number): string[] {
108
+ if (width <= 0 || lines.length === 0) return lines;
109
+
110
+ const compactPrefixBase = composePrefixedLine("");
111
+ const compactPrefix =
112
+ visibleWidth(compactPrefixBase) > width ? truncateToWidth(compactPrefixBase, width, "") : compactPrefixBase;
113
+ const output = [...lines];
114
+
115
+ let targetIndex = -1;
116
+ for (let i = 0; i < output.length; i++) {
117
+ const clean = stripAnsi(output[i] ?? "");
118
+ if (clean.trim().length > 0) {
119
+ targetIndex = i;
120
+ break;
121
+ }
122
+ }
123
+
124
+ if (targetIndex === -1) return [compactPrefix];
125
+
126
+ const remainder = dropLeadingColumns(output[targetIndex] ?? "", 1); // drop 1-column left padding from Markdown/Text
127
+ output[targetIndex] = composePrefixedLine(remainder);
128
+
129
+ return output.map((renderedLine) =>
130
+ visibleWidth(renderedLine) > width ? truncateToWidth(renderedLine, width, "") : renderedLine,
131
+ );
132
+ }
133
+
134
+ export function installAssistantMessagePrefix(theme: any): void {
135
+ activeTheme = theme;
136
+ if (isPatched) return;
137
+ isPatched = true;
138
+
139
+ const baseUpdateContent = (AssistantMessageComponent.prototype as any).updateContent;
140
+ if (typeof baseUpdateContent === "function") {
141
+ (AssistantMessageComponent.prototype as any).updateContent = function patchedAssistantUpdateContent(message: any): void {
142
+ baseUpdateContent.call(this, message);
143
+
144
+ if (!message || !Array.isArray(message.content)) return;
145
+
146
+ const contentBlocks = message.content as Array<any>;
147
+ const firstTextIndex = contentBlocks.findIndex((contentBlock) => isVisibleTextBlock(contentBlock));
148
+ if (firstTextIndex === -1) return;
149
+
150
+ const hasThinkingBeforeText = contentBlocks
151
+ .slice(0, firstTextIndex)
152
+ .some((contentBlock) => isVisibleThinkingBlock(contentBlock));
153
+ if (!hasThinkingBeforeText) return;
154
+
155
+ const hasVisibleContent = contentBlocks.some(
156
+ (contentBlock) => isVisibleTextBlock(contentBlock) || isVisibleThinkingBlock(contentBlock),
157
+ );
158
+ let childIndex = hasVisibleContent ? 1 : 0; // leading Spacer(1)
159
+ let targetChild: any = undefined;
160
+
161
+ for (let i = 0; i < contentBlocks.length; i++) {
162
+ const contentBlock = contentBlocks[i];
163
+ if (isVisibleTextBlock(contentBlock)) {
164
+ if (i === firstTextIndex) {
165
+ targetChild = this?.contentContainer?.children?.[childIndex];
166
+ break;
167
+ }
168
+ childIndex += 1;
169
+ } else if (isVisibleThinkingBlock(contentBlock)) {
170
+ childIndex += 1; // thinking component
171
+ const hasVisibleContentAfter = contentBlocks
172
+ .slice(i + 1)
173
+ .some((nextBlock) => isVisibleTextBlock(nextBlock) || isVisibleThinkingBlock(nextBlock));
174
+ if (hasVisibleContentAfter) childIndex += 1; // inter-block Spacer(1)
175
+ }
176
+ }
177
+
178
+ if (!targetChild || typeof targetChild.render !== "function") return;
179
+
180
+ const childState = targetChild as any;
181
+ if (childState.__droidAssistantResponsePrefixPatched) return;
182
+ childState.__droidAssistantResponsePrefixPatched = true;
183
+
184
+ const baseChildRender = targetChild.render.bind(targetChild);
185
+ targetChild.render = (width: number): string[] => {
186
+ const lines = baseChildRender(width);
187
+ return prefixFirstNonEmptyLine(lines, width);
188
+ };
189
+ };
190
+ }
191
+
192
+ const baseRender = AssistantMessageComponent.prototype.render;
193
+
194
+ AssistantMessageComponent.prototype.render = function patchedAssistantMessageRender(width: number): string[] {
195
+ const lines = baseRender.call(this, width);
196
+ if (width <= 0) return lines;
197
+
198
+ const compactPrefixBase = composePrefixedLine("");
199
+ const compactPrefix =
200
+ visibleWidth(compactPrefixBase) > width ? truncateToWidth(compactPrefixBase, width, "") : compactPrefixBase;
201
+
202
+ if (lines.length === 0) {
203
+ // Render standalone prefix only for finalized tool-call-only assistant turns.
204
+ // During normal streaming startup (before thinking/text arrives), keep empty.
205
+ return isToolCallOnlyAssistantMessage((this as any)?.lastMessage) ? [compactPrefix] : lines;
206
+ }
207
+
208
+ const output = [...lines];
209
+ const startIndex = lines.length > 1 ? 1 : 0; // preserve leading spacer line
210
+
211
+ let targetIndex = -1;
212
+ for (let i = startIndex; i < output.length; i++) {
213
+ const clean = stripAnsi(output[i] ?? "");
214
+ if (clean.trim().length > 0) {
215
+ targetIndex = i;
216
+ break;
217
+ }
218
+ }
219
+
220
+ if (targetIndex === -1) {
221
+ return isToolCallOnlyAssistantMessage((this as any)?.lastMessage) ? [compactPrefix] : lines;
222
+ }
223
+
224
+ const line = output[targetIndex] ?? "";
225
+ const remainder = dropLeadingColumns(line, 1); // drop the 1-column padding, keep content
226
+ output[targetIndex] = composePrefixedLine(remainder);
227
+
228
+ return output.map((renderedLine) =>
229
+ visibleWidth(renderedLine) > width ? truncateToWidth(renderedLine, width, "") : renderedLine,
230
+ );
231
+ };
232
+ }