@quandev104/pi-style 0.1.3 → 0.1.5
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 +30 -0
- package/README.md +8 -4
- package/dist/extensions/pi-style.js +3777 -958
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +2 -0
- package/extension-src/pi-style/domain/config-normalization.ts +21 -5
- package/extension-src/pi-style/domain/config-presets.ts +1 -1
- package/extension-src/pi-style/domain/config-types.ts +7 -3
- package/extension-src/pi-style/domain/status.ts +1 -1
- package/extension-src/pi-style/domain/theme.ts +6 -1
- package/extension-src/pi-style/features/editor/index.ts +169 -27
- package/extension-src/pi-style/features/messages/index.ts +66 -0
- package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
- package/extension-src/pi-style/features/tools/boxed/bash.ts +430 -172
- package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
- package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
- package/extension-src/pi-style/features/tools/boxed/edit.ts +28 -2
- package/extension-src/pi-style/features/tools/boxed/fallback.ts +47 -4
- package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
- package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
- package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
- package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +25 -4
- package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
- package/extension-src/pi-style/features/tools/boxed/session-config.ts +64 -4
- package/extension-src/pi-style/features/tools/boxed/shared.ts +41 -1
- package/extension-src/pi-style/features/tools/boxed/write.ts +19 -2
- package/extension-src/pi-style/pi/compatibility-coordinator.ts +17 -0
- package/extension-src/pi-style/pi/compatibility-probe.ts +104 -10
- package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
- package/extension-src/pi-style/pi/index.ts +18 -1
- package/extension-src/pi-style/pi/session-coordinator.ts +33 -1
- package/extension-src/pi-style/shared/box.ts +86 -25
- package/extension-src/pi-style/shared/split-diff.ts +9 -9
- package/package.json +1 -1
- package/themes/titanium-light.json +82 -0
- package/themes/titanium.json +79 -0
- package/themes/.gitkeep +0 -0
|
@@ -56,6 +56,7 @@ const allowedPaths = new Set([
|
|
|
56
56
|
"tools.showElapsed",
|
|
57
57
|
"theme.nerdFonts",
|
|
58
58
|
"theme.terminalBackgroundSync",
|
|
59
|
+
"theme.autoApply",
|
|
59
60
|
"theme.colors",
|
|
60
61
|
"theme.glyphs",
|
|
61
62
|
"compatibility.allowSafePatches",
|
|
@@ -93,6 +94,7 @@ function validatePathValue(path: string, value: unknown): boolean {
|
|
|
93
94
|
if (path === "startup.mode") return typeof value === "string" && startupModes.includes(value);
|
|
94
95
|
if (path === "editor.style") return typeof value === "string" && styles.includes(value);
|
|
95
96
|
if (path === "editor.frame") return typeof value === "string" && frames.includes(value);
|
|
97
|
+
if (path === "theme.autoApply") return typeof value === "string" && value !== "";
|
|
96
98
|
if (["theme.nerdFonts", "theme.terminalBackgroundSync", "statusLine.separator", "tools.style"].includes(path))
|
|
97
99
|
return typeof value === "string";
|
|
98
100
|
if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
|
|
@@ -27,7 +27,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
|
|
|
27
27
|
bottomMargin: 1,
|
|
28
28
|
contextBarWidth: 10,
|
|
29
29
|
}),
|
|
30
|
-
editor: Object.freeze({ enabled: true, style: "
|
|
30
|
+
editor: Object.freeze({ enabled: true, style: "dock", frame: "rounded", showMetadata: false, hint: "" }),
|
|
31
31
|
messages: Object.freeze({ enabled: true, assistantPrefix: true, specialBlocks: true, hideThinkingLabel: true }),
|
|
32
32
|
tools: Object.freeze({
|
|
33
33
|
enabled: true,
|
|
@@ -37,7 +37,13 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
|
|
|
37
37
|
dimOutput: false,
|
|
38
38
|
showElapsed: true,
|
|
39
39
|
}),
|
|
40
|
-
theme: Object.freeze({
|
|
40
|
+
theme: Object.freeze({
|
|
41
|
+
nerdFonts: "auto",
|
|
42
|
+
terminalBackgroundSync: "auto",
|
|
43
|
+
autoApply: "titanium",
|
|
44
|
+
colors: {},
|
|
45
|
+
glyphs: {},
|
|
46
|
+
}),
|
|
41
47
|
compatibility: Object.freeze({
|
|
42
48
|
allowSafePatches: true,
|
|
43
49
|
allowCorePatches: false,
|
|
@@ -152,10 +158,11 @@ export function normalizeConfig(
|
|
|
152
158
|
style: stringEnum(editor.style, ["compact", "boxed", "dock", "native"], defaults.editor.style),
|
|
153
159
|
frame: stringEnum(
|
|
154
160
|
editor.frame,
|
|
155
|
-
["auto", "halfblock", "line", "solid", "outline", "native"],
|
|
161
|
+
["auto", "halfblock", "line", "solid", "outline", "rounded", "native"],
|
|
156
162
|
defaults.editor.frame,
|
|
157
163
|
),
|
|
158
164
|
showMetadata: bool(editor.showMetadata, defaults.editor.showMetadata),
|
|
165
|
+
hint: typeof editor.hint === "string" ? editor.hint : defaults.editor.hint,
|
|
159
166
|
}),
|
|
160
167
|
messages: Object.freeze({
|
|
161
168
|
enabled: bool(messages.enabled, defaults.messages.enabled),
|
|
@@ -178,6 +185,10 @@ export function normalizeConfig(
|
|
|
178
185
|
["auto", "on", "off"],
|
|
179
186
|
defaults.theme.terminalBackgroundSync,
|
|
180
187
|
),
|
|
188
|
+
autoApply:
|
|
189
|
+
typeof theme.autoApply === "string" && theme.autoApply.trim() !== ""
|
|
190
|
+
? theme.autoApply
|
|
191
|
+
: defaults.theme.autoApply,
|
|
181
192
|
colors: stringMap(theme.colors),
|
|
182
193
|
glyphs: stringMap(theme.glyphs),
|
|
183
194
|
}),
|
|
@@ -201,7 +212,7 @@ const ENUMS: Readonly<Record<string, readonly string[]>> = {
|
|
|
201
212
|
placement: ["above", "below"],
|
|
202
213
|
"startup.mode": ["off", "compact", "overlay"],
|
|
203
214
|
"editor.style": ["compact", "boxed", "dock", "native"],
|
|
204
|
-
"editor.frame": ["auto", "halfblock", "line", "solid", "outline", "native"],
|
|
215
|
+
"editor.frame": ["auto", "halfblock", "line", "solid", "outline", "rounded", "native"],
|
|
205
216
|
"theme.nerdFonts": ["auto", "on", "off"],
|
|
206
217
|
"theme.terminalBackgroundSync": ["auto", "on", "off"],
|
|
207
218
|
};
|
|
@@ -259,7 +270,9 @@ function validCustomItem(item: unknown): boolean {
|
|
|
259
270
|
function validLeaf(path: string, value: unknown): boolean {
|
|
260
271
|
if (BOOL_PATHS.has(path)) return typeof value === "boolean";
|
|
261
272
|
if (ENUMS[path]) return typeof value === "string" && ENUMS[path].includes(value);
|
|
262
|
-
if (path === "
|
|
273
|
+
if (path === "theme.autoApply") return typeof value === "string" && value !== "";
|
|
274
|
+
if (path === "statusLine.separator" || path === "tools.style" || path === "editor.hint")
|
|
275
|
+
return typeof value === "string";
|
|
263
276
|
if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
|
|
264
277
|
return typeof value === "number" && Number.isFinite(value) && value >= 0;
|
|
265
278
|
if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
|
|
@@ -482,6 +495,8 @@ export function resolveConfigDetailed(sources: ConfigSources): {
|
|
|
482
495
|
envPatch.theme = { nerdFonts: env.PI_STYLE_NERD_FONTS === "1" ? "on" : "off" };
|
|
483
496
|
if (env.PI_STYLE_EDITOR && ["native", "compact", "boxed", "dock"].includes(env.PI_STYLE_EDITOR))
|
|
484
497
|
envPatch.editor = { style: env.PI_STYLE_EDITOR };
|
|
498
|
+
if (env.PI_STYLE_THEME !== undefined && env.PI_STYLE_THEME !== "")
|
|
499
|
+
envPatch.theme = { ...(envPatch.theme ?? {}), autoApply: env.PI_STYLE_THEME };
|
|
485
500
|
if (env.PI_STYLE_OSC11 === "1" || env.PI_STYLE_OSC11 === "0")
|
|
486
501
|
envPatch.theme = { ...(envPatch.theme ?? {}), terminalBackgroundSync: env.PI_STYLE_OSC11 === "1" ? "on" : "off" };
|
|
487
502
|
if (env.PI_STYLE_DEBUG === "1") envPatch.debug = true;
|
|
@@ -526,6 +541,7 @@ export function resolveConfigDetailed(sources: ConfigSources): {
|
|
|
526
541
|
"PI_STYLE_OSC11",
|
|
527
542
|
"PI_STYLE_DEBUG",
|
|
528
543
|
"PI_STYLE_STATUS",
|
|
544
|
+
"PI_STYLE_THEME",
|
|
529
545
|
].includes(key)
|
|
530
546
|
)
|
|
531
547
|
diagnostics.push({
|
|
@@ -42,7 +42,7 @@ export const CONFIG_PRESETS: Readonly<Record<PresetName, Readonly<PiStyleConfig>
|
|
|
42
42
|
startup: { mode: "off" },
|
|
43
43
|
messages: { enabled: false },
|
|
44
44
|
tools: { enabled: false },
|
|
45
|
-
theme: { terminalBackgroundSync: "off" },
|
|
45
|
+
theme: { terminalBackgroundSync: "off", autoApply: "off" },
|
|
46
46
|
}),
|
|
47
47
|
});
|
|
48
48
|
|
|
@@ -5,7 +5,7 @@ export type NerdFontsMode = "auto" | "on" | "off";
|
|
|
5
5
|
export type ToggleMode = "auto" | "on" | "off";
|
|
6
6
|
export type PresetName = "default" | "minimal" | "compact" | "full" | "ascii" | "native";
|
|
7
7
|
export type EditorStyle = "compact" | "boxed" | "dock" | "native";
|
|
8
|
-
export type EditorFrame = "auto" | "halfblock" | "line" | "solid" | "outline" | "native";
|
|
8
|
+
export type EditorFrame = "auto" | "halfblock" | "line" | "solid" | "outline" | "rounded" | "native";
|
|
9
9
|
|
|
10
10
|
export interface StatusCustomItemConfig {
|
|
11
11
|
id: string;
|
|
@@ -31,7 +31,7 @@ export interface PiStyleConfig {
|
|
|
31
31
|
/** Context progress-bar cell count (default 10). */
|
|
32
32
|
contextBarWidth?: number;
|
|
33
33
|
};
|
|
34
|
-
editor?: { enabled?: boolean; style?: string; frame?: string; showMetadata?: boolean };
|
|
34
|
+
editor?: { enabled?: boolean; style?: string; frame?: string; showMetadata?: boolean; hint?: string };
|
|
35
35
|
messages?: { enabled?: boolean; assistantPrefix?: boolean; specialBlocks?: boolean; hideThinkingLabel?: boolean };
|
|
36
36
|
tools?: {
|
|
37
37
|
enabled?: boolean;
|
|
@@ -44,6 +44,8 @@ export interface PiStyleConfig {
|
|
|
44
44
|
theme?: {
|
|
45
45
|
nerdFonts?: string;
|
|
46
46
|
terminalBackgroundSync?: string;
|
|
47
|
+
/** Pi theme name auto-applied at TUI session start ("off" disables; default "titanium"). */
|
|
48
|
+
autoApply?: string;
|
|
47
49
|
colors?: Record<string, unknown>;
|
|
48
50
|
glyphs?: Record<string, unknown>;
|
|
49
51
|
};
|
|
@@ -78,7 +80,7 @@ export interface NormalizedPiStyleConfig {
|
|
|
78
80
|
/** Context progress-bar cell count. */
|
|
79
81
|
contextBarWidth: number;
|
|
80
82
|
};
|
|
81
|
-
readonly editor: { enabled: boolean; style: EditorStyle; frame: EditorFrame; showMetadata: boolean };
|
|
83
|
+
readonly editor: { enabled: boolean; style: EditorStyle; frame: EditorFrame; showMetadata: boolean; hint: string };
|
|
82
84
|
readonly messages: { enabled: boolean; assistantPrefix: boolean; specialBlocks: boolean; hideThinkingLabel: boolean };
|
|
83
85
|
readonly tools: {
|
|
84
86
|
enabled: boolean;
|
|
@@ -91,6 +93,8 @@ export interface NormalizedPiStyleConfig {
|
|
|
91
93
|
readonly theme: {
|
|
92
94
|
nerdFonts: NerdFontsMode;
|
|
93
95
|
terminalBackgroundSync: ToggleMode;
|
|
96
|
+
/** Pi theme name auto-applied at TUI session start; "off" disables the surface. */
|
|
97
|
+
autoApply: string;
|
|
94
98
|
colors: Readonly<Record<string, string>>;
|
|
95
99
|
glyphs: Readonly<Record<string, string>>;
|
|
96
100
|
};
|
|
@@ -254,7 +254,7 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
|
|
|
254
254
|
const token = contextBarToken(percent);
|
|
255
255
|
const window = snapshot.context?.windowTokens;
|
|
256
256
|
const label = `ctx${window !== undefined ? ` (${formatTokens(window)})` : ""}:`;
|
|
257
|
-
const width = (options
|
|
257
|
+
const width = (options.context_bar?.width as number | undefined) ?? CONTEXT_BAR_WIDTH;
|
|
258
258
|
return {
|
|
259
259
|
visible: true,
|
|
260
260
|
content: `${theme.apply("muted", label)} ${theme.apply(token, contextBar(percent, width))} ${theme.apply(token, `${Math.round(percent)}%`)}`,
|
|
@@ -35,7 +35,8 @@ export type SemanticToken =
|
|
|
35
35
|
| "cache"
|
|
36
36
|
| "cost"
|
|
37
37
|
| "time"
|
|
38
|
-
| "separator"
|
|
38
|
+
| "separator"
|
|
39
|
+
| "hint";
|
|
39
40
|
|
|
40
41
|
/** Structural view of Pi's theme. fg/bg follow Pi's `(color, text) => string` shape. */
|
|
41
42
|
export interface ActiveTheme {
|
|
@@ -94,6 +95,7 @@ const SEMANTIC_COLORS: Record<SemanticToken, string> = {
|
|
|
94
95
|
cost: "text",
|
|
95
96
|
time: "muted",
|
|
96
97
|
separator: "dim",
|
|
98
|
+
hint: "#8a8a8a",
|
|
97
99
|
};
|
|
98
100
|
|
|
99
101
|
const GLYPHS = {
|
|
@@ -108,6 +110,7 @@ const GLYPHS = {
|
|
|
108
110
|
powerlineThinLeft: "\uE0B1",
|
|
109
111
|
powerlineThinRight: "\uE0B3",
|
|
110
112
|
batchOpen: "\u{F111}",
|
|
113
|
+
bashPrompt: "\u{F12A}",
|
|
111
114
|
},
|
|
112
115
|
unicode: {
|
|
113
116
|
pi: "π",
|
|
@@ -120,6 +123,7 @@ const GLYPHS = {
|
|
|
120
123
|
powerlineThinLeft: "│",
|
|
121
124
|
powerlineThinRight: "│",
|
|
122
125
|
batchOpen: "●",
|
|
126
|
+
bashPrompt: "$",
|
|
123
127
|
},
|
|
124
128
|
ascii: {
|
|
125
129
|
pi: "pi",
|
|
@@ -132,6 +136,7 @@ const GLYPHS = {
|
|
|
132
136
|
powerlineThinLeft: "|",
|
|
133
137
|
powerlineThinRight: "|",
|
|
134
138
|
batchOpen: "v",
|
|
139
|
+
bashPrompt: "$",
|
|
135
140
|
},
|
|
136
141
|
} as const;
|
|
137
142
|
|
|
@@ -53,6 +53,58 @@ function isNativeBorderLine(line: string): boolean {
|
|
|
53
53
|
return /^─{2,}$/.test(stripped) || /^─── [↑↓] \d+ more /.test(stripped);
|
|
54
54
|
}
|
|
55
55
|
|
|
56
|
+
/**
|
|
57
|
+
* Remove `count` leading visible characters from an ANSI-rendered editor line,
|
|
58
|
+
* preserving escape sequences (CSI/OSC/APC/DCS) verbatim. Used to hide the
|
|
59
|
+
* bash-mode `!` prefix; the CURSOR_MARKER and cursor-block sequences pass
|
|
60
|
+
* through unchanged so hardware-cursor placement stays aligned.
|
|
61
|
+
*/
|
|
62
|
+
function stripLeadingVisibleChars(line: string, count: number): string {
|
|
63
|
+
if (count <= 0 || line.length === 0) return line;
|
|
64
|
+
let output = "";
|
|
65
|
+
let stripped = 0;
|
|
66
|
+
let index = 0;
|
|
67
|
+
while (index < line.length) {
|
|
68
|
+
const char = line[index] ?? "";
|
|
69
|
+
if (char === "\x1b") {
|
|
70
|
+
const start = index;
|
|
71
|
+
index++;
|
|
72
|
+
const intro = line[index];
|
|
73
|
+
if (intro === "[") {
|
|
74
|
+
index++;
|
|
75
|
+
while (index < line.length) {
|
|
76
|
+
const byte = line[index] ?? "";
|
|
77
|
+
index++;
|
|
78
|
+
if (byte >= "@" && byte <= "~") break;
|
|
79
|
+
}
|
|
80
|
+
} else if (intro === "]" || intro === "_" || intro === "^" || intro === "P") {
|
|
81
|
+
index++;
|
|
82
|
+
while (index < line.length) {
|
|
83
|
+
const byte = line[index] ?? "";
|
|
84
|
+
if (byte === "\x1b" && line[index + 1] === "\\") {
|
|
85
|
+
index += 2;
|
|
86
|
+
break;
|
|
87
|
+
}
|
|
88
|
+
index++;
|
|
89
|
+
if (byte === "\x07") break;
|
|
90
|
+
}
|
|
91
|
+
} else if (intro !== undefined) {
|
|
92
|
+
index++;
|
|
93
|
+
}
|
|
94
|
+
output += line.slice(start, index);
|
|
95
|
+
continue;
|
|
96
|
+
}
|
|
97
|
+
if (stripped < count) {
|
|
98
|
+
stripped++;
|
|
99
|
+
index++;
|
|
100
|
+
continue;
|
|
101
|
+
}
|
|
102
|
+
output += char;
|
|
103
|
+
index++;
|
|
104
|
+
}
|
|
105
|
+
return output;
|
|
106
|
+
}
|
|
107
|
+
|
|
56
108
|
function semanticTheme(theme: PiEditorTheme, config: NormalizedPiStyleConfig): ResolvedTheme {
|
|
57
109
|
const editorTheme = theme;
|
|
58
110
|
return resolveTheme(
|
|
@@ -118,6 +170,7 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
118
170
|
override render(width: number): string[] {
|
|
119
171
|
if (width <= 0) return [];
|
|
120
172
|
const nativeLines = super.render(width);
|
|
173
|
+
const style = this.styleFor(width);
|
|
121
174
|
// Autocomplete (slash menu / @-mentions) restructure: Pi draws the
|
|
122
175
|
// suggestions after its own bottom border, which pushes the below-editor
|
|
123
176
|
// widgets (status line) down. Re-frame the native output so the dropdown
|
|
@@ -125,7 +178,7 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
125
178
|
// Native layout: [top border, text lines, bottom border, dropdown lines...].
|
|
126
179
|
if ((this as unknown as { autocompleteState?: unknown }).autocompleteState) {
|
|
127
180
|
const prompt = this.prompt();
|
|
128
|
-
const padding = this.paddingFor(width,
|
|
181
|
+
const padding = this.paddingFor(width, style);
|
|
129
182
|
const promptWidth = widthOf(prompt) + 1;
|
|
130
183
|
const prefix = `${" ".repeat(padding)}${prompt} `;
|
|
131
184
|
const continuation = " ".repeat(padding + promptWidth);
|
|
@@ -133,31 +186,63 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
133
186
|
const split = borderIndex >= 0 ? borderIndex + 1 : nativeLines.length;
|
|
134
187
|
const body = nativeLines.slice(1, split);
|
|
135
188
|
const dropdown = nativeLines.slice(split);
|
|
136
|
-
const border = this.borderFor(
|
|
137
|
-
const
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
189
|
+
const border = this.borderFor();
|
|
190
|
+
const kind = this.frameKind(style);
|
|
191
|
+
const renderWidth = width - (kind === "rounded" ? 2 : 0);
|
|
192
|
+
const sideColor = kind === "rounded" ? this.borderColorFor() : undefined;
|
|
193
|
+
const wrap = (line: string) =>
|
|
194
|
+
kind === "rounded" && sideColor ? `${sideColor("│")}${line}${sideColor("│")}` : line;
|
|
195
|
+
const bashHidden = this.bashHiddenCount();
|
|
196
|
+
const renderedBody = body.map((line, index) => {
|
|
197
|
+
const source = index === 0 && bashHidden > 0 ? stripLeadingVisibleChars(line, bashHidden) : line;
|
|
198
|
+
return wrap(widthSafe(`${index === 0 ? prefix : continuation}${source}`, renderWidth));
|
|
199
|
+
});
|
|
200
|
+
const dropdownLines = dropdown.map((line) => wrap(widthSafe(line, renderWidth)));
|
|
201
|
+
if (kind === "rounded") {
|
|
202
|
+
return [
|
|
203
|
+
border(`╭${"─".repeat(Math.max(0, width - 2))}╮`),
|
|
204
|
+
...renderedBody,
|
|
205
|
+
...dropdownLines,
|
|
206
|
+
border(`╰${"─".repeat(Math.max(0, width - 2))}╯`),
|
|
207
|
+
];
|
|
208
|
+
}
|
|
209
|
+
return [border("─".repeat(width)), ...renderedBody, ...dropdownLines, border("─".repeat(width))];
|
|
144
210
|
}
|
|
145
|
-
const style = this.styleFor(width);
|
|
146
211
|
if (style === "native") return nativeLines.map((line) => widthSafe(line, width));
|
|
147
212
|
|
|
148
213
|
const prompt = this.prompt();
|
|
149
214
|
const promptWidth = widthOf(prompt) + 1;
|
|
150
215
|
const padding = this.paddingFor(width, style);
|
|
151
|
-
const
|
|
216
|
+
const kind = this.frameKind(style);
|
|
217
|
+
const sideReserve = kind === "rounded" ? 2 : 0;
|
|
218
|
+
const renderWidth = Math.max(1, width - sideReserve);
|
|
219
|
+
const innerWidth = Math.max(1, renderWidth - promptWidth - padding * 2);
|
|
152
220
|
const innerLines = super.render(innerWidth);
|
|
153
221
|
if (innerLines.length === 0) return [];
|
|
154
222
|
|
|
155
223
|
const body = innerLines.slice(1, -1);
|
|
156
224
|
const prefix = `${" ".repeat(padding)}${prompt} `;
|
|
157
225
|
const continuation = " ".repeat(padding + promptWidth);
|
|
226
|
+
const hint = this.config.editor.hint;
|
|
227
|
+
const showHint = hint !== "" && this.getText() === "";
|
|
228
|
+
const bashHidden = this.bashHiddenCount();
|
|
158
229
|
const renderedBody = body.map((line, index) => {
|
|
159
230
|
const lead = index === 0 ? prefix : continuation;
|
|
160
|
-
|
|
231
|
+
const source = index === 0 && bashHidden > 0 ? stripLeadingVisibleChars(line, bashHidden) : line;
|
|
232
|
+
let content = `${lead}${source}`;
|
|
233
|
+
// Empty-input hint: the cursor block (first cell of the native empty
|
|
234
|
+
// line) stays at the input position, the dim hint trails it. The native
|
|
235
|
+
// line is pre-padded to renderWidth with literal spaces; drop them from
|
|
236
|
+
// the raw end (safe: no ANSI follows the padding) before appending the
|
|
237
|
+
// hint, or the hint is truncated away by widthSafe. Typing any character
|
|
238
|
+
// makes the text non-empty and the hint disappears.
|
|
239
|
+
if (showHint && index === 0 && line) {
|
|
240
|
+
let end = content.length;
|
|
241
|
+
while (end > 0 && content[end - 1] === " ") end--;
|
|
242
|
+
if (end < content.length) content = content.slice(0, end);
|
|
243
|
+
content += this.semantic.apply("hint", hint);
|
|
244
|
+
}
|
|
245
|
+
return widthSafe(content, renderWidth);
|
|
161
246
|
});
|
|
162
247
|
const metadata = this.metadata(width, style);
|
|
163
248
|
const framed = this.frame(width, style, renderedBody, metadata);
|
|
@@ -171,11 +256,41 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
171
256
|
}
|
|
172
257
|
|
|
173
258
|
private prompt(): string {
|
|
259
|
+
if (this.isBashMode()) {
|
|
260
|
+
// Bash mode (`!` prefix): the prompt glyph becomes the bash icon and the
|
|
261
|
+
// leading `!` is hidden from the input text. The glyph takes the live
|
|
262
|
+
// border color (pi sets editor.borderColor to the bashMode color).
|
|
263
|
+
const glyph = this.semantic.glyph("bashPrompt");
|
|
264
|
+
return this.borderColor(glyph);
|
|
265
|
+
}
|
|
174
266
|
const configured = this.config.theme.glyphs.prompt;
|
|
175
267
|
if (configured) return configured;
|
|
176
268
|
return this.semantic.mode === "ascii" ? ">" : "❯";
|
|
177
269
|
}
|
|
178
270
|
|
|
271
|
+
/** Pi's bash mode: the input starts with `!` after optional whitespace. */
|
|
272
|
+
private isBashMode(): boolean {
|
|
273
|
+
return this.getText().trimStart().startsWith("!");
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
/**
|
|
277
|
+
* Number of leading `!` characters to hide from the displayed input while
|
|
278
|
+
* bash mode is active. Characters under the cursor are never hidden, so the
|
|
279
|
+
* native cursor block stays visible when the cursor sits on a `!`.
|
|
280
|
+
*/
|
|
281
|
+
private bashHiddenCount(): number {
|
|
282
|
+
const text = this.getText();
|
|
283
|
+
let index = 0;
|
|
284
|
+
while (index < text.length && (text[index] === " " || text[index] === "\t")) index++;
|
|
285
|
+
const runStart = index;
|
|
286
|
+
while (index < text.length && text[index] === "!") index++;
|
|
287
|
+
const run = index - runStart;
|
|
288
|
+
if (run === 0) return 0;
|
|
289
|
+
const cursor = this.getCursor();
|
|
290
|
+
const position = cursor.line === 0 ? cursor.col : Number.POSITIVE_INFINITY;
|
|
291
|
+
return Math.min(run, Math.max(0, position - runStart));
|
|
292
|
+
}
|
|
293
|
+
|
|
179
294
|
private styleFor(width: number): "compact" | "boxed" | "dock" | "native" {
|
|
180
295
|
if (width < 20) return "native";
|
|
181
296
|
if (["compact", "boxed", "dock", "native"].includes(this.config.editor.style)) {
|
|
@@ -186,17 +301,40 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
186
301
|
return "compact";
|
|
187
302
|
}
|
|
188
303
|
|
|
304
|
+
/**
|
|
305
|
+
* Resolve the frame treatment for a style: horizontal bars for compact,
|
|
306
|
+
* full-width bars for boxed, an outlined box for dock, and a rounded box
|
|
307
|
+
* with side borders (`╭─╮ │ │ ╰─╯`) for `frame: "rounded"`.
|
|
308
|
+
*/
|
|
309
|
+
private frameKind(
|
|
310
|
+
style: "compact" | "boxed" | "dock" | "native",
|
|
311
|
+
): "compact" | "boxed" | "outline" | "rounded" | "native" {
|
|
312
|
+
const frame = this.config.editor.frame;
|
|
313
|
+
if (style === "compact" || frame === "line" || frame === "solid") return "compact";
|
|
314
|
+
if (style === "boxed") return "boxed";
|
|
315
|
+
if (frame === "native") return "native";
|
|
316
|
+
if (frame === "rounded") return "rounded";
|
|
317
|
+
return "outline";
|
|
318
|
+
}
|
|
319
|
+
|
|
189
320
|
private paddingFor(width: number, style: "compact" | "boxed" | "dock" | "native"): number {
|
|
190
321
|
if (width < 50) return 0;
|
|
191
322
|
return style === "boxed" ? 2 : style === "dock" ? 1 : 1;
|
|
192
323
|
}
|
|
193
324
|
|
|
194
|
-
private borderFor(
|
|
195
|
-
|
|
325
|
+
private borderFor(): (line: string) => string {
|
|
326
|
+
return (line: string) => this.borderColorFor()(line);
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/** Raw border color function (thinking-synced) WITHOUT full-width padding, for single glyphs. */
|
|
330
|
+
private borderColorFor(): (line: string) => string {
|
|
331
|
+
// While bash mode is active pi keeps editor.borderColor set to the
|
|
332
|
+
// bashMode color (its native updateEditorBorderColor path); prefer it over
|
|
333
|
+
// the thinking-level color so the whole frame switches to the bash color.
|
|
334
|
+
if (this.isBashMode()) return this.borderColor;
|
|
196
335
|
const level = this.snapshot.thinkingLevel;
|
|
197
336
|
const thinking = this.fullTheme?.getThinkingBorderColor?.(level ?? "off");
|
|
198
|
-
|
|
199
|
-
return (line: string) => border(widthSafe(line, width));
|
|
337
|
+
return thinking ?? this.piTheme.borderColor;
|
|
200
338
|
}
|
|
201
339
|
|
|
202
340
|
private frame(
|
|
@@ -205,23 +343,27 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
|
|
|
205
343
|
body: string[],
|
|
206
344
|
metadata: string[],
|
|
207
345
|
): string[] {
|
|
208
|
-
const border = this.borderFor(
|
|
209
|
-
const
|
|
210
|
-
if (
|
|
346
|
+
const border = this.borderFor();
|
|
347
|
+
const kind = this.frameKind(style);
|
|
348
|
+
if (kind === "compact") {
|
|
211
349
|
// Match Pi's native editor: a horizontal border above and below the input.
|
|
212
350
|
return [border("─".repeat(width)), ...body, border("─".repeat(width)), ...metadata];
|
|
213
351
|
}
|
|
214
|
-
if (
|
|
352
|
+
if (kind === "boxed") {
|
|
215
353
|
const glyph = this.config.editor.frame === "halfblock" ? "▀" : "━";
|
|
216
354
|
return [border(glyph.repeat(width)), ...body, border(glyph.repeat(width)), ...metadata];
|
|
217
355
|
}
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
356
|
+
if (kind === "native") return body;
|
|
357
|
+
const inner = Math.max(0, width - 2);
|
|
358
|
+
if (kind === "rounded") {
|
|
359
|
+
// Rounded box with vertical side borders: `╭─╮ / │ text │ / ╰─╯`.
|
|
360
|
+
// Body lines were rendered at width - 2; re-fit defensively, then wrap.
|
|
361
|
+
// Side glyphs use the raw border color (no full-width padding, unlike border()).
|
|
362
|
+
const sideColor = this.borderColorFor();
|
|
363
|
+
const side = (line: string) => `${sideColor("│")}${widthSafe(line, inner)}${sideColor("│")}`;
|
|
364
|
+
return [border(`╭${"─".repeat(inner)}╮`), ...body.map(side), border(`╰${"─".repeat(inner)}╯`), ...metadata];
|
|
365
|
+
}
|
|
366
|
+
return [border(`┌${"─".repeat(inner)}┐`), ...body, border(`└${"─".repeat(inner)}┘`), ...metadata];
|
|
225
367
|
}
|
|
226
368
|
|
|
227
369
|
private metadata(width: number, style: "compact" | "boxed" | "dock" | "native"): string[] {
|
|
@@ -176,6 +176,8 @@ function prefixNative(lines: unknown, width: number, prefix: string): string[] |
|
|
|
176
176
|
export type MessageDecorationSnapshot = Readonly<{
|
|
177
177
|
assistantPrefix: string;
|
|
178
178
|
assistantEnabled: boolean;
|
|
179
|
+
/** Drop the hidden-thinking label row and its trailing spacer (zero-trace collapse). */
|
|
180
|
+
collapseHiddenThinking: boolean;
|
|
179
181
|
}>;
|
|
180
182
|
|
|
181
183
|
export function decorateMessageRender(
|
|
@@ -185,6 +187,7 @@ export function decorateMessageRender(
|
|
|
185
187
|
snapshot: MessageDecorationSnapshot = {
|
|
186
188
|
assistantPrefix: "│ ",
|
|
187
189
|
assistantEnabled: true,
|
|
190
|
+
collapseHiddenThinking: false,
|
|
188
191
|
},
|
|
189
192
|
): unknown {
|
|
190
193
|
if (typeof original !== "function") return undefined;
|
|
@@ -199,4 +202,67 @@ export function decorateMessageRender(
|
|
|
199
202
|
return prefixNative(native, width, prefix) ?? native;
|
|
200
203
|
}
|
|
201
204
|
|
|
205
|
+
/** Spacer-like: renders empty lines and exposes only setLines among these surfaces. */
|
|
206
|
+
function isSpacerChild(child: unknown): boolean {
|
|
207
|
+
return typeof (child as { setLines?: unknown } | undefined)?.setLines === "function";
|
|
208
|
+
}
|
|
209
|
+
|
|
210
|
+
/**
|
|
211
|
+
* The hidden-thinking placeholder: a Text (setCustomBgFn) whose ANSI-stripped
|
|
212
|
+
* rendered content is empty. Duck-typed on the public shape because
|
|
213
|
+
* pi-coding-agent may resolve its own nested pi-tui copy, so `instanceof`
|
|
214
|
+
* across that module boundary is unreliable.
|
|
215
|
+
*/
|
|
216
|
+
function isBlankTextChild(child: unknown): boolean {
|
|
217
|
+
const candidate = child as { setCustomBgFn?: unknown; render?: (width: number) => string[] } | undefined;
|
|
218
|
+
if (typeof candidate?.setCustomBgFn !== "function" || typeof candidate.render !== "function") return false;
|
|
219
|
+
return contentText(candidate.render(0).join("\n")).trim() === "";
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Collapse Pi's hidden-thinking placeholder row to zero trace.
|
|
224
|
+
*
|
|
225
|
+
* Native `AssistantMessageComponent.updateContent` renders the thinking block as
|
|
226
|
+
* `Text(theme.italic(theme.fg("thinkingText", label)), outputPad, 0)` plus a
|
|
227
|
+
* trailing `Spacer(1)`. An empty label is still wrapped in ANSI SGR codes, so
|
|
228
|
+
* `Text.render` cannot treat it as empty (its check is `text.trim() === ""`,
|
|
229
|
+
* and trim does not strip escape sequences) and emits one full-width invisible
|
|
230
|
+
* line. That invisible row plus the surrounding spacers is the "gap" left when
|
|
231
|
+
* the label is hidden. This wrapper runs the native layout, then drops the
|
|
232
|
+
* invisible label row and the spacer the native layout appends after the
|
|
233
|
+
* thinking run, leaving the same single top padding as a text-only message.
|
|
234
|
+
*/
|
|
235
|
+
export function decorateMessageUpdate(
|
|
236
|
+
original: unknown,
|
|
237
|
+
instance: object,
|
|
238
|
+
args: unknown[],
|
|
239
|
+
snapshot: MessageDecorationSnapshot = {
|
|
240
|
+
assistantPrefix: "│ ",
|
|
241
|
+
assistantEnabled: true,
|
|
242
|
+
collapseHiddenThinking: false,
|
|
243
|
+
},
|
|
244
|
+
): unknown {
|
|
245
|
+
if (typeof original !== "function") return undefined;
|
|
246
|
+
const result = Reflect.apply(original, instance, args);
|
|
247
|
+
if (!snapshot.collapseHiddenThinking) return result;
|
|
248
|
+
const target = instance as {
|
|
249
|
+
hideThinkingBlock?: boolean;
|
|
250
|
+
hiddenThinkingLabel?: string;
|
|
251
|
+
contentContainer?: { children?: unknown[] };
|
|
252
|
+
};
|
|
253
|
+
// Only meaningful when Pi renders the hidden-block label (hideThinkingBlock)
|
|
254
|
+
// and the extension has blanked that label out ("" — the zero-trace mode).
|
|
255
|
+
if (target.hideThinkingBlock !== true || target.hiddenThinkingLabel !== "") return result;
|
|
256
|
+
const children = target.contentContainer?.children;
|
|
257
|
+
if (!children) return result;
|
|
258
|
+
for (let index = children.length - 1; index >= 0; index--) {
|
|
259
|
+
if (!isBlankTextChild(children[index])) continue;
|
|
260
|
+
children.splice(index, 1);
|
|
261
|
+
// Drop the Spacer(1) the native layout appends after the thinking run when
|
|
262
|
+
// another visible block follows; the message keeps only its shared top padding.
|
|
263
|
+
if (isSpacerChild(children[index])) children.splice(index, 1);
|
|
264
|
+
}
|
|
265
|
+
return result;
|
|
266
|
+
}
|
|
267
|
+
|
|
202
268
|
// Special private layouts are intentionally not installed; their prototypes remain native.
|