@heyhuynhgiabuu/pi-pretty 0.6.26 → 0.6.27
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 +18 -0
- package/README.md +34 -17
- package/dist/config.d.ts +16 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +30 -0
- package/dist/config.js.map +1 -1
- package/dist/fff.d.ts +16 -1
- package/dist/fff.d.ts.map +1 -1
- package/dist/fff.js +139 -33
- package/dist/fff.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +70 -24
- package/dist/index.js.map +1 -1
- package/dist/prompt-editor.d.ts +11 -0
- package/dist/prompt-editor.d.ts.map +1 -0
- package/dist/prompt-editor.js +78 -0
- package/dist/prompt-editor.js.map +1 -0
- package/dist/render.d.ts +9 -1
- package/dist/render.d.ts.map +1 -1
- package/dist/render.js +25 -9
- package/dist/render.js.map +1 -1
- package/dist/tools/bash.d.ts.map +1 -1
- package/dist/tools/bash.js +73 -30
- package/dist/tools/bash.js.map +1 -1
- package/dist/tools/find.d.ts.map +1 -1
- package/dist/tools/find.js +29 -10
- package/dist/tools/find.js.map +1 -1
- package/dist/tools/grep.d.ts.map +1 -1
- package/dist/tools/grep.js +20 -10
- package/dist/tools/grep.js.map +1 -1
- package/dist/tools/ls.d.ts.map +1 -1
- package/dist/tools/ls.js +10 -5
- package/dist/tools/ls.js.map +1 -1
- package/dist/tools/metrics.d.ts +6 -1
- package/dist/tools/metrics.d.ts.map +1 -1
- package/dist/tools/metrics.js +21 -8
- package/dist/tools/metrics.js.map +1 -1
- package/dist/tools/read.d.ts +2 -1
- package/dist/tools/read.d.ts.map +1 -1
- package/dist/tools/read.js +94 -31
- package/dist/tools/read.js.map +1 -1
- package/dist/types.d.ts +9 -3
- package/dist/types.d.ts.map +1 -1
- package/dist/working-indicator.d.ts +39 -31
- package/dist/working-indicator.d.ts.map +1 -1
- package/dist/working-indicator.js +114 -87
- package/dist/working-indicator.js.map +1 -1
- package/package.json +1 -1
- package/src/config.ts +51 -0
- package/src/fff.ts +158 -29
- package/src/index.ts +77 -24
- package/src/prompt-editor.ts +102 -0
- package/src/render.ts +30 -11
- package/src/tools/bash.ts +94 -32
- package/src/tools/find.ts +39 -17
- package/src/tools/grep.ts +26 -15
- package/src/tools/ls.ts +19 -10
- package/src/tools/metrics.ts +28 -8
- package/src/tools/read.ts +123 -31
- package/src/types.ts +10 -3
- package/src/working-indicator.ts +123 -95
package/src/fff.ts
CHANGED
|
@@ -6,11 +6,44 @@
|
|
|
6
6
|
* Graceful fallback: if FFF is not installed or fails, tools degrade to SDK.
|
|
7
7
|
*/
|
|
8
8
|
|
|
9
|
-
import { mkdirSync } from "node:fs";
|
|
10
|
-
import {
|
|
9
|
+
import { mkdirSync, realpathSync } from "node:fs";
|
|
10
|
+
import { homedir } from "node:os";
|
|
11
|
+
import { join, parse, resolve } from "node:path";
|
|
11
12
|
|
|
12
13
|
import type { FileFinder as FffFileFinder, GrepCursor } from "@ff-labs/fff-node";
|
|
13
14
|
|
|
15
|
+
export interface FffInitOptions {
|
|
16
|
+
enableHomeDirScanning: boolean;
|
|
17
|
+
enableFsRootScanning: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export type FffInitFailureKind = "restricted-base-path" | "native";
|
|
21
|
+
export type FffRestrictedScanScope = "home" | "root";
|
|
22
|
+
|
|
23
|
+
export class FffInitError extends Error {
|
|
24
|
+
readonly kind: FffInitFailureKind;
|
|
25
|
+
readonly restrictedScope: FffRestrictedScanScope | undefined;
|
|
26
|
+
|
|
27
|
+
constructor(kind: FffInitFailureKind, message: string, restrictedScope?: FffRestrictedScanScope) {
|
|
28
|
+
super(message);
|
|
29
|
+
this.name = "FffInitError";
|
|
30
|
+
this.kind = kind;
|
|
31
|
+
this.restrictedScope = restrictedScope;
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
export function isFffRestrictedBasePathError(error: unknown, options: FffInitOptions): boolean {
|
|
36
|
+
if (!(error instanceof FffInitError) || error.kind !== "restricted-base-path") return false;
|
|
37
|
+
if (error.restrictedScope === "home") return !options.enableHomeDirScanning;
|
|
38
|
+
if (error.restrictedScope === "root") return !options.enableFsRootScanning;
|
|
39
|
+
return false;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
const DEFAULT_FFF_INIT_OPTIONS: FffInitOptions = {
|
|
43
|
+
enableHomeDirScanning: false,
|
|
44
|
+
enableFsRootScanning: false,
|
|
45
|
+
};
|
|
46
|
+
|
|
14
47
|
// ---------------------------------------------------------------------------
|
|
15
48
|
// Cursor store — pagination cursors for grep
|
|
16
49
|
// ---------------------------------------------------------------------------
|
|
@@ -53,6 +86,12 @@ type SharedFffState = {
|
|
|
53
86
|
service: FffService;
|
|
54
87
|
};
|
|
55
88
|
|
|
89
|
+
type FinderConfig = {
|
|
90
|
+
basePath: string;
|
|
91
|
+
enableHomeDirScanning: boolean;
|
|
92
|
+
enableFsRootScanning: boolean;
|
|
93
|
+
};
|
|
94
|
+
|
|
56
95
|
type GlobalWithFffService = typeof globalThis & {
|
|
57
96
|
[SHARED_FFF_SERVICE_KEY]?: SharedFffState;
|
|
58
97
|
};
|
|
@@ -83,7 +122,9 @@ export class FffService {
|
|
|
83
122
|
|
|
84
123
|
private fffModule: typeof import("@ff-labs/fff-node") | null = null;
|
|
85
124
|
private dbDir: string | null = null;
|
|
125
|
+
private finderConfig: FinderConfig | null = null;
|
|
86
126
|
private finderPromise: Promise<void> | null = null;
|
|
127
|
+
private finderPromiseConfig: FinderConfig | null = null;
|
|
87
128
|
|
|
88
129
|
constructor(fffModule?: typeof import("@ff-labs/fff-node"), agentDir?: string) {
|
|
89
130
|
this.configure(fffModule, agentDir);
|
|
@@ -130,47 +171,97 @@ export class FffService {
|
|
|
130
171
|
}
|
|
131
172
|
}
|
|
132
173
|
|
|
133
|
-
async ensureFinder(cwd: string): Promise<void> {
|
|
134
|
-
|
|
135
|
-
if (this.finderPromise) return this.finderPromise;
|
|
174
|
+
async ensureFinder(cwd: string, options: FffInitOptions = DEFAULT_FFF_INIT_OPTIONS): Promise<void> {
|
|
175
|
+
const requestedConfig = finderConfig(cwd, options);
|
|
136
176
|
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
177
|
+
for (;;) {
|
|
178
|
+
if (this.finder && !this.finder.isDestroyed && sameFinderConfig(this.finderConfig, requestedConfig)) return;
|
|
179
|
+
|
|
180
|
+
const pending = this.finderPromise;
|
|
181
|
+
if (pending) {
|
|
182
|
+
const pendingConfig = this.finderPromiseConfig;
|
|
183
|
+
try {
|
|
184
|
+
await pending;
|
|
185
|
+
} catch (error) {
|
|
186
|
+
// A concurrent request for the same scope must observe the same
|
|
187
|
+
// failure. A different scope can retry after the pending attempt.
|
|
188
|
+
if (sameFinderConfig(pendingConfig, requestedConfig)) throw error;
|
|
189
|
+
}
|
|
190
|
+
continue;
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
const promise = this._createFinder(cwd, options, requestedConfig);
|
|
194
|
+
this.finderPromise = promise;
|
|
195
|
+
this.finderPromiseConfig = requestedConfig;
|
|
196
|
+
try {
|
|
197
|
+
await promise;
|
|
198
|
+
return;
|
|
199
|
+
} finally {
|
|
200
|
+
if (this.finderPromise === promise) {
|
|
201
|
+
this.finderPromise = null;
|
|
202
|
+
this.finderPromiseConfig = null;
|
|
203
|
+
}
|
|
204
|
+
}
|
|
143
205
|
}
|
|
144
206
|
}
|
|
145
207
|
|
|
146
|
-
private async _createFinder(cwd: string): Promise<void> {
|
|
208
|
+
private async _createFinder(cwd: string, options: FffInitOptions, config: FinderConfig): Promise<void> {
|
|
147
209
|
if (!this.fffModule) return;
|
|
148
210
|
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
211
|
+
const result = this.createFinder(cwd, this.dbDir, options);
|
|
212
|
+
if (!result.ok) {
|
|
213
|
+
const kind = isRestrictedBasePathMessage(result.error) ? "restricted-base-path" : "native";
|
|
214
|
+
throw new FffInitError(
|
|
215
|
+
kind,
|
|
216
|
+
result.error,
|
|
217
|
+
kind === "restricted-base-path" ? restrictedScanScope(cwd) : undefined,
|
|
218
|
+
);
|
|
152
219
|
}
|
|
153
220
|
|
|
154
|
-
const
|
|
155
|
-
|
|
156
|
-
|
|
221
|
+
const candidate = result.value;
|
|
222
|
+
let scan: Awaited<ReturnType<typeof candidate.waitForScan>>;
|
|
223
|
+
try {
|
|
224
|
+
scan = await candidate.waitForScan(15_000);
|
|
225
|
+
} catch (error) {
|
|
226
|
+
if (!candidate.isDestroyed) {
|
|
227
|
+
try {
|
|
228
|
+
candidate.destroy();
|
|
229
|
+
} catch {
|
|
230
|
+
// Preserve the scan failure; the candidate is no longer selected.
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
throw error;
|
|
157
234
|
}
|
|
158
235
|
|
|
159
|
-
|
|
160
|
-
|
|
236
|
+
const previous = this.finder;
|
|
237
|
+
this.finder = candidate;
|
|
161
238
|
this.partialIndex = scan.ok && !scan.value;
|
|
239
|
+
this.finderConfig = config;
|
|
240
|
+
if (previous && previous !== candidate && !previous.isDestroyed) {
|
|
241
|
+
try {
|
|
242
|
+
previous.destroy();
|
|
243
|
+
} catch {
|
|
244
|
+
// Keep the replacement active; native cleanup is best effort.
|
|
245
|
+
}
|
|
246
|
+
}
|
|
162
247
|
}
|
|
163
248
|
|
|
164
249
|
private createFinder(
|
|
165
250
|
cwd: string,
|
|
166
251
|
dbDir: string | null,
|
|
252
|
+
options: FffInitOptions,
|
|
167
253
|
): ReturnType<typeof import("@ff-labs/fff-node").FileFinder.create> {
|
|
168
254
|
if (!this.fffModule) throw new Error("FFF module is not loaded");
|
|
169
|
-
const
|
|
255
|
+
const commonOptions = {
|
|
170
256
|
basePath: cwd,
|
|
257
|
+
aiMode: true,
|
|
258
|
+
enableHomeDirScanning: options.enableHomeDirScanning,
|
|
259
|
+
enableFsRootScanning: options.enableFsRootScanning,
|
|
260
|
+
};
|
|
261
|
+
const result = this.fffModule.FileFinder.create({
|
|
262
|
+
...commonOptions,
|
|
171
263
|
frecencyDbPath: dbDir ? join(dbDir, "frecency.mdb") : "",
|
|
172
264
|
historyDbPath: dbDir ? join(dbDir, "history.mdb") : "",
|
|
173
|
-
aiMode: true,
|
|
174
265
|
});
|
|
175
266
|
if (result.ok || !isAlreadyOpenError(result.error) || !dbDir) return result;
|
|
176
267
|
|
|
@@ -181,20 +272,23 @@ export class FffService {
|
|
|
181
272
|
return result;
|
|
182
273
|
}
|
|
183
274
|
return this.fffModule.FileFinder.create({
|
|
184
|
-
|
|
275
|
+
...commonOptions,
|
|
185
276
|
frecencyDbPath: join(isolatedDir, "frecency.mdb"),
|
|
186
277
|
historyDbPath: join(isolatedDir, "history.mdb"),
|
|
187
|
-
aiMode: true,
|
|
188
278
|
});
|
|
189
279
|
}
|
|
190
280
|
|
|
191
281
|
destroy(): void {
|
|
192
|
-
|
|
193
|
-
this.finder.destroy();
|
|
194
|
-
this.finder = null;
|
|
195
|
-
}
|
|
196
|
-
this.partialIndex = false;
|
|
282
|
+
this.disposeFinder();
|
|
197
283
|
this.finderPromise = null;
|
|
284
|
+
this.finderPromiseConfig = null;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
private disposeFinder(): void {
|
|
288
|
+
if (this.finder && !this.finder.isDestroyed) this.finder.destroy();
|
|
289
|
+
this.finder = null;
|
|
290
|
+
this.finderConfig = null;
|
|
291
|
+
this.partialIndex = false;
|
|
198
292
|
}
|
|
199
293
|
|
|
200
294
|
getFinder(): import("@ff-labs/fff-node").FileFinder | null {
|
|
@@ -206,6 +300,41 @@ export class FffService {
|
|
|
206
300
|
}
|
|
207
301
|
}
|
|
208
302
|
|
|
303
|
+
function finderConfig(cwd: string, options: FffInitOptions): FinderConfig {
|
|
304
|
+
return {
|
|
305
|
+
basePath: canonicalPath(cwd),
|
|
306
|
+
enableHomeDirScanning: options.enableHomeDirScanning,
|
|
307
|
+
enableFsRootScanning: options.enableFsRootScanning,
|
|
308
|
+
};
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
function sameFinderConfig(left: FinderConfig | null, right: FinderConfig): boolean {
|
|
312
|
+
return (
|
|
313
|
+
left?.basePath === right.basePath &&
|
|
314
|
+
left.enableHomeDirScanning === right.enableHomeDirScanning &&
|
|
315
|
+
left.enableFsRootScanning === right.enableFsRootScanning
|
|
316
|
+
);
|
|
317
|
+
}
|
|
318
|
+
|
|
209
319
|
function isAlreadyOpenError(error: string): boolean {
|
|
210
320
|
return /environment already open/i.test(error);
|
|
211
321
|
}
|
|
322
|
+
|
|
323
|
+
function isRestrictedBasePathMessage(error: string): boolean {
|
|
324
|
+
return /certain FFF features in a file system root or home directories/i.test(error);
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
function restrictedScanScope(cwd: string): FffRestrictedScanScope | undefined {
|
|
328
|
+
const basePath = canonicalPath(cwd);
|
|
329
|
+
if (basePath === parse(basePath).root) return "root";
|
|
330
|
+
if (basePath === canonicalPath(homedir())) return "home";
|
|
331
|
+
return undefined;
|
|
332
|
+
}
|
|
333
|
+
|
|
334
|
+
function canonicalPath(path: string): string {
|
|
335
|
+
try {
|
|
336
|
+
return realpathSync.native(path);
|
|
337
|
+
} catch {
|
|
338
|
+
return resolve(path);
|
|
339
|
+
}
|
|
340
|
+
}
|
package/src/index.ts
CHANGED
|
@@ -21,8 +21,16 @@ import type {
|
|
|
21
21
|
} from "@earendil-works/pi-coding-agent";
|
|
22
22
|
import * as hostSdk from "@earendil-works/pi-coding-agent";
|
|
23
23
|
import { createFffAutocompleteProvider } from "./autocomplete.js";
|
|
24
|
-
import {
|
|
25
|
-
|
|
24
|
+
import {
|
|
25
|
+
applyConfig,
|
|
26
|
+
getDefaultAgentDir,
|
|
27
|
+
loadConfig,
|
|
28
|
+
normalizeToolList,
|
|
29
|
+
resolveFffScanSettings,
|
|
30
|
+
resolveToolSets,
|
|
31
|
+
} from "./config.js";
|
|
32
|
+
import { type FffInitOptions, type FffService, getSharedFffService, isFffRestrictedBasePathError } from "./fff.js";
|
|
33
|
+
import { createPromptEditorClass } from "./prompt-editor.js";
|
|
26
34
|
import { registerBashTool } from "./tools/bash.js";
|
|
27
35
|
import { registerFindTool } from "./tools/find.js";
|
|
28
36
|
import { registerGrepTool } from "./tools/grep.js";
|
|
@@ -41,7 +49,6 @@ import {
|
|
|
41
49
|
thinkingBlockActive,
|
|
42
50
|
WORKING_INTERVAL_MS,
|
|
43
51
|
type WorkingIndicatorController,
|
|
44
|
-
workingTokens,
|
|
45
52
|
} from "./working-indicator.js";
|
|
46
53
|
|
|
47
54
|
// ---------------------------------------------------------------------------
|
|
@@ -49,6 +56,12 @@ import {
|
|
|
49
56
|
// ---------------------------------------------------------------------------
|
|
50
57
|
|
|
51
58
|
const DEFAULT_DISABLED_TOOLS = new Set(["ls"]);
|
|
59
|
+
const USER_MESSAGE_ICON = "❯";
|
|
60
|
+
type EditorFactory = Parameters<ExtensionContext["ui"]["setEditorComponent"]>[0];
|
|
61
|
+
type EditorUiCompatibility = {
|
|
62
|
+
setEditorComponent?: (factory: EditorFactory) => void;
|
|
63
|
+
getEditorComponent?: () => EditorFactory;
|
|
64
|
+
};
|
|
52
65
|
|
|
53
66
|
function envTools(name: "PRETTY_DISABLE_TOOLS" | "PRETTY_ENABLE_TOOLS"): Set<string> {
|
|
54
67
|
return new Set(normalizeToolList((process.env[name] ?? "").split(",")));
|
|
@@ -63,6 +76,26 @@ export type { PiPrettyDeps };
|
|
|
63
76
|
export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrettyDeps): Promise<void> {
|
|
64
77
|
const config = loadConfig();
|
|
65
78
|
applyConfig(config);
|
|
79
|
+
pi.registerMarkdownTransformer?.((markdown, context) => {
|
|
80
|
+
if (context.messageType !== "user" || markdown.trim() === "") return markdown;
|
|
81
|
+
return ` ${USER_MESSAGE_ICON} ${markdown}`;
|
|
82
|
+
});
|
|
83
|
+
pi.registerFlag?.("pretty-fff-home-scan", {
|
|
84
|
+
description: "Allow FFF to index the home directory when Pi starts there",
|
|
85
|
+
type: "boolean",
|
|
86
|
+
});
|
|
87
|
+
pi.registerFlag?.("pretty-fff-root-scan", {
|
|
88
|
+
description: "Allow FFF to index the filesystem root when Pi starts there",
|
|
89
|
+
type: "boolean",
|
|
90
|
+
});
|
|
91
|
+
const fffScanSettings = resolveFffScanSettings(config, {
|
|
92
|
+
enableHomeScanning: pi.getFlag?.("pretty-fff-home-scan"),
|
|
93
|
+
enableRootScanning: pi.getFlag?.("pretty-fff-root-scan"),
|
|
94
|
+
});
|
|
95
|
+
const fffInitOptions: FffInitOptions = {
|
|
96
|
+
enableHomeDirScanning: fffScanSettings.enableHomeScanning,
|
|
97
|
+
enableFsRootScanning: fffScanSettings.enableRootScanning,
|
|
98
|
+
};
|
|
66
99
|
const { disabledTools, enabledTools } = resolveToolSets(
|
|
67
100
|
envTools("PRETTY_DISABLE_TOOLS"),
|
|
68
101
|
envTools("PRETTY_ENABLE_TOOLS"),
|
|
@@ -171,12 +204,29 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
171
204
|
let thinkingHiddenCheckedAt = 0;
|
|
172
205
|
let workingSessionName: string | undefined;
|
|
173
206
|
let workingStreaming = false;
|
|
174
|
-
let
|
|
207
|
+
let promptEditorInstalled = false;
|
|
208
|
+
let previousEditorFactory: EditorFactory;
|
|
209
|
+
const hostCustomEditor =
|
|
210
|
+
deps?.customEditorClass ??
|
|
211
|
+
(hostSdk as unknown as { CustomEditor?: PiPrettyDeps["customEditorClass"] }).CustomEditor;
|
|
212
|
+
const installPromptEditor = (ctx: ExtensionContext): void => {
|
|
213
|
+
if (ctx.mode !== "tui" || promptEditorInstalled || typeof hostCustomEditor !== "function") return;
|
|
214
|
+
const editorUi = ctx.ui as unknown as EditorUiCompatibility;
|
|
215
|
+
if (typeof editorUi.setEditorComponent !== "function") return;
|
|
216
|
+
previousEditorFactory = editorUi.getEditorComponent?.();
|
|
217
|
+
const PromptEditor = createPromptEditorClass(hostCustomEditor, (icon) => {
|
|
218
|
+
const theme = ctx.ui.theme;
|
|
219
|
+
return typeof theme.getThinkingBorderColor === "function"
|
|
220
|
+
? theme.getThinkingBorderColor(ctx.thinkingLevel ?? "off")(icon)
|
|
221
|
+
: theme.fg("thinkingText", icon);
|
|
222
|
+
});
|
|
223
|
+
editorUi.setEditorComponent((tui, theme, keybindings) => new PromptEditor(tui, theme, keybindings));
|
|
224
|
+
promptEditorInstalled = true;
|
|
225
|
+
};
|
|
175
226
|
let perRowLabels: PerRowThinkingLabels | undefined;
|
|
176
227
|
/** Accumulated thinking time per message timestamp — later runs in the same
|
|
177
228
|
* message resume from this total instead of rewinding to zero. */
|
|
178
229
|
const thinkingElapsedMs = new Map<number, number>();
|
|
179
|
-
const TOKEN_COUNT_FORMAT = new Intl.NumberFormat("en-US");
|
|
180
230
|
|
|
181
231
|
/** Message timestamps are the per-row identity the label patch keys on. */
|
|
182
232
|
const messageTimestamp = (message: unknown): number | undefined => {
|
|
@@ -217,8 +267,8 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
217
267
|
}
|
|
218
268
|
};
|
|
219
269
|
|
|
220
|
-
// (Re-)install the
|
|
221
|
-
// accent tint tracks the name, omp-style). Preserves streaming state so a
|
|
270
|
+
// (Re-)install the working indicator: new session, reload, or session rename
|
|
271
|
+
// (the accent tint tracks the name, omp-style). Preserves streaming state so a
|
|
222
272
|
// mid-stream rename does not drop the row.
|
|
223
273
|
const installIndicator = async (ctx: ExtensionContext): Promise<void> => {
|
|
224
274
|
workingController?.dispose();
|
|
@@ -244,6 +294,7 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
244
294
|
|
|
245
295
|
pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
246
296
|
if (ctx.mode === "tui") {
|
|
297
|
+
installPromptEditor(ctx);
|
|
247
298
|
ctx.ui.setToolsExpanded(false);
|
|
248
299
|
// Per-row hidden-thinking labels: intercept the host's label fan-out so
|
|
249
300
|
// each row keeps its own duration. Falls back to the global-label
|
|
@@ -267,7 +318,7 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
267
318
|
if (!loaded) return;
|
|
268
319
|
}
|
|
269
320
|
|
|
270
|
-
await fffService.ensureFinder(ctx.cwd);
|
|
321
|
+
await fffService.ensureFinder(ctx.cwd, fffInitOptions);
|
|
271
322
|
if (fffService.partialIndex) {
|
|
272
323
|
ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
|
|
273
324
|
} else {
|
|
@@ -281,7 +332,10 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
281
332
|
createFffAutocompleteProvider(current, () => fffService?.getFinder() ?? null),
|
|
282
333
|
);
|
|
283
334
|
} catch (error: unknown) {
|
|
284
|
-
|
|
335
|
+
// Root/home refusal is an intentional FFF safety boundary. FFF is an
|
|
336
|
+
// accelerator, so fall back to the SDK without interrupting startup.
|
|
337
|
+
if (isFffRestrictedBasePathError(error, fffInitOptions)) return;
|
|
338
|
+
ctx.ui?.notify?.(`FFF init failed: ${error instanceof Error ? error.message : String(error)}`, "warning");
|
|
285
339
|
}
|
|
286
340
|
});
|
|
287
341
|
|
|
@@ -294,12 +348,10 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
294
348
|
if (workingSettings.enabled) await installIndicator(ctx);
|
|
295
349
|
});
|
|
296
350
|
|
|
297
|
-
// Drive the
|
|
351
|
+
// Drive the working indicator with the streaming lifecycle (the host row is hidden until agent_start).
|
|
298
352
|
pi.on("agent_start", async (_event: unknown, ctx: ExtensionContext) => {
|
|
299
353
|
if (ctx.mode !== "tui") return;
|
|
300
354
|
workingStreaming = true;
|
|
301
|
-
workingStatsUpdatedAt = 0;
|
|
302
|
-
workingController?.setStats(undefined);
|
|
303
355
|
workingController?.start();
|
|
304
356
|
});
|
|
305
357
|
const stopStreaming = async (_event: unknown, ctx: ExtensionContext): Promise<void> => {
|
|
@@ -388,8 +440,8 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
388
440
|
return;
|
|
389
441
|
}
|
|
390
442
|
thinkingTimer?.tick();
|
|
391
|
-
// The label has no widget of its own —
|
|
392
|
-
//
|
|
443
|
+
// The label has no widget of its own — ask the working indicator to
|
|
444
|
+
// request a host render even when deltas pause mid-thinking.
|
|
393
445
|
workingController?.requestRender();
|
|
394
446
|
} catch (error: unknown) {
|
|
395
447
|
// A throwing host call inside our timer would crash pi (uncaught in
|
|
@@ -411,13 +463,6 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
411
463
|
pi.on("message_update", async (event: MessageUpdateEvent, ctx: ExtensionContext) => {
|
|
412
464
|
if (ctx.mode !== "tui" || !workingStreaming) return;
|
|
413
465
|
thinkingLastMessage = event.message;
|
|
414
|
-
// Live token suffix on the working row (throttled to one update/second).
|
|
415
|
-
const now = Date.now();
|
|
416
|
-
if (now - workingStatsUpdatedAt >= 1000) {
|
|
417
|
-
workingStatsUpdatedAt = now;
|
|
418
|
-
const tokens = workingTokens(event.message);
|
|
419
|
-
workingController?.setStats(tokens > 0 ? ` (↓ ${TOKEN_COUNT_FORMAT.format(tokens)} tokens)` : undefined);
|
|
420
|
-
}
|
|
421
466
|
if (thinkingInterval) {
|
|
422
467
|
// Complete immediately on the first text/tool delta instead of waiting
|
|
423
468
|
// for the next animation frame.
|
|
@@ -436,14 +481,22 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
|
|
|
436
481
|
// The completed wording belongs to the message that produced it. The label
|
|
437
482
|
// is global, so once that assistant message ends, restore pi's default —
|
|
438
483
|
// otherwise every later phase stamps its frozen duration onto every hidden
|
|
439
|
-
// thinking row in the transcript (the image-read "Thought for 5s" stack).
|
|
484
|
+
// thinking row in the transcript (the image-read " Thought for 5s" stack).
|
|
440
485
|
pi.on("message_end", async (event: MessageEndEvent, ctx: ExtensionContext) => {
|
|
441
486
|
if (ctx.mode !== "tui" || event.message.role !== "assistant") return;
|
|
442
487
|
if (thinkingInterval || thinkingTimer) stopThinkingShimmer();
|
|
443
488
|
});
|
|
444
489
|
|
|
445
|
-
pi.on("session_shutdown", async () => {
|
|
446
|
-
|
|
490
|
+
pi.on("session_shutdown", async (_event: unknown, ctx?: ExtensionContext) => {
|
|
491
|
+
if (promptEditorInstalled) {
|
|
492
|
+
if (ctx) {
|
|
493
|
+
const editorUi = ctx.ui as unknown as EditorUiCompatibility;
|
|
494
|
+
if (typeof editorUi.setEditorComponent === "function") editorUi.setEditorComponent(previousEditorFactory);
|
|
495
|
+
}
|
|
496
|
+
promptEditorInstalled = false;
|
|
497
|
+
previousEditorFactory = undefined;
|
|
498
|
+
}
|
|
499
|
+
// Tear down the indicator animation; pi re-runs session_start (and our
|
|
447
500
|
// install) after resume or session switching.
|
|
448
501
|
workingController?.dispose();
|
|
449
502
|
workingController = undefined;
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { CustomEditor } from "@earendil-works/pi-coding-agent";
|
|
2
|
+
import { sliceByColumn, visibleWidth } from "@earendil-works/pi-tui";
|
|
3
|
+
|
|
4
|
+
/** Constructor shape used to preserve compatibility with older Pi hosts. */
|
|
5
|
+
export type CustomEditorConstructor = new (...args: ConstructorParameters<typeof CustomEditor>) => CustomEditor;
|
|
6
|
+
|
|
7
|
+
type PromptColor = (text: string) => string;
|
|
8
|
+
|
|
9
|
+
const PROMPT_ICON = "❯";
|
|
10
|
+
const PROMPT_WIDTH = 3; // one space on either side of the icon
|
|
11
|
+
|
|
12
|
+
/**
|
|
13
|
+
* Build a CustomEditor subclass that reserves room for, and renders, the
|
|
14
|
+
* user-message prompt. The host still owns all editing and app keybindings.
|
|
15
|
+
*/
|
|
16
|
+
export function createPromptEditorClass(
|
|
17
|
+
Base: CustomEditorConstructor,
|
|
18
|
+
colorPrompt: PromptColor,
|
|
19
|
+
): CustomEditorConstructor {
|
|
20
|
+
const basePrototype = Base.prototype as unknown as {
|
|
21
|
+
renderTopBorder?: unknown;
|
|
22
|
+
renderBottomBorder?: unknown;
|
|
23
|
+
};
|
|
24
|
+
const supportsBorderHooks =
|
|
25
|
+
typeof basePrototype.renderTopBorder === "function" && typeof basePrototype.renderBottomBorder === "function";
|
|
26
|
+
|
|
27
|
+
class PromptEditor extends Base {
|
|
28
|
+
private userPaddingX = 0;
|
|
29
|
+
private isScrolled = false;
|
|
30
|
+
private bottomBorderLine = "";
|
|
31
|
+
|
|
32
|
+
constructor(...args: ConstructorParameters<CustomEditorConstructor>) {
|
|
33
|
+
const [tui, theme, keybindings, options] = args;
|
|
34
|
+
super(tui, theme, keybindings, { ...(options ?? {}), embedWorkingStatus: true });
|
|
35
|
+
this.userPaddingX = super.getPaddingX();
|
|
36
|
+
super.setPaddingX(this.userPaddingX + this.promptWidth());
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
private promptWidth(): number {
|
|
40
|
+
// At zero user padding, reserve one extra column so the border leaves
|
|
41
|
+
// the same visible gap before the icon as padded editors.
|
|
42
|
+
return PROMPT_WIDTH + (supportsBorderHooks && this.userPaddingX === 0 ? 1 : 0);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
override getPaddingX(): number {
|
|
46
|
+
return this.userPaddingX;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
override setPaddingX(padding: number): void {
|
|
50
|
+
this.userPaddingX = Math.max(0, padding);
|
|
51
|
+
super.setPaddingX(this.userPaddingX + this.promptWidth());
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
protected override renderTopBorder(width: number, hiddenLineCount: number): string {
|
|
55
|
+
this.isScrolled = hiddenLineCount > 0;
|
|
56
|
+
return this.roundBorder(super.renderTopBorder(width, hiddenLineCount), width, "╭", "╮");
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
protected override renderBottomBorder(width: number, hiddenLineCount: number): string {
|
|
60
|
+
this.bottomBorderLine = this.roundBorder(super.renderBottomBorder(width, hiddenLineCount), width, "╰", "╯");
|
|
61
|
+
return this.bottomBorderLine;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
private roundBorder(line: string, width: number, left: string, right: string): string {
|
|
65
|
+
if (width < 2 || visibleWidth(line) !== width) return line;
|
|
66
|
+
return `${this.borderColor(left)}${sliceByColumn(line, 1, width - 2, true)}${this.borderColor(right)}`;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
override render(width: number): string[] {
|
|
70
|
+
this.bottomBorderLine = "";
|
|
71
|
+
const lines = super.render(width);
|
|
72
|
+
if (lines.length < 2) return lines;
|
|
73
|
+
|
|
74
|
+
if (!this.isScrolled) {
|
|
75
|
+
const firstContentLine = lines[1];
|
|
76
|
+
if (firstContentLine) {
|
|
77
|
+
const maxPadding = Math.max(0, Math.floor((width - 1) / 2));
|
|
78
|
+
const promptPadding = Math.min(this.userPaddingX + this.promptWidth(), maxPadding);
|
|
79
|
+
if (promptPadding >= PROMPT_WIDTH && firstContentLine.startsWith(" ".repeat(promptPadding))) {
|
|
80
|
+
const promptStart = promptPadding - PROMPT_WIDTH;
|
|
81
|
+
const prompt = ` ${colorPrompt(PROMPT_ICON)} `;
|
|
82
|
+
lines[1] = `${firstContentLine.slice(0, promptStart)}${prompt}${firstContentLine.slice(promptPadding)}`;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
if (!supportsBorderHooks || !this.bottomBorderLine) return lines;
|
|
88
|
+
|
|
89
|
+
const bottomBorderIndex = lines.lastIndexOf(this.bottomBorderLine);
|
|
90
|
+
const contentEnd = bottomBorderIndex > 0 ? bottomBorderIndex : lines.length - 1;
|
|
91
|
+
for (let i = 1; i < contentEnd; i++) {
|
|
92
|
+
const line = lines[i];
|
|
93
|
+
if (!line || visibleWidth(line) !== width || !line.startsWith(" ") || !line.endsWith(" ")) continue;
|
|
94
|
+
lines[i] = `${this.borderColor("│")}${line.slice(1, -1)}${this.borderColor("│")}`;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
return lines;
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
return PromptEditor;
|
|
102
|
+
}
|
package/src/render.ts
CHANGED
|
@@ -39,12 +39,7 @@ import {
|
|
|
39
39
|
formatElapsedMs,
|
|
40
40
|
normalizeLineEndings,
|
|
41
41
|
} from "./helpers.js";
|
|
42
|
-
import type { RenderCtxLike as RenderContext, ThemeLike } from "./types.js";
|
|
43
|
-
|
|
44
|
-
/** Thin wrapper over pi-tui's truncateToWidth (imported at top level above). */
|
|
45
|
-
function _truncateToWidth(text: string, maxWidth: number, ellipsis?: string, pad?: boolean): string {
|
|
46
|
-
return truncateToWidth(text, maxWidth, ellipsis, pad);
|
|
47
|
-
}
|
|
42
|
+
import type { RenderCtxLike as RenderContext, TextLike, ThemeLike } from "./types.js";
|
|
48
43
|
|
|
49
44
|
// ---------------------------------------------------------------------------
|
|
50
45
|
// Shiki ANSI cache
|
|
@@ -215,13 +210,37 @@ export function fillToolBackground(text: string, bg = BG_BASE, width?: number):
|
|
|
215
210
|
}
|
|
216
211
|
const plainLead = line.replace(ANSI_CAPTURE_RE, "");
|
|
217
212
|
const skipPad = line.startsWith(TOOL_RESULT_INDENT) || plainLead.startsWith(TOOL_RESULT_INDENT);
|
|
218
|
-
const fitted =
|
|
213
|
+
const fitted = truncateToWidth(line, width, "", !skipPad);
|
|
219
214
|
const stripped = preserveBoxBackground(fitted);
|
|
220
215
|
return bg ? bg + stripped : stripped;
|
|
221
216
|
})
|
|
222
217
|
.join("\n");
|
|
223
218
|
}
|
|
224
219
|
|
|
220
|
+
/** Add exactly the missing terminal row after a rendered tool body. */
|
|
221
|
+
export function fillToolBody(text: string, bg = BG_BASE, width?: number): string {
|
|
222
|
+
return fillToolBackground(text.endsWith("\n") ? text : `${text}\n`, bg, width);
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
const TOOL_TITLE_STATE_KEY = "__piPrettyToolTitle";
|
|
226
|
+
type ToolTitleRenderer = (suffix?: string) => string;
|
|
227
|
+
type ToolTitleState = { component: TextLike; render: ToolTitleRenderer };
|
|
228
|
+
|
|
229
|
+
/** Keep a self-rendered tool's call title mutable while its result arrives. */
|
|
230
|
+
export function rememberToolTitle(ctx: RenderContext, component: TextLike, render: ToolTitleRenderer): void {
|
|
231
|
+
ctx.state[TOOL_TITLE_STATE_KEY] = { component, render } satisfies ToolTitleState;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** Move collapsed result metadata into the call title and hide the result slot. */
|
|
235
|
+
export function setCollapsedToolTitle(ctx: RenderContext, result: TextLike, suffix: string): boolean {
|
|
236
|
+
if (ctx.expanded) return false;
|
|
237
|
+
const title = ctx.state[TOOL_TITLE_STATE_KEY] as ToolTitleState | undefined;
|
|
238
|
+
if (!title || typeof title.render !== "function") return false;
|
|
239
|
+
title.component.setText(title.render(suffix));
|
|
240
|
+
result.setText("");
|
|
241
|
+
return true;
|
|
242
|
+
}
|
|
243
|
+
|
|
225
244
|
function lnum(n: number, w: number): string {
|
|
226
245
|
const v = String(n);
|
|
227
246
|
return `${FG_LNUM}${" ".repeat(Math.max(0, w - v.length))}${v}${RST}`;
|
|
@@ -253,7 +272,7 @@ export function renderToolError(error: string, theme: ThemeLike): string {
|
|
|
253
272
|
const body = compactErrorLines(error)
|
|
254
273
|
.map((line) => `${TOOL_RESULT_INDENT}${line ? theme.fg("error", line) : ""}`)
|
|
255
274
|
.join("\n");
|
|
256
|
-
return
|
|
275
|
+
return fillToolBody(body, BG_ERROR);
|
|
257
276
|
}
|
|
258
277
|
|
|
259
278
|
// ---------------------------------------------------------------------------
|
|
@@ -277,7 +296,7 @@ export async function renderFileContent(
|
|
|
277
296
|
|
|
278
297
|
const out: string[] = [];
|
|
279
298
|
for (const line of hl) {
|
|
280
|
-
out.push(
|
|
299
|
+
out.push(truncateToWidth(line ?? "", Math.max(1, tw), `${FG_DIM}›`));
|
|
281
300
|
}
|
|
282
301
|
return out.join("\n");
|
|
283
302
|
}
|
|
@@ -448,14 +467,14 @@ export function makeRenderResult() {
|
|
|
448
467
|
const more = lines.length > maxShow ? `\n${FG_DIM}... ${lines.length - maxShow} more lines${RST}` : "";
|
|
449
468
|
const metrics = renderToolMetrics(result);
|
|
450
469
|
text.setText(
|
|
451
|
-
|
|
470
|
+
fillToolBody(
|
|
452
471
|
`${TOOL_RESULT_INDENT}${preview}${more}${metrics ? `\n${TOOL_RESULT_INDENT}${metrics}` : ""}`,
|
|
453
472
|
undefined,
|
|
454
473
|
renderWidth,
|
|
455
474
|
),
|
|
456
475
|
);
|
|
457
476
|
} else {
|
|
458
|
-
text.setText(
|
|
477
|
+
text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${theme.fg("dim", "(no text output)")}`));
|
|
459
478
|
}
|
|
460
479
|
return text;
|
|
461
480
|
};
|