@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.
- package/CHANGELOG.md +21 -0
- package/README.md +8 -3
- package/dist/extensions/pi-style.js +1328 -333
- package/dist/extensions/pi-style.js.map +1 -1
- package/extension-src/pi-style/app/command-service.ts +8 -0
- package/extension-src/pi-style/app/runtime.ts +33 -4
- package/extension-src/pi-style/domain/config-normalization.ts +11 -1
- package/extension-src/pi-style/domain/config-types.ts +12 -0
- package/extension-src/pi-style/domain/status-renderer.ts +41 -9
- package/extension-src/pi-style/domain/status.ts +28 -12
- package/extension-src/pi-style/domain/theme.ts +32 -1
- package/extension-src/pi-style/features/editor/index.ts +144 -5
- package/extension-src/pi-style/features/messages/image-input.ts +205 -0
- package/extension-src/pi-style/features/messages/image-preview.ts +288 -0
- package/extension-src/pi-style/features/messages/index.ts +302 -61
- package/extension-src/pi-style/features/messages/render-config.ts +36 -0
- package/extension-src/pi-style/features/status-line/index.ts +41 -11
- package/extension-src/pi-style/features/tools/boxed/bash.ts +101 -40
- package/extension-src/pi-style/features/tools/boxed/batch.ts +17 -1
- package/extension-src/pi-style/features/tools/boxed/edit.ts +20 -15
- package/extension-src/pi-style/features/tools/boxed/find.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/git.ts +46 -2
- package/extension-src/pi-style/features/tools/boxed/grep.ts +9 -2
- package/extension-src/pi-style/features/tools/boxed/ls.ts +9 -4
- package/extension-src/pi-style/features/tools/boxed/quick-edit.ts +18 -11
- package/extension-src/pi-style/features/tools/boxed/read.ts +4 -2
- package/extension-src/pi-style/features/tools/boxed/shared.ts +27 -1
- package/extension-src/pi-style/features/tools/boxed/turn-summary.ts +12 -0
- package/extension-src/pi-style/pi/index.ts +55 -2
- package/extension-src/pi-style/pi/session-coordinator.ts +13 -0
- package/extension-src/pi-style/shared/ansi.ts +17 -5
- package/extension-src/pi-style/shared/box.ts +70 -4
- package/extension-src/pi-style/shared/clipboard-path.ts +98 -0
- package/extension-src/pi-style/shared/clipboard-presence.ts +112 -0
- package/extension-src/pi-style/shared/pending-images.ts +199 -0
- package/extension-src/pi-style/shared/split-diff.ts +8 -5
- package/package.json +1 -1
- 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
|
-
|
|
349
|
-
|
|
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
|
-
|
|
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: "
|
|
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, {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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:
|
|
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
|
-
|
|
313
|
-
|
|
314
|
-
|
|
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
|
-
|
|
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
|
|
60
|
-
|
|
61
|
-
|
|
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.
|
|
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
|
-
|
|
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
|
},
|