@heyhuynhgiabuu/pi-pretty 0.6.25 → 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 +34 -0
  2. package/README.md +47 -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 +192 -43
  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 +93 -30
  41. package/dist/tools/read.js.map +1 -1
  42. package/dist/types.d.ts +13 -3
  43. package/dist/types.d.ts.map +1 -1
  44. package/dist/working-indicator.d.ts +91 -26
  45. package/dist/working-indicator.d.ts.map +1 -1
  46. package/dist/working-indicator.js +259 -77
  47. package/dist/working-indicator.js.map +1 -1
  48. package/package.json +7 -6
  49. package/src/config.ts +51 -0
  50. package/src/fff.ts +158 -29
  51. package/src/index.ts +207 -47
  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 +122 -30
  60. package/src/types.ts +14 -3
  61. package/src/working-indicator.ts +309 -88
package/src/index.ts CHANGED
@@ -15,13 +15,22 @@ import type {
15
15
  ExtensionAPI,
16
16
  ExtensionCommandContext,
17
17
  ExtensionContext,
18
+ MessageEndEvent,
18
19
  MessageUpdateEvent,
19
20
  SessionInfoChangedEvent,
20
21
  } from "@earendil-works/pi-coding-agent";
21
22
  import * as hostSdk from "@earendil-works/pi-coding-agent";
22
23
  import { createFffAutocompleteProvider } from "./autocomplete.js";
23
- import { applyConfig, getDefaultAgentDir, loadConfig, normalizeToolList, resolveToolSets } from "./config.js";
24
- 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";
25
34
  import { registerBashTool } from "./tools/bash.js";
26
35
  import { registerFindTool } from "./tools/find.js";
27
36
  import { registerGrepTool } from "./tools/grep.js";
@@ -30,10 +39,13 @@ import { registerReadTool } from "./tools/read.js";
30
39
  import type { PiPrettyDeps, SdkTools } from "./types.js";
31
40
  import {
32
41
  createThinkingLabelAnimator,
42
+ createThinkingTimer,
43
+ installPerRowThinkingLabels,
33
44
  installWorkingIndicator,
45
+ type PerRowThinkingLabels,
34
46
  resolveThinkingIndicatorSettings,
35
47
  resolveWorkingIndicatorSettings,
36
- type ThinkingLabelAnimator,
48
+ type ThinkingTimer,
37
49
  thinkingBlockActive,
38
50
  WORKING_INTERVAL_MS,
39
51
  type WorkingIndicatorController,
@@ -44,6 +56,12 @@ import {
44
56
  // ---------------------------------------------------------------------------
45
57
 
46
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
+ };
47
65
 
48
66
  function envTools(name: "PRETTY_DISABLE_TOOLS" | "PRETTY_ENABLE_TOOLS"): Set<string> {
49
67
  return new Set(normalizeToolList((process.env[name] ?? "").split(",")));
@@ -58,6 +76,26 @@ export type { PiPrettyDeps };
58
76
  export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrettyDeps): Promise<void> {
59
77
  const config = loadConfig();
60
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
+ };
61
99
  const { disabledTools, enabledTools } = resolveToolSets(
62
100
  envTools("PRETTY_DISABLE_TOOLS"),
63
101
  envTools("PRETTY_ENABLE_TOOLS"),
@@ -160,13 +198,41 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
160
198
  const workingSettings = resolveWorkingIndicatorSettings(config.workingIndicator);
161
199
  const thinkingSettings = resolveThinkingIndicatorSettings(config.thinkingIndicator);
162
200
  let workingController: WorkingIndicatorController | undefined;
163
- let thinkingAnimator: ThinkingLabelAnimator | undefined;
201
+ let thinkingTimer: ThinkingTimer | undefined;
164
202
  let thinkingInterval: ReturnType<typeof setInterval> | undefined;
165
203
  let thinkingLastMessage: unknown;
166
- let thinkingHiddenCache = true;
167
204
  let thinkingHiddenCheckedAt = 0;
168
205
  let workingSessionName: string | undefined;
169
206
  let workingStreaming = false;
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
+ };
226
+ let perRowLabels: PerRowThinkingLabels | undefined;
227
+ /** Accumulated thinking time per message timestamp — later runs in the same
228
+ * message resume from this total instead of rewinding to zero. */
229
+ const thinkingElapsedMs = new Map<number, number>();
230
+
231
+ /** Message timestamps are the per-row identity the label patch keys on. */
232
+ const messageTimestamp = (message: unknown): number | undefined => {
233
+ const ts = (message as { timestamp?: unknown } | undefined)?.timestamp;
234
+ return typeof ts === "number" ? ts : undefined;
235
+ };
170
236
 
171
237
  /** pi persists the thinking-visibility toggle as a top-level settings key. */
172
238
  const thinkingHidden = (): boolean => {
@@ -182,6 +248,17 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
182
248
  }
183
249
  };
184
250
 
251
+ /**
252
+ * True when the 500ms-cached hidden check is stale and settings now show
253
+ * thinking revealed — the single re-check shared by the ticker and the
254
+ * completed-phase message_update path (timestamp guard runs before the read).
255
+ */
256
+ const thinkingRevealed = (): boolean => {
257
+ if (Date.now() - thinkingHiddenCheckedAt <= 500) return false;
258
+ thinkingHiddenCheckedAt = Date.now();
259
+ return !thinkingHidden();
260
+ };
261
+
185
262
  const safeSessionName = (ctx: ExtensionContext): string | undefined => {
186
263
  try {
187
264
  return ctx.sessionManager?.getSessionName?.() ?? undefined;
@@ -190,8 +267,8 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
190
267
  }
191
268
  };
192
269
 
193
- // (Re-)install the widget: new session, reload, or session rename (the
194
- // 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
195
272
  // mid-stream rename does not drop the row.
196
273
  const installIndicator = async (ctx: ExtensionContext): Promise<void> => {
197
274
  workingController?.dispose();
@@ -217,7 +294,17 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
217
294
 
218
295
  pi.on("session_start", async (_event: unknown, ctx: ExtensionContext) => {
219
296
  if (ctx.mode === "tui") {
297
+ installPromptEditor(ctx);
220
298
  ctx.ui.setToolsExpanded(false);
299
+ // Per-row hidden-thinking labels: intercept the host's label fan-out so
300
+ // each row keeps its own duration. Falls back to the global-label
301
+ // behavior whenever the host class is missing or reshaped.
302
+ perRowLabels = thinkingSettings.enabled
303
+ ? installPerRowThinkingLabels(
304
+ deps?.assistantMessageComponent ??
305
+ (hostSdk as { AssistantMessageComponent?: unknown }).AssistantMessageComponent,
306
+ )
307
+ : undefined;
221
308
  workingSessionName = safeSessionName(ctx);
222
309
  await installIndicator(ctx);
223
310
  }
@@ -231,7 +318,7 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
231
318
  if (!loaded) return;
232
319
  }
233
320
 
234
- await fffService.ensureFinder(ctx.cwd);
321
+ await fffService.ensureFinder(ctx.cwd, fffInitOptions);
235
322
  if (fffService.partialIndex) {
236
323
  ctx.ui?.notify?.("FFF: scan timed out — using partial index. Run /fff-rescan when ready.", "warning");
237
324
  } else {
@@ -245,7 +332,10 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
245
332
  createFffAutocompleteProvider(current, () => fffService?.getFinder() ?? null),
246
333
  );
247
334
  } catch (error: unknown) {
248
- 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");
249
339
  }
250
340
  });
251
341
 
@@ -258,7 +348,7 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
258
348
  if (workingSettings.enabled) await installIndicator(ctx);
259
349
  });
260
350
 
261
- // 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).
262
352
  pi.on("agent_start", async (_event: unknown, ctx: ExtensionContext) => {
263
353
  if (ctx.mode !== "tui") return;
264
354
  workingStreaming = true;
@@ -273,77 +363,147 @@ export default async function piPrettyExtension(pi: ExtensionAPI, deps?: PiPrett
273
363
  pi.on("agent_end", stopStreaming);
274
364
  pi.on("agent_settled", stopStreaming);
275
365
 
366
+ const clearThinkingInterval = (): void => {
367
+ if (!thinkingInterval) return;
368
+ clearInterval(thinkingInterval);
369
+ thinkingInterval = undefined;
370
+ };
371
+
276
372
  const stopThinkingShimmer = (): void => {
277
- if (thinkingInterval) {
278
- clearInterval(thinkingInterval);
279
- thinkingInterval = undefined;
373
+ clearThinkingInterval();
374
+ perRowLabels?.clearActive();
375
+ try {
376
+ thinkingTimer?.restore();
377
+ } catch {
378
+ // Teardown must never throw — a host that explodes on restore is left
379
+ // with whatever label it has; the interval is already gone.
380
+ }
381
+ thinkingTimer = undefined;
382
+ thinkingLastMessage = undefined;
383
+ };
384
+
385
+ const completeThinkingShimmer = (): void => {
386
+ clearThinkingInterval();
387
+ const ts = messageTimestamp(thinkingLastMessage);
388
+ const frozenMs = thinkingTimer?.complete();
389
+ if (ts !== undefined && frozenMs !== undefined) {
390
+ thinkingElapsedMs.set(ts, frozenMs);
391
+ perRowLabels?.complete(ts, frozenMs);
280
392
  }
281
- thinkingAnimator?.restore();
282
- thinkingAnimator = undefined;
393
+ // The completed row is no longer the animating one — drop the active mark
394
+ // so subsequent global writes freeze it at its own duration.
395
+ perRowLabels?.clearActive();
283
396
  thinkingLastMessage = undefined;
284
397
  };
285
398
 
399
+ /** Surface one warning without letting a throwing host escape the crash guard. */
400
+ const notifyThinkingFailure = (ctx: ExtensionContext, error: unknown): void => {
401
+ try {
402
+ ctx.ui?.notify?.(
403
+ `pi-pretty thinking indicator failed: ${error instanceof Error ? error.message : String(error)}`,
404
+ "warning",
405
+ );
406
+ } catch {
407
+ // A host that throws on notify too must not crash the timer path.
408
+ }
409
+ };
410
+
286
411
  // Same 30fps cadence as the working row — but the interval lives ONLY while
287
412
  // the streaming message is actively emitting a thinking block (each label
288
413
  // change rebuilds chat children, so the moment text or tool calls stream the
289
- // shimmer stops and pi's default label is restored).
414
+ // label freezes as `Thought for {duration}` until the run ends).
290
415
  const startThinkingShimmer = (ctx: ExtensionContext): void => {
291
416
  if (thinkingInterval || !thinkingSettings.enabled) return;
292
417
  try {
293
- thinkingAnimator = createThinkingLabelAnimator(ctx.ui, workingSettings, workingSessionName, thinkingSettings);
294
- thinkingHiddenCache = thinkingHidden();
295
418
  thinkingHiddenCheckedAt = Date.now();
296
- if (!thinkingHiddenCache) {
297
- stopThinkingShimmer();
298
- return;
299
- }
300
- thinkingAnimator.tick();
419
+ if (!thinkingHidden()) return; // thinking visible: no label writes at all
420
+ const animator = createThinkingLabelAnimator(ctx.ui, workingSettings, workingSessionName, thinkingSettings);
421
+ const activeTs = messageTimestamp(thinkingLastMessage);
422
+ // Same message thinking again (interleaved runs): resume its total.
423
+ const resumeMs = activeTs !== undefined ? (thinkingElapsedMs.get(activeTs) ?? 0) : 0;
424
+ thinkingTimer = createThinkingTimer(animator, Date.now, resumeMs);
425
+ if (activeTs !== undefined) perRowLabels?.setActive(activeTs);
426
+ thinkingTimer.tick();
301
427
  thinkingInterval = setInterval(() => {
302
- if (!workingStreaming) {
303
- stopThinkingShimmer();
304
- return;
305
- }
306
- const now = Date.now();
307
- if (now - thinkingHiddenCheckedAt > 500) {
308
- thinkingHiddenCache = thinkingHidden();
309
- thinkingHiddenCheckedAt = now;
310
- if (!thinkingHiddenCache) {
428
+ try {
429
+ if (!workingStreaming) {
311
430
  stopThinkingShimmer();
312
431
  return;
313
432
  }
314
- }
315
- if (!thinkingBlockActive(thinkingLastMessage)) {
316
- // Thinking phase over: restore pi's default label and stop.
433
+ if (thinkingRevealed()) {
434
+ stopThinkingShimmer();
435
+ return;
436
+ }
437
+ if (!thinkingBlockActive(thinkingLastMessage)) {
438
+ // Thinking phase over: freeze its duration until the run ends.
439
+ completeThinkingShimmer();
440
+ return;
441
+ }
442
+ thinkingTimer?.tick();
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.
445
+ workingController?.requestRender();
446
+ } catch (error: unknown) {
447
+ // A throwing host call inside our timer would crash pi (uncaught in
448
+ // setInterval) — tear the label down and surface one warning instead.
317
449
  stopThinkingShimmer();
318
- return;
450
+ notifyThinkingFailure(ctx, error);
319
451
  }
320
- thinkingAnimator?.tick();
321
452
  }, WORKING_INTERVAL_MS);
322
453
  } catch (error: unknown) {
323
454
  stopThinkingShimmer();
324
- ctx.ui?.notify?.(
325
- `pi-pretty thinking indicator failed: ${error instanceof Error ? error.message : String(error)}`,
326
- "warning",
327
- );
455
+ notifyThinkingFailure(ctx, error);
328
456
  }
329
457
  };
330
458
 
331
459
  // Remember the streaming message; lazily start the shimmer on the first
332
- // thinking delta (thinkingHidden() is re-checked at most every 500ms).
460
+ // thinking delta (thinkingHidden() is re-checked at most every 500ms). After
461
+ // the phase completes, deltas keep refreshing the visibility cache so a
462
+ // mid-run `hideThinkingBlock` toggle-off restores the default label.
333
463
  pi.on("message_update", async (event: MessageUpdateEvent, ctx: ExtensionContext) => {
334
464
  if (ctx.mode !== "tui" || !workingStreaming) return;
335
465
  thinkingLastMessage = event.message;
336
- if (thinkingInterval) return;
337
- if (!thinkingBlockActive(event.message)) return;
338
- startThinkingShimmer(ctx);
466
+ if (thinkingInterval) {
467
+ // Complete immediately on the first text/tool delta instead of waiting
468
+ // for the next animation frame.
469
+ if (!thinkingBlockActive(event.message)) completeThinkingShimmer();
470
+ return;
471
+ }
472
+ if (thinkingBlockActive(event.message)) {
473
+ // First thinking delta — or a later phase after tool calls — starts a
474
+ // fresh timer (the completed one is replaced wholesale).
475
+ startThinkingShimmer(ctx);
476
+ return;
477
+ }
478
+ if (thinkingTimer && thinkingRevealed()) stopThinkingShimmer();
479
+ });
480
+
481
+ // The completed wording belongs to the message that produced it. The label
482
+ // is global, so once that assistant message ends, restore pi's default —
483
+ // otherwise every later phase stamps its frozen duration onto every hidden
484
+ // thinking row in the transcript (the image-read " Thought for 5s" stack).
485
+ pi.on("message_end", async (event: MessageEndEvent, ctx: ExtensionContext) => {
486
+ if (ctx.mode !== "tui" || event.message.role !== "assistant") return;
487
+ if (thinkingInterval || thinkingTimer) stopThinkingShimmer();
339
488
  });
340
489
 
341
- pi.on("session_shutdown", async () => {
342
- // 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
343
500
  // install) after resume or session switching.
344
501
  workingController?.dispose();
345
502
  workingController = undefined;
346
503
  stopThinkingShimmer();
504
+ perRowLabels?.uninstall();
505
+ perRowLabels = undefined;
506
+ thinkingElapsedMs.clear();
347
507
  workingStreaming = false;
348
508
  // Intentionally keep the native FFF finder on session shutdown.
349
509
  // Pi can emit shutdown/start during resume or session switching while the
@@ -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
  };