@quandev104/pi-style 0.1.3 → 0.1.4

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.
@@ -33,19 +33,79 @@ export function getToolsRenderConfig(): ToolsRenderConfig {
33
33
 
34
34
  // Wall-clock elapsed tracking through the renderer context state (no tool
35
35
  // re-registration, so result.details has no execution timing).
36
+ //
37
+ // Elapsed is computed live from the recorded start on every read, so a running
38
+ // tool keeps growing its displayed time. The value only freezes once the
39
+ // execution end is recorded (terminal result), which keeps the completed footer
40
+ // stable across later re-renders (expand toggles, terminal resizes).
36
41
 
37
42
  const STARTED_AT_KEY = "__piStyleStartedAt";
38
- const ELAPSED_MS_KEY = "__piStyleElapsedMs";
43
+ const ENDED_AT_KEY = "__piStyleEndedAt";
44
+ const RESULT_SEEN_KEY = "__piStyleResultSeen";
45
+ const TICKER_KEY = "__piStyleElapsedTicker";
39
46
 
40
47
  export function recordExecutionStarted(state: Record<string, unknown> | undefined, executionStarted: boolean): void {
41
48
  if (!executionStarted || !state || typeof state !== "object") return;
42
49
  if (typeof state[STARTED_AT_KEY] !== "number") state[STARTED_AT_KEY] = performance.now();
43
50
  }
44
51
 
52
+ /** Freeze the elapsed at the first terminal render (idempotent). */
53
+ export function recordExecutionEnded(state: Record<string, unknown> | undefined): void {
54
+ if (!state || typeof state !== "object") return;
55
+ if (typeof state[ENDED_AT_KEY] !== "number") state[ENDED_AT_KEY] = performance.now();
56
+ }
57
+
45
58
  export function getStateElapsedMs(state: Record<string, unknown> | undefined): number | undefined {
46
59
  if (!state || typeof state !== "object") return undefined;
47
- if (typeof state[ELAPSED_MS_KEY] !== "number" && typeof state[STARTED_AT_KEY] === "number") {
48
- state[ELAPSED_MS_KEY] = performance.now() - state[STARTED_AT_KEY];
60
+ const started = state[STARTED_AT_KEY];
61
+ if (typeof started !== "number") return undefined;
62
+ const ended = state[ENDED_AT_KEY];
63
+ if (typeof ended === "number") return Math.max(0, ended - started);
64
+ return Math.max(0, performance.now() - started);
65
+ }
66
+
67
+ /** Whether a result renderer already produced a continuation for this call. */
68
+ export function isResultSeen(state: Record<string, unknown> | undefined): boolean {
69
+ return Boolean(state && typeof state === "object" && state[RESULT_SEEN_KEY] === true);
70
+ }
71
+
72
+ /** Record that a result renderer ran for this call (streaming or final). */
73
+ export function markResultSeen(state: Record<string, unknown> | undefined): void {
74
+ if (!state || typeof state !== "object") return;
75
+ state[RESULT_SEEN_KEY] = true;
76
+ }
77
+
78
+ type TickerHandle = ReturnType<typeof setInterval>;
79
+
80
+ /** States that currently own a 1s elapsed-render interval, for session cleanup. */
81
+ const tickerStates = new Set<Record<string, unknown>>();
82
+
83
+ /**
84
+ * While a tool is running, re-render once per second so live elapsed labels
85
+ * tick without any output events. Idempotent per state.
86
+ */
87
+ export function startElapsedTicker(state: Record<string, unknown> | undefined, invalidate: () => void): void {
88
+ if (!state || typeof state !== "object") return;
89
+ if (state[TICKER_KEY] !== undefined) return;
90
+ state[TICKER_KEY] = setInterval(() => invalidate(), 1000) as unknown as TickerHandle;
91
+ tickerStates.add(state);
92
+ }
93
+
94
+ /** Stop a running tool's elapsed ticker (terminal result, error, session end). */
95
+ export function stopElapsedTicker(state: Record<string, unknown> | undefined): void {
96
+ if (!state || typeof state !== "object") return;
97
+ const handle = state[TICKER_KEY] as TickerHandle | undefined;
98
+ if (handle !== undefined) clearInterval(handle);
99
+ delete state[TICKER_KEY];
100
+ tickerStates.delete(state);
101
+ }
102
+
103
+ /** Stop every elapsed ticker (session start/shutdown). */
104
+ export function stopAllElapsedTickers(): void {
105
+ for (const state of tickerStates) {
106
+ const handle = state[TICKER_KEY] as TickerHandle | undefined;
107
+ if (handle !== undefined) clearInterval(handle);
108
+ delete state[TICKER_KEY];
49
109
  }
50
- return typeof state[ELAPSED_MS_KEY] === "number" ? state[ELAPSED_MS_KEY] : undefined;
110
+ tickerStates.clear();
51
111
  }
@@ -13,12 +13,21 @@ import {
13
13
  resolveRelativePath,
14
14
  shortenPath,
15
15
  } from "../../../shared/box.js";
16
- import { getStateElapsedMs, recordExecutionStarted } from "./session-config.js";
16
+ import {
17
+ getStateElapsedMs,
18
+ isResultSeen,
19
+ markResultSeen,
20
+ recordExecutionEnded,
21
+ recordExecutionStarted,
22
+ startElapsedTicker,
23
+ stopElapsedTicker,
24
+ } from "./session-config.js";
17
25
 
18
26
  /** Renderer context delivered by Pi's ToolExecutionComponent (getRenderContext). */
19
27
  export interface BoxedToolContext {
20
28
  readonly args: Record<string, unknown>;
21
29
  readonly toolCallId: string;
30
+ readonly invalidate: () => void;
22
31
  readonly state: Record<string, unknown>;
23
32
  readonly cwd: string;
24
33
  readonly executionStarted: boolean;
@@ -88,6 +97,7 @@ export function compactCall(
88
97
  isError: Boolean(options.context.isError),
89
98
  isPartial: Boolean(options.context.isPartial),
90
99
  isPending: pendingFlag(options.context),
100
+ running: Boolean(options.context.executionStarted),
91
101
  });
92
102
  }
93
103
 
@@ -96,6 +106,36 @@ export function noteExecutionStart(context: BoxedToolContext): void {
96
106
  recordExecutionStarted(context.state, context.executionStarted);
97
107
  }
98
108
 
109
+ /**
110
+ * Keep running/ended execution state in sync from a call renderer pass. While
111
+ * the tool runs, a 1s re-render ticker keeps live elapsed labels current; once
112
+ * the call renders in its terminal form the elapsed freezes.
113
+ */
114
+ export function noteBoxedCallState(context: BoxedToolContext): void {
115
+ if (!context.executionStarted) return;
116
+ if (context.isPartial) startElapsedTicker(context.state, context.invalidate);
117
+ else {
118
+ recordExecutionEnded(context.state);
119
+ stopElapsedTicker(context.state);
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Record a result renderer pass and keep the ticker/ended state in sync.
125
+ * Returns whether this is the first result pass for the call, so renderers can
126
+ * render nothing while the pending/running call card stands alone.
127
+ */
128
+ export function noteBoxedResultPhase(context: BoxedToolContext, isPartial: boolean): boolean {
129
+ const firstResultPass = !isResultSeen(context.state);
130
+ markResultSeen(context.state);
131
+ if (isPartial) startElapsedTicker(context.state, context.invalidate);
132
+ else {
133
+ recordExecutionEnded(context.state);
134
+ stopElapsedTicker(context.state);
135
+ }
136
+ return firstResultPass;
137
+ }
138
+
99
139
  export function stateElapsedMs(context: BoxedToolContext): number | undefined {
100
140
  return getStateElapsedMs(context.state);
101
141
  }
@@ -34,6 +34,14 @@ import {
34
34
  /** Right-side bottom-border hint shown when the compact preview is truncated. */
35
35
  const WRITE_EXPAND_HINT = "Ctrl+O for more";
36
36
 
37
+ /** Partial-pass result: the compact call keeps its `◌ Running` card. */
38
+ const EMPTY_WRITE_RESULT: Component = Object.freeze({
39
+ invalidate() {},
40
+ render() {
41
+ return [];
42
+ },
43
+ });
44
+
37
45
  type NumberedLine = { number: string; content: string };
38
46
 
39
47
  /**
@@ -66,6 +74,7 @@ function renderWritePreviewBox(
66
74
  state?: Record<string, unknown>;
67
75
  isError: boolean;
68
76
  isPending: boolean;
77
+ running?: boolean;
69
78
  expanded: boolean;
70
79
  },
71
80
  ): Component {
@@ -78,6 +87,7 @@ function renderWritePreviewBox(
78
87
  ...(options.state ? { state: options.state } : {}),
79
88
  isError: options.isError,
80
89
  isPending: options.isPending,
90
+ running: Boolean(options.running),
81
91
  bodyLines: () => {
82
92
  if (preview.length === 0) return [];
83
93
  const shown = preview.slice(0, budget).map((line) => formatNumberedLine(theme, line));
@@ -107,10 +117,11 @@ export const writeTool: BoxedToolDefinition = {
107
117
  state: context.state,
108
118
  isError: Boolean(context.isError),
109
119
  isPending: Boolean(context.isPartial),
120
+ running: Boolean(context.executionStarted),
110
121
  expanded: Boolean(context.expanded),
111
122
  });
112
123
  },
113
- result(result, _options, theme, context) {
124
+ result(result, options, theme, context) {
114
125
  clearFooterState(context);
115
126
  const output = getTextOutput(result);
116
127
  const detail = displayPath(String(context?.args?.path ?? context?.args?.file_path ?? ""), context);
@@ -124,6 +135,11 @@ export const writeTool: BoxedToolDefinition = {
124
135
  });
125
136
  }
126
137
 
138
+ // While the result is still streaming, don't stamp a metrics footer into
139
+ // the shared state: the compact call keeps its `◌ Running` card and only
140
+ // closes with `elapsed · words` once the tool settles.
141
+ if (options.isPartial) return EMPTY_WRITE_RESULT;
142
+
127
143
  // Success (compact and expanded): the preview box closes with the metrics
128
144
  // footer stored into the shared renderer state; the result adds nothing.
129
145
  return compactFooterWithState(theme, result, context);
@@ -7,7 +7,11 @@ import { setSpecialBlockTheme } from "../features/messages/special-blocks.js";
7
7
  import { resetBashTreeRegistry } from "../features/tools/boxed/bash.js";
8
8
  import { resetBatchRegistry } from "../features/tools/boxed/batch.js";
9
9
  import { resetGrepRegistry } from "../features/tools/boxed/grep.js";
10
- import { setToolsRenderConfig, type ToolsRenderConfig } from "../features/tools/boxed/session-config.js";
10
+ import {
11
+ setToolsRenderConfig,
12
+ stopAllElapsedTickers,
13
+ type ToolsRenderConfig,
14
+ } from "../features/tools/boxed/session-config.js";
11
15
  import { createCompatibilityCoordinator } from "./compatibility-coordinator.js";
12
16
  import {
13
17
  type CompatibilityCleanupResult,
@@ -150,6 +154,9 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
150
154
  resetBatchRegistry();
151
155
  resetGrepRegistry();
152
156
  resetBashTreeRegistry();
157
+ // Stop any 1s elapsed re-render ticker left by a tool that was still
158
+ // running when the session ended.
159
+ stopAllElapsedTickers();
153
160
  active = false;
154
161
  await app.reload();
155
162
  productGate = app.productPolicy.corePatchGate;
@@ -216,6 +223,7 @@ export function createPiStyleSessionCoordinator(pi: ExtensionAPI, hooks: Compati
216
223
  resetBatchRegistry();
217
224
  resetGrepRegistry();
218
225
  resetBashTreeRegistry();
226
+ stopAllElapsedTickers();
219
227
  app.sessionShutdown();
220
228
  // Tier C prototype patches stay installed across session switches. Pi renders
221
229
  // the restored chat (renderBeforeBind) AFTER session_shutdown but BEFORE the
@@ -43,7 +43,15 @@ export interface BoxedRenderOptions {
43
43
  isError?: boolean;
44
44
  isPartial?: boolean;
45
45
  isPending?: boolean;
46
+ /** Execution has started but the tool is still running (title `◌` instead of `✓`). */
47
+ running?: boolean;
48
+ /** A result renderer already produced a continuation for this call, so the call
49
+ * leaves the box open instead of closing it with a pending label. */
50
+ resultSeen?: boolean;
46
51
  pendingText?: string;
52
+ /** Verbatim bottom-border label for the pending/running card (overrides the
53
+ * `… ${pendingText}` default, e.g. a live `◌ Running · 12.4s` status). */
54
+ pendingLabel?: string;
47
55
  state?: Record<string, unknown>;
48
56
  /** Wall-clock elapsed override (used when metrics are not in result.details). */
49
57
  elapsedMs?: number;
@@ -333,22 +341,45 @@ function formatBoxedStatusIcon(theme: BoxTheme, isError?: boolean): string {
333
341
 
334
342
  /**
335
343
  * Colored `➔ Name` prefix for tool titles (identity color). The status glyph
336
- * (✓/✗) is appended separately by formatBoxedToolTitle.
344
+ * (✓/◌/✗) is appended separately by formatBoxedToolTitle.
337
345
  */
338
346
  export function formatToolTitlePrefix(theme: BoxTheme, name: string): string {
339
347
  return colorFromExtra(theme, "bashPromptColor", "bashMode", `➔ ${name}`);
340
348
  }
341
349
 
342
- export function formatBoxedToolTitle(theme: BoxTheme, name: string, isError?: boolean): string {
350
+ export type BoxedTitleStatus = "running" | "pending";
351
+
352
+ /**
353
+ * Boxed tool title: `➔ Name ✓` when settled, `➔ Name ◌` while running, plain
354
+ * `➔ Name` while pending, and a fully error-colored `➔ Name ✗` on failure.
355
+ * The ✓/◌ glyphs are never shown before the tool settles, so a card that is
356
+ * still executing never reads as finished.
357
+ */
358
+ export function formatBoxedToolTitle(
359
+ theme: BoxTheme,
360
+ name: string,
361
+ isError?: boolean,
362
+ status?: BoxedTitleStatus,
363
+ ): string {
343
364
  // On failure the whole title turns error-colored (not just the ✗) so a failed
344
365
  // tool reads instantly; on success the tool keeps its identity color and only
345
366
  // the ✓ carries the success color.
346
367
  const coloredTitle = isError
347
368
  ? theme.fg("error", `➔ ${name} ✗`)
348
- : `${formatToolTitlePrefix(theme, name)} ${formatBoxedStatusIcon(theme, false)}`;
369
+ : status === "running"
370
+ ? `${formatToolTitlePrefix(theme, name)} ${theme.fg("text", "◌")}`
371
+ : status === "pending"
372
+ ? formatToolTitlePrefix(theme, name)
373
+ : `${formatToolTitlePrefix(theme, name)} ${formatBoxedStatusIcon(theme, false)}`;
349
374
  return typeof theme?.bold === "function" ? theme.bold(coloredTitle) : coloredTitle;
350
375
  }
351
376
 
377
+ /** Live running status label for pending/running cards and streaming footers. */
378
+ export function formatBoxedRunningStatus(theme: BoxTheme, elapsedMs: number | undefined): string {
379
+ const elapsed = elapsedMs === undefined ? "" : `${theme.fg("text", ` · ${(elapsedMs / 1000).toFixed(1)}s`)}`;
380
+ return `${theme.fg("dim", "◌ Running")}${elapsed}`;
381
+ }
382
+
352
383
  function boxText(theme: BoxTheme, text: string): string {
353
384
  return `${RESET_INTENSITY}${theme.fg("borderMuted", text)}`;
354
385
  }
@@ -571,7 +602,12 @@ export function renderBoxedToolCall(
571
602
  },
572
603
  render(width: number): string[] {
573
604
  if (cache?.width === width) return cache.lines;
574
- const title = formatBoxedToolTitle(theme, toolName, options.isError);
605
+ const title = formatBoxedToolTitle(
606
+ theme,
607
+ toolName,
608
+ options.isError,
609
+ options.isPending ? (options.running ? "running" : "pending") : undefined,
610
+ );
575
611
  const headerLabel = options.headerDetail ? `${title} · ${options.headerDetail}` : title;
576
612
  const renderedWidth = boxWidth(width);
577
613
  const lines = [
@@ -579,22 +615,26 @@ export function renderBoxedToolCall(
579
615
  boxBlankLine(theme, renderedWidth),
580
616
  ...detailLines.flatMap((line) => boxedWrappedLines(theme, line, renderedWidth)),
581
617
  ];
582
- if (options.isPending) {
583
- const pendingText = options.pendingText ?? "Waiting for output…";
618
+ if (options.isPending && !options.resultSeen) {
619
+ // Pending/running card: close the box with the status label. Once a
620
+ // result renderer has produced a continuation, the box stays open and
621
+ // that continuation closes it.
622
+ const pendingLabel =
623
+ options.pendingLabel ?? theme.fg("dim", `… ${options.pendingText ?? "Waiting for output…"}`);
584
624
  lines.push(
585
625
  boxBlankLine(theme, renderedWidth),
586
626
  boxLabeledBorder(
587
627
  theme,
588
628
  BOX_ROUND_BOTTOM_LEFT,
589
629
  BOX_ROUND_BOTTOM_RIGHT,
590
- theme.fg("dim", `… ${pendingText}`),
630
+ pendingLabel,
591
631
  undefined,
592
632
  renderedWidth,
593
633
  ),
594
634
  );
595
635
  } else {
596
636
  // Leave the box open with trailing breathing room; the result renderer
597
- // continues it with the Response divider.
637
+ // continues it with the result divider.
598
638
  lines.push(boxBlankLine(theme, renderedWidth));
599
639
  }
600
640
  cache = { width, lines };
@@ -624,7 +664,12 @@ export function renderCompactBoxedToolCall(
624
664
  invalidate() {},
625
665
  render(width: number): string[] {
626
666
  const renderedWidth = boxWidth(width);
627
- const title = formatBoxedToolTitle(theme, toolName, options.isError);
667
+ const title = formatBoxedToolTitle(
668
+ theme,
669
+ toolName,
670
+ options.isError,
671
+ options.isPending ? (options.running ? "running" : "pending") : undefined,
672
+ );
628
673
  const headerLabel = detailLine ? `${title} · ${detailLine}` : title;
629
674
  const compactFooter =
630
675
  typeof options.state?.[COMPACT_FOOTER_KEY] === "string" ? options.state[COMPACT_FOOTER_KEY] : "";
@@ -649,19 +694,23 @@ export function renderCompactBoxedToolCall(
649
694
  ),
650
695
  );
651
696
  } else if (options.isPending) {
652
- const pendingText = options.pendingText ?? "Waiting for output…";
697
+ const pendingLabel =
698
+ options.pendingLabel ??
699
+ (options.running
700
+ ? formatBoxedRunningStatus(theme, undefined)
701
+ : theme.fg("dim", `… ${options.pendingText ?? "Waiting for output…"}`));
653
702
  lines.push(
654
703
  boxLabeledBorder(
655
704
  theme,
656
705
  BOX_ROUND_BOTTOM_LEFT,
657
706
  BOX_ROUND_BOTTOM_RIGHT,
658
- theme.fg("dim", `… ${pendingText}`),
707
+ pendingLabel,
659
708
  options.bottomRightLabel,
660
709
  renderedWidth,
661
710
  ),
662
711
  );
663
712
  } else {
664
- // No footer yet (transient, or the result opens the Response divider):
713
+ // No footer yet (transient, or the result opens the result divider):
665
714
  // leave the box open so the result renderer continues the same box.
666
715
  }
667
716
  return lines;
@@ -689,6 +738,10 @@ export function renderBoxedToolResult(
689
738
  expandHint?: string;
690
739
  isError?: boolean;
691
740
  isPartial?: boolean;
741
+ /** Skip the result divider entirely (streaming continuation into an open call box). */
742
+ showDivider?: boolean;
743
+ /** Error state marker prepended to the body (default `✗ Error`). */
744
+ errorLabel?: string;
692
745
  } = {},
693
746
  ): Component {
694
747
  let cache: RenderLinesCache | null = null;
@@ -702,7 +755,7 @@ export function renderBoxedToolResult(
702
755
  const renderedWidth = boxWidth(width);
703
756
  const maxContentWidth = boxInnerWidth(renderedWidth);
704
757
  const bodyLines = typeof body === "function" ? body(maxContentWidth) : body.render(maxContentWidth);
705
- const errorPrefix = options.isError ? [theme.fg("error", "✗ Error")] : [];
758
+ const errorPrefix = options.isError ? [theme.fg("error", options.errorLabel ?? "✗ Error")] : [];
706
759
  const outputLines =
707
760
  bodyLines.length > 0
708
761
  ? [...errorPrefix, ...bodyLines]
@@ -713,14 +766,18 @@ export function renderBoxedToolResult(
713
766
  ? options.dividerLabel(renderedWidth)
714
767
  : (options.dividerLabel ?? "Response");
715
768
  const rendered = [
716
- boxLabeledBorder(
717
- theme,
718
- BOX_DIVIDER_LEFT,
719
- BOX_DIVIDER_RIGHT,
720
- theme.fg("dim", dividerText),
721
- options.dividerRightLabel ? theme.fg("dim", options.dividerRightLabel) : undefined,
722
- renderedWidth,
723
- ),
769
+ ...(options.showDivider === false
770
+ ? []
771
+ : [
772
+ boxLabeledBorder(
773
+ theme,
774
+ BOX_DIVIDER_LEFT,
775
+ BOX_DIVIDER_RIGHT,
776
+ theme.fg("dim", dividerText),
777
+ options.dividerRightLabel ? theme.fg("dim", options.dividerRightLabel) : undefined,
778
+ renderedWidth,
779
+ ),
780
+ ]),
724
781
  boxBlankLine(theme, renderedWidth),
725
782
  ...renderBoxedOutputLines(theme, outputLines, renderedWidth, options.renderLineBudget ?? outputLines.length),
726
783
  boxBlankLine(theme, renderedWidth),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quandev104/pi-style",
3
- "version": "0.1.3",
3
+ "version": "0.1.4",
4
4
  "description": "A native-layout, cohesive visual style package for Pi.",
5
5
  "license": "MIT",
6
6
  "type": "module",