@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.
Files changed (61) hide show
  1. package/CHANGELOG.md +18 -0
  2. package/README.md +34 -17
  3. package/dist/config.d.ts +16 -0
  4. package/dist/config.d.ts.map +1 -1
  5. package/dist/config.js +30 -0
  6. package/dist/config.js.map +1 -1
  7. package/dist/fff.d.ts +16 -1
  8. package/dist/fff.d.ts.map +1 -1
  9. package/dist/fff.js +139 -33
  10. package/dist/fff.js.map +1 -1
  11. package/dist/index.d.ts.map +1 -1
  12. package/dist/index.js +70 -24
  13. package/dist/index.js.map +1 -1
  14. package/dist/prompt-editor.d.ts +11 -0
  15. package/dist/prompt-editor.d.ts.map +1 -0
  16. package/dist/prompt-editor.js +78 -0
  17. package/dist/prompt-editor.js.map +1 -0
  18. package/dist/render.d.ts +9 -1
  19. package/dist/render.d.ts.map +1 -1
  20. package/dist/render.js +25 -9
  21. package/dist/render.js.map +1 -1
  22. package/dist/tools/bash.d.ts.map +1 -1
  23. package/dist/tools/bash.js +73 -30
  24. package/dist/tools/bash.js.map +1 -1
  25. package/dist/tools/find.d.ts.map +1 -1
  26. package/dist/tools/find.js +29 -10
  27. package/dist/tools/find.js.map +1 -1
  28. package/dist/tools/grep.d.ts.map +1 -1
  29. package/dist/tools/grep.js +20 -10
  30. package/dist/tools/grep.js.map +1 -1
  31. package/dist/tools/ls.d.ts.map +1 -1
  32. package/dist/tools/ls.js +10 -5
  33. package/dist/tools/ls.js.map +1 -1
  34. package/dist/tools/metrics.d.ts +6 -1
  35. package/dist/tools/metrics.d.ts.map +1 -1
  36. package/dist/tools/metrics.js +21 -8
  37. package/dist/tools/metrics.js.map +1 -1
  38. package/dist/tools/read.d.ts +2 -1
  39. package/dist/tools/read.d.ts.map +1 -1
  40. package/dist/tools/read.js +94 -31
  41. package/dist/tools/read.js.map +1 -1
  42. package/dist/types.d.ts +9 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/working-indicator.d.ts +39 -31
  45. package/dist/working-indicator.d.ts.map +1 -1
  46. package/dist/working-indicator.js +114 -87
  47. package/dist/working-indicator.js.map +1 -1
  48. package/package.json +1 -1
  49. package/src/config.ts +51 -0
  50. package/src/fff.ts +158 -29
  51. package/src/index.ts +77 -24
  52. package/src/prompt-editor.ts +102 -0
  53. package/src/render.ts +30 -11
  54. package/src/tools/bash.ts +94 -32
  55. package/src/tools/find.ts +39 -17
  56. package/src/tools/grep.ts +26 -15
  57. package/src/tools/ls.ts +19 -10
  58. package/src/tools/metrics.ts +28 -8
  59. package/src/tools/read.ts +123 -31
  60. package/src/types.ts +10 -3
  61. 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 { join } from "node:path";
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
- if (this.finder && !this.finder.isDestroyed) return;
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
- const promise = this._createFinder(cwd);
138
- this.finderPromise = promise;
139
- try {
140
- await promise;
141
- } finally {
142
- if (this.finderPromise === promise) this.finderPromise = null;
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
- if (this.finder && !this.finder.isDestroyed) {
150
- this.finder.destroy();
151
- this.finder = null;
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 result = this.createFinder(cwd, this.dbDir);
155
- if (!result.ok) {
156
- throw new Error(`FFF init failed: ${result.error}`);
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
- this.finder = result.value;
160
- const scan = await this.finder.waitForScan(15_000);
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 result = this.fffModule.FileFinder.create({
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
- basePath: cwd,
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
- if (this.finder && !this.finder.isDestroyed) {
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 { applyConfig, getDefaultAgentDir, loadConfig, normalizeToolList, resolveToolSets } from "./config.js";
25
- import { type FffService, getSharedFffService } from "./fff.js";
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 workingStatsUpdatedAt = 0;
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 widget: new session, reload, or session rename (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
- ctx.ui?.notify?.(`FFF init failed: ${error instanceof Error ? error.message : String(error)}`, "error");
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 widget with the streaming lifecycle (host loader is hidden).
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 — reuse the working widget's
392
- // TUI handle so the frame lands even when deltas pause mid-thinking.
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
- // Tear down the widget animation; pi re-runs session_start (and our
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 = _truncateToWidth(line, width, "", !skipPad);
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 fillToolBackground(body, BG_ERROR);
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(_truncateToWidth(line ?? "", Math.max(1, tw), `${FG_DIM}›`));
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
- fillToolBackground(
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(fillToolBackground(`${TOOL_RESULT_INDENT}${theme.fg("dim", "(no text output)")}`));
477
+ text.setText(fillToolBody(`${TOOL_RESULT_INDENT}${theme.fg("dim", "(no text output)")}`));
459
478
  }
460
479
  return text;
461
480
  };