@quandev104/pi-style 0.1.4 → 0.1.6

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.
Files changed (34) hide show
  1. package/CHANGELOG.md +30 -0
  2. package/README.md +10 -6
  3. package/dist/extensions/pi-style.js +3939 -1431
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +2 -0
  6. package/extension-src/pi-style/domain/config-authorization.ts +6 -3
  7. package/extension-src/pi-style/domain/config-normalization.ts +21 -5
  8. package/extension-src/pi-style/domain/config-presets.ts +1 -1
  9. package/extension-src/pi-style/domain/config-types.ts +7 -3
  10. package/extension-src/pi-style/domain/theme.ts +6 -1
  11. package/extension-src/pi-style/features/editor/index.ts +169 -27
  12. package/extension-src/pi-style/features/messages/index.ts +66 -0
  13. package/extension-src/pi-style/features/tools/bash-execution.ts +112 -0
  14. package/extension-src/pi-style/features/tools/boxed/bash.ts +154 -130
  15. package/extension-src/pi-style/features/tools/boxed/batch.ts +50 -27
  16. package/extension-src/pi-style/features/tools/boxed/command-shape.ts +136 -0
  17. package/extension-src/pi-style/features/tools/boxed/find.ts +2 -2
  18. package/extension-src/pi-style/features/tools/boxed/gh.ts +1012 -0
  19. package/extension-src/pi-style/features/tools/boxed/git.ts +1960 -0
  20. package/extension-src/pi-style/features/tools/boxed/grep.ts +2 -2
  21. package/extension-src/pi-style/features/tools/boxed/output-tree.ts +9 -10
  22. package/extension-src/pi-style/features/tools/boxed/read.ts +3 -3
  23. package/extension-src/pi-style/features/tools/boxed/write.ts +2 -1
  24. package/extension-src/pi-style/pi/compatibility-coordinator.ts +32 -11
  25. package/extension-src/pi-style/pi/compatibility-probe.ts +341 -205
  26. package/extension-src/pi-style/pi/compatibility-registry.ts +19 -3
  27. package/extension-src/pi-style/pi/index.ts +21 -3
  28. package/extension-src/pi-style/pi/session-coordinator.ts +24 -0
  29. package/extension-src/pi-style/shared/box.ts +8 -4
  30. package/extension-src/pi-style/shared/split-diff.ts +9 -9
  31. package/package.json +9 -9
  32. package/themes/titanium-light.json +82 -0
  33. package/themes/titanium.json +79 -0
  34. 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")
@@ -1,16 +1,19 @@
1
1
  import type { NormalizedPiStyleConfig } from "./config-types.js";
2
2
 
3
3
  export interface TierCAuthorizationInput {
4
- readonly certifiedHost: boolean;
5
4
  readonly coreFlag: boolean;
6
5
  readonly surfaceFlag: boolean;
7
6
  readonly surface: "messages" | "tools" | "assistantMessage" | "specialBlocks";
8
7
  readonly config: NormalizedPiStyleConfig;
9
8
  }
10
9
 
11
- /** Persisted and ordinary command state is intentionally not an input: authorization is session-bound. */
10
+ /**
11
+ * Tier C surface authorization is session-bound to the core/surface flags and
12
+ * normalized config; runtime compatibility is decided per-surface by the probe's
13
+ * identity (fingerprint) check, never by a hard-pinned Pi version.
14
+ */
12
15
  export function isTierCAuthorized(input: TierCAuthorizationInput): boolean {
13
- if (!input.certifiedHost || !input.coreFlag || !input.surfaceFlag || !input.config.enabled) return false;
16
+ if (!input.coreFlag || !input.surfaceFlag || !input.config.enabled) return false;
14
17
  if (input.surface === "tools") return input.config.tools.enabled;
15
18
  if (input.surface === "assistantMessage")
16
19
  return input.config.messages.enabled && input.config.messages.assistantPrefix;
@@ -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: "compact", frame: "auto", showMetadata: false }),
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({ nerdFonts: "auto", terminalBackgroundSync: "auto", colors: {}, glyphs: {} }),
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 === "statusLine.separator" || path === "tools.style") return typeof value === "string";
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
  };
@@ -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, this.styleFor(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(width);
137
- const renderedBody = body.map((line, index) => widthSafe(`${index === 0 ? prefix : continuation}${line}`, width));
138
- return [
139
- border("".repeat(width)),
140
- ...renderedBody,
141
- ...dropdown.map((line) => widthSafe(line, width)),
142
- border("─".repeat(width)),
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 innerWidth = Math.max(1, width - promptWidth - padding * 2);
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
- return widthSafe(`${lead}${line}`, width);
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(width: number): (line: string) => string {
195
- // Sync the frame color with the active thinking level, matching Pi's native editor.
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
- const border = thinking ?? this.piTheme.borderColor;
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(width);
209
- const lineMode = this.config.editor.frame === "line" || this.config.editor.frame === "solid";
210
- if (style === "compact" || lineMode) {
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 (style === "boxed") {
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 (this.config.editor.frame === "native") return body;
219
- return [
220
- border(`┌${"".repeat(Math.max(0, width - 2))}┐`),
221
- ...body,
222
- border(`└${"─".repeat(Math.max(0, width - 2))}┘`),
223
- ...metadata,
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.