@quandev104/pi-style 0.2.1 → 0.2.3

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 (38) hide show
  1. package/CHANGELOG.md +21 -0
  2. package/README.md +8 -3
  3. package/dist/extensions/pi-style.js +1328 -333
  4. package/dist/extensions/pi-style.js.map +1 -1
  5. package/extension-src/pi-style/app/command-service.ts +8 -0
  6. package/extension-src/pi-style/app/runtime.ts +33 -4
  7. package/extension-src/pi-style/domain/config-normalization.ts +11 -1
  8. package/extension-src/pi-style/domain/config-types.ts +12 -0
  9. package/extension-src/pi-style/domain/status-renderer.ts +41 -9
  10. package/extension-src/pi-style/domain/status.ts +28 -12
  11. package/extension-src/pi-style/domain/theme.ts +32 -1
  12. package/extension-src/pi-style/features/editor/index.ts +144 -5
  13. package/extension-src/pi-style/features/messages/image-input.ts +205 -0
  14. package/extension-src/pi-style/features/messages/image-preview.ts +288 -0
  15. package/extension-src/pi-style/features/messages/index.ts +302 -61
  16. package/extension-src/pi-style/features/messages/render-config.ts +36 -0
  17. package/extension-src/pi-style/features/status-line/index.ts +41 -11
  18. package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
  19. package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
  20. package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
  21. package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
  22. package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
  23. package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
  24. package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
  25. package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
  26. package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
  27. package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
  28. package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
  29. package/extension-src/pi-style/pi/index.ts +55 -2
  30. package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
  31. package/extension-src/pi-style/shared/ansi.ts +17 -5
  32. package/extension-src/pi-style/shared/box.ts +70 -4
  33. package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
  34. package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
  35. package/extension-src/pi-style/shared/pending-images.ts +199 -0
  36. package/extension-src/pi-style/shared/split-diff.ts +8 -5
  37. package/package.json +1 -1
  38. package/extension-src/pi-style/features/.gitkeep +0 -0
@@ -49,6 +49,9 @@ const allowedPaths = new Set([
49
49
  "messages.specialBlocks",
50
50
  "messages.hideThinkingLabel",
51
51
  "messages.hideInterimText",
52
+ "messages.showImagePreviews",
53
+ "messages.clipboardImages",
54
+ "messages.previewMaxWidth",
52
55
  "tools.enabled",
53
56
  "tools.style",
54
57
  "tools.maxCollapsedLines",
@@ -82,6 +85,9 @@ function validatePathValue(path: string, value: unknown): boolean {
82
85
  "messages.specialBlocks",
83
86
  "messages.hideThinkingLabel",
84
87
  "messages.hideInterimText",
88
+ "messages.showImagePreviews",
89
+ "messages.clipboardImages",
90
+ "messages.previewMaxWidth",
85
91
  "tools.enabled",
86
92
  "tools.showElapsed",
87
93
  "tools.dimOutput",
@@ -105,6 +111,8 @@ function validatePathValue(path: string, value: unknown): boolean {
105
111
  return typeof value === "string";
106
112
  if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
107
113
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
114
+ if (path === "messages.previewMaxWidth")
115
+ return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60;
108
116
  if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
109
117
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
110
118
  if (path.endsWith(".colors") || path.endsWith(".glyphs"))
@@ -5,6 +5,7 @@ import type { GitCommandRunner } from "../domain/providers.js";
5
5
  import type { ContextSnapshot, StatusSnapshot } from "../domain/status.js";
6
6
  import { normalizeThinkingLevel } from "../domain/status.js";
7
7
  import { installEditor } from "../features/editor/index.js";
8
+ import { createClipboardImagePasteSurface } from "../features/messages/image-input.js";
8
9
  import {
9
10
  installStartup,
10
11
  type StartupHost,
@@ -18,6 +19,9 @@ import { CachedGitProvider, InMemoryContextProvider, InMemoryUsageProvider } fro
18
19
  import { RenderScheduler } from "./render-scheduler.js";
19
20
  import { createSnapshot, replaceSnapshot, type UiSnapshot } from "./snapshot.js";
20
21
 
22
+ /** Debounce window for coalescing git refresh spawns after invalidateGit(). */
23
+ const GIT_INVALIDATE_DEBOUNCE_MS = 250;
24
+
21
25
  export interface RuntimeInstallationState {
22
26
  readonly status: "installed" | "disabled" | "failed";
23
27
  readonly editor: "installed" | "preserved" | "disabled" | "failed";
@@ -94,6 +98,11 @@ export function createPiStyleRuntime(
94
98
  const disposables = new DisposableStore();
95
99
  const scheduler = new RenderScheduler({ requestRender }, generation, () => !disposed);
96
100
  const git = new CachedGitProvider(host.gitRunner);
101
+ // Debounced git refresh (invalidateGit): tool-result bursts fire one
102
+ // invalidateGit per write/edit/bash, each of which would otherwise spawn a
103
+ // `git status` process. Only one refresh may be pending at a time; the
104
+ // handle is unreffed so it can never keep the process alive.
105
+ let pendingGitRefresh: ReturnType<typeof setTimeout> | undefined;
97
106
  const contextProvider = new InMemoryContextProvider();
98
107
  const usageProvider = new InMemoryUsageProvider();
99
108
  const initialContext = contextSnapshot();
@@ -190,6 +199,9 @@ export function createPiStyleRuntime(
190
199
  generation,
191
200
  initialSnapshot: currentSnapshot,
192
201
  isCurrent: () => !disposed,
202
+ // Clipboard image paste surface (ADR 0009): instant `[Image #N] `
203
+ // markers at keystroke time, artifact fallback, atomic backspace.
204
+ clipboardImagePaste: createClipboardImagePasteSurface(),
193
205
  });
194
206
  if (editor) {
195
207
  disposables.add(editor);
@@ -345,11 +357,24 @@ export function createPiStyleRuntime(
345
357
  },
346
358
  invalidateGit() {
347
359
  if (disposed || !host.cwd || !currentConfig.enabled || !currentConfig.statusLine.enabled) return;
348
- git.invalidate(host.cwd);
349
- void git.get(host.cwd).then((value) => {
360
+ const cwd = host.cwd;
361
+ // Mark the cache stale immediately (cheap, no spawn); the actual
362
+ // `git status` process spawn is coalesced behind a short debounce so
363
+ // bursts of tool results trigger ONE refresh, not one per event.
364
+ // CachedGitProvider serializes concurrent gets via entry.promise and
365
+ // honors invalidate-during-flight (needsRefresh re-runs the fetch),
366
+ // so this debounce only reduces spawn count and never loses a signal.
367
+ git.invalidate(cwd);
368
+ if (pendingGitRefresh !== undefined) return;
369
+ pendingGitRefresh = setTimeout(() => {
370
+ pendingGitRefresh = undefined;
350
371
  if (disposed) return;
351
- if (updateSnapshot(withSnapshotPatch({ git: value }))) requestRender();
352
- });
372
+ void git.get(cwd).then((value) => {
373
+ if (disposed) return;
374
+ if (updateSnapshot(withSnapshotPatch({ git: value }))) requestRender();
375
+ });
376
+ }, GIT_INVALIDATE_DEBOUNCE_MS);
377
+ pendingGitRefresh.unref?.();
353
378
  },
354
379
  get disposed() {
355
380
  return disposed;
@@ -357,6 +382,10 @@ export function createPiStyleRuntime(
357
382
  dispose() {
358
383
  if (disposed) return;
359
384
  disposed = true;
385
+ if (pendingGitRefresh !== undefined) {
386
+ clearTimeout(pendingGitRefresh);
387
+ pendingGitRefresh = undefined;
388
+ }
360
389
  scheduler.cancel();
361
390
  // UI surfaces are restored synchronously so teardown is deterministic;
362
391
  // the store then disposes the (already disposed) feature instances idempotently.
@@ -16,7 +16,7 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
16
16
  startup: Object.freeze({ mode: "compact", showResources: false, alwaysExpanded: false }),
17
17
  statusLine: Object.freeze({
18
18
  enabled: true,
19
- separator: "powerline-thin",
19
+ separator: "|",
20
20
  layout: Object.freeze({
21
21
  left: ["path", "git", "context_bar", "cost"],
22
22
  right: ["model_effort"],
@@ -34,6 +34,9 @@ export const DEFAULT_CONFIG: NormalizedPiStyleConfig = Object.freeze({
34
34
  specialBlocks: true,
35
35
  hideThinkingLabel: true,
36
36
  hideInterimText: true,
37
+ showImagePreviews: true,
38
+ clipboardImages: true,
39
+ previewMaxWidth: 30,
37
40
  }),
38
41
  tools: Object.freeze({
39
42
  enabled: true,
@@ -178,6 +181,9 @@ export function normalizeConfig(
178
181
  specialBlocks: bool(messages.specialBlocks, defaults.messages.specialBlocks),
179
182
  hideThinkingLabel: bool(messages.hideThinkingLabel, defaults.messages.hideThinkingLabel),
180
183
  hideInterimText: bool(messages.hideInterimText, defaults.messages.hideInterimText),
184
+ showImagePreviews: bool(messages.showImagePreviews, defaults.messages.showImagePreviews),
185
+ clipboardImages: bool(messages.clipboardImages, defaults.messages.clipboardImages),
186
+ previewMaxWidth: boundedInt(messages.previewMaxWidth, defaults.messages.previewMaxWidth, 8, 60),
181
187
  }),
182
188
  tools: Object.freeze({
183
189
  enabled: bool(tools.enabled, defaults.tools.enabled),
@@ -239,6 +245,8 @@ const BOOL_PATHS = new Set([
239
245
  "messages.specialBlocks",
240
246
  "messages.hideThinkingLabel",
241
247
  "messages.hideInterimText",
248
+ "messages.showImagePreviews",
249
+ "messages.clipboardImages",
242
250
  "tools.enabled",
243
251
  "tools.showElapsed",
244
252
  "tools.dimOutput",
@@ -289,6 +297,8 @@ function validLeaf(path: string, value: unknown): boolean {
289
297
  return typeof value === "string";
290
298
  if (path === "tools.maxCollapsedLines" || path === "tools.maxExpandedLines")
291
299
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
300
+ if (path === "messages.previewMaxWidth")
301
+ return typeof value === "number" && Number.isFinite(value) && value >= 8 && value <= 60;
292
302
  if (path === "statusLine.bottomMargin" || path === "statusLine.contextBarWidth")
293
303
  return typeof value === "number" && Number.isFinite(value) && value >= 0;
294
304
  if (STRING_ARRAY_PATHS.has(path)) return Array.isArray(value) && value.every((item) => typeof item === "string");
@@ -38,6 +38,12 @@ export interface PiStyleConfig {
38
38
  specialBlocks?: boolean;
39
39
  hideThinkingLabel?: boolean;
40
40
  hideInterimText?: boolean;
41
+ /** Inline previews for user-prompt images (ADR 0008); gates append and render. */
42
+ showImagePreviews?: boolean;
43
+ /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
44
+ clipboardImages?: boolean;
45
+ /** Max cell width per user-prompt image preview (ADR 0008); default 30. */
46
+ previewMaxWidth?: number;
41
47
  };
42
48
  tools?: {
43
49
  enabled?: boolean;
@@ -97,6 +103,12 @@ export interface NormalizedPiStyleConfig {
97
103
  specialBlocks: boolean;
98
104
  hideThinkingLabel: boolean;
99
105
  hideInterimText: boolean;
106
+ /** Inline previews for user-prompt images (ADR 0008); gates append and render. */
107
+ showImagePreviews: boolean;
108
+ /** Clipboard image input (ADR 0009): upgrade built-in paste temp paths to attachments. */
109
+ clipboardImages: boolean;
110
+ /** Max cell width per user-prompt image preview (ADR 0008); default 30. */
111
+ previewMaxWidth: number;
100
112
  };
101
113
  readonly tools: {
102
114
  enabled: boolean;
@@ -25,6 +25,8 @@ interface Candidate {
25
25
  readonly segment: StatusSegment;
26
26
  readonly result: SegmentRenderResult;
27
27
  content: string;
28
+ /** Visible width of `content`; updated when the compact form is swapped in. */
29
+ contentWidth: number;
28
30
  compact: boolean;
29
31
  moved: boolean;
30
32
  }
@@ -47,10 +49,6 @@ function renderGroup(items: readonly Candidate[], separator: string, padding: st
47
49
  .join(`${padding}${separator}${padding}`);
48
50
  }
49
51
 
50
- function widthOf(items: readonly Candidate[], separator: string, padding: string): number {
51
- return visibleWidth(renderGroup(items, separator, padding));
52
- }
53
-
54
52
  export function renderStatus(
55
53
  layout: StatusLayout,
56
54
  snapshot: StatusSnapshot,
@@ -58,7 +56,7 @@ export function renderStatus(
58
56
  options: StatusRendererOptions,
59
57
  ): StatusRenderResult {
60
58
  if (width <= 0) return { primary: "", lines: [], visibleSegments: [] };
61
- const separator = options.separator ?? "";
59
+ const separator = options.separator ?? "|";
62
60
  const padding = options.padding ?? " ";
63
61
  const normalized = uniqueLayout(layout);
64
62
  const context: SegmentContext = { snapshot, theme: options.theme, options: options.options ?? {}, width };
@@ -70,7 +68,15 @@ export function renderStatus(
70
68
  try {
71
69
  const result = segment.render(context);
72
70
  if (!result.visible || !result.content) continue;
73
- candidates.set(id, { id, segment, result, content: result.content, compact: false, moved: false });
71
+ candidates.set(id, {
72
+ id,
73
+ segment,
74
+ result,
75
+ content: result.content,
76
+ contentWidth: visibleWidth(result.content),
77
+ compact: false,
78
+ moved: false,
79
+ });
74
80
  } catch {
75
81
  // A broken optional segment must not break the status row.
76
82
  }
@@ -88,13 +94,34 @@ export function renderStatus(
88
94
  .filter((candidate): candidate is Candidate => candidate !== undefined);
89
95
  const visible: Candidate[] = [];
90
96
  const overflow: Candidate[] = [];
97
+ // Incremental fit tracking: the rendered width of a group is the sum of the
98
+ // member content widths plus one separator gap between consecutive members
99
+ // (every join boundary is broken by the separator string, so visible widths
100
+ // add up). This replaces re-joining and re-measuring the whole group after
101
+ // every push, which made the overflow loop quadratic in segment count.
102
+ const gapWidth = visibleWidth(`${padding}${separator}${padding}`);
103
+ let groupWidth = 0;
104
+ let groupCount = 0;
105
+ const widthAfter = (baseWidth: number, count: number, candidate: Candidate): number =>
106
+ count === 0 ? candidate.contentWidth : baseWidth + gapWidth + candidate.contentWidth;
91
107
  for (const candidate of primary) {
92
108
  visible.push(candidate);
93
- if (widthOf(visible, separator, padding) <= width) continue;
109
+ const pushedWidth = widthAfter(groupWidth, groupCount, candidate);
110
+ if (pushedWidth <= width) {
111
+ groupWidth = pushedWidth;
112
+ groupCount++;
113
+ continue;
114
+ }
94
115
  if (candidate.result.compactContent && !candidate.compact) {
95
116
  candidate.content = candidate.result.compactContent;
96
117
  candidate.compact = true;
97
- if (widthOf(visible, separator, padding) <= width) continue;
118
+ candidate.contentWidth = visibleWidth(candidate.content);
119
+ const compactedWidth = widthAfter(groupWidth, groupCount, candidate);
120
+ if (compactedWidth <= width) {
121
+ groupWidth = compactedWidth;
122
+ groupCount++;
123
+ continue;
124
+ }
98
125
  }
99
126
  visible.pop();
100
127
  if (candidate.segment.overflow !== "drop" && candidate.segment.overflow !== "primary") {
@@ -127,9 +154,14 @@ export function renderStatus(
127
154
  }
128
155
  if (visibleWidth(primaryText) > width) primaryText = truncateAnsi(primaryText, width);
129
156
  const secondaryVisible: Candidate[] = [];
157
+ let secondaryWidth = 0;
158
+ let secondaryCount = 0;
130
159
  for (const candidate of [...secondary].sort((a, b) => b.segment.defaultPriority - a.segment.defaultPriority)) {
160
+ const pushedWidth = widthAfter(secondaryWidth, secondaryCount, candidate);
161
+ if (pushedWidth > width) continue;
131
162
  secondaryVisible.push(candidate);
132
- if (widthOf(secondaryVisible, separator, padding) > width) secondaryVisible.pop();
163
+ secondaryWidth = pushedWidth;
164
+ secondaryCount++;
133
165
  }
134
166
  const secondaryText = renderGroup(secondaryVisible, separator, padding);
135
167
  const lines = secondaryText ? [primaryText, secondaryText] : primaryText ? [primaryText] : [];
@@ -252,12 +252,21 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
252
252
  const percent = contextPercent(snapshot.context ?? {});
253
253
  if (percent === undefined) return { visible: false, content: "" };
254
254
  const token = contextBarToken(percent);
255
- const window = snapshot.context?.windowTokens;
256
- const label = `ctx${window !== undefined ? ` (${formatTokens(window)})` : ""}:`;
257
255
  const width = (options.context_bar?.width as number | undefined) ?? CONTEXT_BAR_WIDTH;
256
+ // Pipe-delimited context block: `[█████░░░░░] | 47% used | 235K/1.0M`.
257
+ // Uses plain `|` (not the tall `│`) so the inline block stays visually short.
258
+ // No boundary pipes: the status renderer's segment separator already
259
+ // delimits the block, and extra pipes would double it up.
260
+ const pipe = theme.apply("separator", "|");
261
+ const bar = `${theme.apply("muted", "[")}${theme.apply(token, contextBar(percent, width))}${theme.apply("muted", "]")}`;
262
+ const parts = [bar, `${theme.apply(token, `${Math.round(percent)}%`)}${theme.apply("muted", " used")}`];
263
+ const current = snapshot.context?.currentTokens;
264
+ const window = snapshot.context?.windowTokens;
265
+ if (current !== undefined && window !== undefined)
266
+ parts.push(theme.apply("muted", `${formatTokens(current)}/${formatTokens(window)}`));
258
267
  return {
259
268
  visible: true,
260
- content: `${theme.apply("muted", label)} ${theme.apply(token, contextBar(percent, width))} ${theme.apply(token, `${Math.round(percent)}%`)}`,
269
+ content: parts.join(` ${pipe} `),
261
270
  compactContent: theme.apply(token, `${Math.round(percent)}%`),
262
271
  };
263
272
  }),
@@ -308,11 +317,10 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
308
317
  ? ""
309
318
  : theme.apply("time", formatElapsed(Date.now() - snapshot.sessionStartedAt)),
310
319
  })),
311
- segment("time", 20, ({ theme }) => ({
312
- visible: true,
313
- content: theme.apply("time", new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })),
314
- compactContent: theme.apply("time", new Date().toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" })),
315
- })),
320
+ segment("time", 20, ({ theme }) => {
321
+ const text = theme.apply("time", clockTime());
322
+ return { visible: true, content: text, compactContent: text };
323
+ }),
316
324
  segment("hostname", 20, ({ snapshot, theme }) => ({
317
325
  visible: Boolean(snapshot.hostname),
318
326
  content: theme.apply("muted", snapshot.hostname ?? ""),
@@ -337,6 +345,17 @@ export function createBuiltinSegments(): ReadonlyMap<StatusSegmentId, StatusSegm
337
345
  return new Map(segments.map((item) => [item.id, item]));
338
346
  }
339
347
 
348
+ /** Cached clock text: the minute-precision string only changes once per minute. */
349
+ let clockCache: { minuteKey: number; value: string } | undefined;
350
+ function clockTime(): string {
351
+ const now = new Date();
352
+ const minuteKey = now.getHours() * 60 + now.getMinutes();
353
+ if (clockCache?.minuteKey !== minuteKey) {
354
+ clockCache = { minuteKey, value: now.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }) };
355
+ }
356
+ return clockCache.value;
357
+ }
358
+
340
359
  const CONTEXT_BAR_WIDTH = 10;
341
360
 
342
361
  function contextBar(percent: number, width = CONTEXT_BAR_WIDTH): string {
@@ -352,10 +371,7 @@ function contextBarToken(percent: number): SemanticToken {
352
371
  }
353
372
 
354
373
  function formatTokens(value: number): string {
355
- if (value >= 1_000_000) {
356
- const millions = value / 1_000_000;
357
- return `${Number.isInteger(millions) ? millions : millions.toFixed(1)}M`;
358
- }
374
+ if (value >= 1_000_000) return `${(value / 1_000_000).toFixed(1)}M`;
359
375
  if (value >= 1_000) {
360
376
  const thousands = value / 1_000;
361
377
  return `${Number.isInteger(thousands) ? thousands : thousands.toFixed(1)}K`;
@@ -200,10 +200,32 @@ function colorPrefixFor(
200
200
  return "";
201
201
  }
202
202
 
203
+ function envKeyFor(env: Record<string, string | undefined>): string {
204
+ return `${env.PI_STYLE_NERD_FONTS ?? ""}\u0000${env.GHOSTTY_RESOURCES_DIR ? "1" : "0"}\u0000${env.TERM_PROGRAM ?? ""}`;
205
+ }
206
+
207
+ const glyphModeCache = new WeakMap<object, Map<string, GlyphMode>>();
208
+
203
209
  export function detectGlyphMode(
204
210
  config: NormalizedPiStyleConfig,
205
211
  env: Record<string, string | undefined> = {},
206
212
  ): GlyphMode {
213
+ // Memoized per config object; the env-derived key keeps the result correct
214
+ // when the same config is resolved against different environments.
215
+ let byEnv = glyphModeCache.get(config);
216
+ if (!byEnv) {
217
+ byEnv = new Map();
218
+ glyphModeCache.set(config, byEnv);
219
+ }
220
+ const envKey = envKeyFor(env);
221
+ const cached = byEnv.get(envKey);
222
+ if (cached !== undefined) return cached;
223
+ const mode = computeGlyphMode(config, env);
224
+ byEnv.set(envKey, mode);
225
+ return mode;
226
+ }
227
+
228
+ function computeGlyphMode(config: NormalizedPiStyleConfig, env: Record<string, string | undefined>): GlyphMode {
207
229
  if (env.PI_STYLE_NERD_FONTS === "1") return "nerd";
208
230
  if (env.PI_STYLE_NERD_FONTS === "0") return "unicode";
209
231
  if (config.theme.nerdFonts === "on") return "nerd";
@@ -224,7 +246,16 @@ export function resolveTheme(
224
246
  ): ResolvedTheme {
225
247
  const noColor = Object.hasOwn(env, "NO_COLOR") && env.NO_COLOR !== "" && config.theme.colors.colorOverride !== "on";
226
248
  const mode = config.preset === "ascii" ? "ascii" : detectGlyphMode(config, env);
227
- const color = (token: SemanticToken) => colorPrefixFor(active, config, noColor, token);
249
+ // Lazy per-instance prefix memo: repeated apply(token, ...) calls stop
250
+ // re-running colorPrefixFor (and Pi's active.fg()) for the same token.
251
+ const prefixes = new Map<SemanticToken, string>();
252
+ const color = (token: SemanticToken): string => {
253
+ const cached = prefixes.get(token);
254
+ if (cached !== undefined) return cached;
255
+ const prefix = colorPrefixFor(active, config, noColor, token);
256
+ prefixes.set(token, prefix);
257
+ return prefix;
258
+ };
228
259
  return {
229
260
  mode,
230
261
  noColor,
@@ -6,6 +6,7 @@ import { contextPercent, type StatusSnapshot, type ThinkingLevel } from "../../d
6
6
  import type { ResolvedTheme } from "../../domain/theme.js";
7
7
  import { resolveTheme } from "../../domain/theme.js";
8
8
  import { stripAnsi, truncateAnsi } from "../../shared/ansi.js";
9
+ import { clipboardMarkerAtEnd, isSingleClipboardImagePath } from "../../shared/clipboard-path.js";
9
10
 
10
11
  export const EDITOR_DIAGNOSTIC_KEY = "pi-style.editor";
11
12
  type SetEditorComponent = NonNullable<ExtensionUIContext["setEditorComponent"]>;
@@ -30,6 +31,18 @@ export interface EditorInstallation {
30
31
  dispose(): void;
31
32
  }
32
33
 
34
+ /** Structural clipboard-image paste surface (ADR 0009) consumed by the
35
+ * editor. Structural so this feature needs no cross-feature import; the
36
+ * app layer passes the instance built in features/messages. */
37
+ interface ClipboardImagePasteSurface {
38
+ enabled(): boolean;
39
+ clipboardHasImage(): boolean | null;
40
+ markerFromClipboard(): string;
41
+ markerFromArtifact(path: string): Promise<string>;
42
+ isMarkerIndexRegistered(index: number): boolean;
43
+ discardMarkerIndex(index: number): void;
44
+ }
45
+
33
46
  interface EditorOptions {
34
47
  config: NormalizedPiStyleConfig;
35
48
  snapshot: StatusSnapshot;
@@ -37,6 +50,22 @@ interface EditorOptions {
37
50
  /** Full Pi theme for thinking-level border colors (optional; falls back to borderColor). */
38
51
  fullTheme?: { getThinkingBorderColor?: (level: ThinkingLevel) => (str: string) => string };
39
52
  onSnapshot: (snapshot: StatusSnapshot) => void;
53
+ /** Clipboard image paste surface (ADR 0009): instant `[Image #N] ` markers
54
+ * at keystroke time, artifact-path fallback, atomic marker backspace.
55
+ * Undefined keeps native behavior entirely. */
56
+ clipboardImagePaste?: ClipboardImagePasteSurface;
57
+ }
58
+
59
+ /** Pi-tui Editor internals mirrored for O(1) atomic marker surgery. All are
60
+ * runtime-present on the Editor prototype chain but TS-private; the typed
61
+ * cast follows the autocompleteState-access pattern used in render(). */
62
+ interface EditorInternals {
63
+ readonly state: { lines: string[]; cursorLine: number; cursorCol: number };
64
+ lastAction: string | null;
65
+ readonly onChange?: (text: string) => void;
66
+ exitHistoryBrowsing(): void;
67
+ setCursorCol(col: number): void;
68
+ cancelAutocomplete(): void;
40
69
  }
41
70
 
42
71
  interface RenderPlan {
@@ -56,9 +85,13 @@ const widthOf = visibleWidth;
56
85
 
57
86
  function widthSafe(value: string, width: number): string {
58
87
  if (width <= 0) return "";
59
- const fitted = widthOf(value) > width ? truncateAnsi(value, width, "") : value;
60
- const current = widthOf(fitted);
61
- return current < width ? fitted + " ".repeat(width - current) : fitted;
88
+ const measured = widthOf(value);
89
+ if (measured > width) {
90
+ const fitted = truncateAnsi(value, width, "");
91
+ const current = widthOf(fitted);
92
+ return current < width ? fitted + " ".repeat(width - current) : fitted;
93
+ }
94
+ return measured < width ? value + " ".repeat(width - measured) : value;
62
95
  }
63
96
 
64
97
  function isNativeBorderLine(line: string): boolean {
@@ -142,8 +175,14 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
142
175
  private readonly fullTheme: EditorOptions["fullTheme"];
143
176
  private readonly onSnapshot: (snapshot: StatusSnapshot) => void;
144
177
  private semantic: ResolvedTheme;
178
+ /** Set when config changes; invalidate() then rebuilds the (otherwise stable) semantic theme. */
179
+ private semanticDirty = false;
145
180
  private disposed = false;
146
181
  private renderPlanCache: { key: string; plan: RenderPlan } | undefined;
182
+ private readonly clipboardImagePaste: ClipboardImagePasteSurface | undefined;
183
+ /** Keybinding matcher (the factory's KeybindingsManager) for keystroke-level
184
+ * interception: the paste keybinding and backspace checks below. */
185
+ private readonly editorKeybindings: { matches(data: string, keybinding: string): boolean };
147
186
 
148
187
  constructor(tui: Tui, theme: PiEditorTheme, keybindings: Keybindings, options: EditorOptions) {
149
188
  super(tui, theme, keybindings);
@@ -152,6 +191,8 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
152
191
  this.piTheme = theme;
153
192
  this.fullTheme = options.fullTheme;
154
193
  this.onSnapshot = options.onSnapshot;
194
+ this.clipboardImagePaste = options.clipboardImagePaste;
195
+ this.editorKeybindings = keybindings as unknown as { matches(data: string, keybinding: string): boolean };
155
196
  this.semantic = semanticTheme(theme, options.config);
156
197
  this.setPaddingX(0);
157
198
  }
@@ -165,19 +206,115 @@ export class StyledEditor extends CustomEditor implements EditorComponent {
165
206
  configure(config: NormalizedPiStyleConfig): void {
166
207
  if (this.disposed) return;
167
208
  this.config = config;
168
- this.semantic = semanticTheme(this.piTheme, config);
209
+ this.semanticDirty = true;
169
210
  this.invalidate();
170
211
  }
171
212
 
172
213
  override handleInput(data: string): void {
173
214
  if (this.disposed) return;
215
+ if (this.handleClipboardImagePasteKeystroke(data)) return;
216
+ if (this.handleAtomicMarkerBackspace(data)) return;
174
217
  super.handleInput(data);
175
218
  this.onSnapshot({ ...this.snapshot });
176
219
  }
177
220
 
221
+ /**
222
+ * Instant marker (ADR 0009): when the built-in paste keybinding fires, the
223
+ * clipboard holds an image (sync native probe), and the surface is enabled,
224
+ * the keystroke is owned here — an `[Image #N] ` marker is inserted
225
+ * immediately (zero-latency feedback) and the bytes fill the registry
226
+ * entry asynchronously. Pi's own paste handler never runs for this
227
+ * keystroke, so no temp artifact is written. Text pastes (no image) and
228
+ * probe-unavailable hosts fall through to the native path untouched.
229
+ */
230
+ private handleClipboardImagePasteKeystroke(data: string): boolean {
231
+ const surface = this.clipboardImagePaste;
232
+ if (!surface?.enabled()) return false;
233
+ if (!this.editorKeybindings.matches(data, "app.clipboard.pasteImage")) return false;
234
+ if (surface.clipboardHasImage() !== true) return false;
235
+ super.insertTextAtCursor(surface.markerFromClipboard());
236
+ this.onSnapshot({ ...this.snapshot });
237
+ return true;
238
+ }
239
+
240
+ /**
241
+ * Atomic marker backspace (ADR 0009): backspacing directly after a
242
+ * registered `[Image #N] ` marker deletes the whole marker as one unit —
243
+ * the image is discarded (image-paste semantics). Direct state surgery
244
+ * (O(1)) mirroring pi-tui's handleBackspace mutation protocol: no setText
245
+ * round-trip (it clears the pastes registry, and undo snapshots store that
246
+ * Map by reference — silently breaking `[paste #N]` atomicity on undo) and
247
+ * no per-column left-arrow inputs (O(K·L) through the keybinding chain and
248
+ * visual line maps). No undo snapshot is pushed: the discarded image
249
+ * cannot be restored, so the deletion is intentionally non-undoable.
250
+ */
251
+ private handleAtomicMarkerBackspace(data: string): boolean {
252
+ const surface = this.clipboardImagePaste;
253
+ if (!surface?.enabled()) return false;
254
+ const isBackspace = this.editorKeybindings.matches(data, "tui.editor.deleteCharBackward") || data === "\x7f";
255
+ if (!isBackspace) return false;
256
+ const lines = this.getLines();
257
+ const cursor = this.getCursor();
258
+ const current = lines[cursor.line];
259
+ if (current === undefined) return false;
260
+ const before = current.slice(0, cursor.col);
261
+ const hit = clipboardMarkerAtEnd(before);
262
+ if (!hit || !surface.isMarkerIndexRegistered(hit.index)) return false;
263
+ const editor = this as unknown as EditorInternals;
264
+ const targetCol = cursor.col - hit.length;
265
+ editor.exitHistoryBrowsing();
266
+ editor.lastAction = null;
267
+ editor.cancelAutocomplete();
268
+ // Splice the marker out of its line in place (deletion never removes a
269
+ // line, so cursorLine is unchanged). setCursorCol also clears
270
+ // preferredVisualCol/snappedFromCursorCol. scrollOffset is left alone;
271
+ // render() re-clamps it to keep the cursor visible.
272
+ editor.state.lines[cursor.line] = current.slice(0, targetCol) + current.slice(cursor.col);
273
+ editor.setCursorCol(targetCol);
274
+ editor.onChange?.(this.getText());
275
+ surface.discardMarkerIndex(hit.index);
276
+ this.invalidate();
277
+ this.onSnapshot({ ...this.snapshot });
278
+ return true;
279
+ }
280
+
281
+ /**
282
+ * Artifact-path fallback (ADR 0009): when the keystroke was not owned
283
+ * (probe unavailable, native editor path, config toggled) Pi's built-in
284
+ * paste still inserts a `<tmpdir>/pi-clipboard-<uuid>.<ext>` path through
285
+ * this method. Convert it to a marker when the artifact reads successfully;
286
+ * every failure inserts the original text verbatim (exact native behavior).
287
+ */
288
+ override insertTextAtCursor(text: string): void {
289
+ const surface = this.clipboardImagePaste;
290
+ if (!surface?.enabled() || !isSingleClipboardImagePath(text)) {
291
+ super.insertTextAtCursor(text);
292
+ return;
293
+ }
294
+ void (async () => {
295
+ let replacement = text;
296
+ try {
297
+ replacement = await surface.markerFromArtifact(text);
298
+ } catch {
299
+ // Unreadable artifact or surface failure: keep native behavior.
300
+ }
301
+ super.insertTextAtCursor(replacement);
302
+ // The caller (Pi's handleClipboardPaste) requested its render before
303
+ // this async insert completed — without a repaint the marker stays
304
+ // invisible until the next keystroke. Request one now.
305
+ this.tui.requestRender();
306
+ })();
307
+ }
308
+
178
309
  override invalidate(): void {
179
310
  super.invalidate();
180
- this.semantic = semanticTheme(this.piTheme, this.config);
311
+ // The semantic theme is derived from (piTheme, config); piTheme is fixed per
312
+ // instance and config changes go through configure(), so keystroke-driven
313
+ // invalidations reuse the cached instance instead of rebuilding it.
314
+ if (this.semanticDirty) {
315
+ this.semantic = semanticTheme(this.piTheme, this.config);
316
+ this.semanticDirty = false;
317
+ }
181
318
  this.renderPlanCache = undefined;
182
319
  this.tui.requestRender();
183
320
  }
@@ -400,6 +537,7 @@ export function installEditor(options: {
400
537
  generation: number;
401
538
  initialSnapshot: StatusSnapshot;
402
539
  isCurrent?: () => boolean;
540
+ clipboardImagePaste?: EditorOptions["clipboardImagePaste"];
403
541
  }): EditorInstallation | undefined {
404
542
  if (!options.host.setEditorComponent) return undefined;
405
543
  const previous = options.host.getEditorComponent?.();
@@ -424,6 +562,7 @@ export function installEditor(options: {
424
562
  snapshot,
425
563
  theme,
426
564
  ...(options.host.theme ? { fullTheme: options.host.theme } : {}),
565
+ ...(options.clipboardImagePaste ? { clipboardImagePaste: options.clipboardImagePaste } : {}),
427
566
  onSnapshot: (next) => {
428
567
  if (!disposed && options.isCurrent?.() !== false) snapshot = next;
429
568
  },