@pi-archimedes/core 2.5.1 → 2.6.2

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.
@@ -22,9 +22,16 @@ import {
22
22
  resolvePalette,
23
23
  } from "../chrome.js";
24
24
  import { isParentBorder, formatKey } from "../text.js";
25
+ import { SPIN_INTERVALS, BorderTypeSpinner } from "./spin.js";
26
+ import { SPIN_SPEED_MULT, type CoreConfig, type SpinnerStyle } from "../config.js";
25
27
 
26
28
  const DOUBLE_PRESS_WINDOW_MS = 500;
27
29
 
30
+ /** The 4-cell window inside the `┌───┐` border row — both stage sets are 1 wide per stage char, so the window stays 4 chars wide. */
31
+ export const SPIN_TYPE_CELLS = BorderTypeSpinner.CELLS;
32
+ /** Window start (in cells) inside the `┌───┐` border row, after `┌` — position 0, the block sits directly after the corner; the window's leading padding space is the first cell after it (`┌␠⠛⠛⠛⠛ …`). */
33
+ export const SPIN_TYPE_START = 0;
34
+
28
35
  export class HephaestusEditor extends CustomEditor {
29
36
  private readonly piKeybindings: KeybindingsManager;
30
37
  private readonly getTheme: () => Theme;
@@ -34,6 +41,16 @@ export class HephaestusEditor extends CustomEditor {
34
41
  private hintMessage: string | undefined;
35
42
  private pendingQuitUntil = 0;
36
43
 
44
+ private readonly spinEnabled: boolean;
45
+ /** Label typed after the 4-cell window while busy (the `editorSpinLabel` setting): an empty string hides it. */
46
+ private readonly spinLabel: string;
47
+ /** The border-row spin spinner (mechanism in `BorderTypeSpinner`): the `spinStyle` (raw setting strings normalize — unknown names fall back to typing frames) — created only when `spin` is on, so the off path stays fully inert. The tick period is the style's native tempo × the `spinSpeed` multiplier, 32 ms floor. */
48
+ private readonly borderSpinner: BorderTypeSpinner | undefined;
49
+ private readonly onSpinInterval:
50
+ | ((interval: ReturnType<typeof setInterval> | undefined) => void)
51
+ | undefined;
52
+ private spinTimer: ReturnType<typeof setInterval> | undefined;
53
+
37
54
  constructor(
38
55
  tui: TUI,
39
56
  editorTheme: EditorTheme,
@@ -42,10 +59,27 @@ export class HephaestusEditor extends CustomEditor {
42
59
  getTheme,
43
60
  isIdle,
44
61
  shutdown,
62
+ spin = false,
63
+ spinSpeed = "normal",
64
+ spinStyle = "pendulum",
65
+ spinLabel = "Working",
66
+ onSpinInterval,
45
67
  }: {
46
68
  getTheme: () => Theme;
47
69
  isIdle: () => boolean;
48
70
  shutdown: () => void;
71
+ /** Type a 4-cell spinner window into the editor's top border while the agent is busy (the animation mechanism lives in `BorderTypeSpinner`, `./spin.js`): the style-configured 4-cell window (the gallery-derived styles in `SPIN_VARIANTS` — the 2×4 braille dot block (⠁ → ⣿, Unicode chart order, `typing`) grows cell-by-cell left→right — each cell walking the 8 stages in 2-step line pairs (⠁⠉/⠋⠛/⠟⠿/⡿⣿) — then holds, clears, repeats (in EAW terminals the stage set falls back to the width-1 shading ░ → █; raw setting strings are tolerated — unknown names normalize to typing frames). */
72
+ spin?: boolean;
73
+ /** Tick period = the style's native per-tick tempo (`SPIN_INTERVALS[normalizeSpinnerStyle(spinStyle)]`) × the `editorSpinSpeed` multiplier (1.5 / 1 / 0.6), clamped at the 32 ms tick floor (the floor also caps a 30 ms native style under `fast` at 32). */
74
+ spinSpeed?: CoreConfig["editorSpinSpeed"];
75
+ /** The `editorSpinStyle` setting — the default style, from the config default (pendulum); raw setting strings are tolerated, but unknown names still normalize to typing frames (the normalizer's fallback, kept distinct from the default). */
76
+ spinStyle?: SpinnerStyle | string;
77
+ /** Label typed after the window while busy (the `editorSpinLabel` setting); an empty string hides it. Non-string values (corrupt config) fall back to "Working". */
78
+ spinLabel?: string;
79
+ /** Lets an out-of-editor scope (core index.ts session hooks) clear the timer. */
80
+ onSpinInterval?: (
81
+ interval: ReturnType<typeof setInterval> | undefined,
82
+ ) => void;
49
83
  },
50
84
  ) {
51
85
  super(tui, editorTheme, keybindings);
@@ -53,6 +87,49 @@ export class HephaestusEditor extends CustomEditor {
53
87
  this.getTheme = getTheme;
54
88
  this.isIdle = isIdle;
55
89
  this.shutdown = shutdown;
90
+ this.onSpinInterval = onSpinInterval;
91
+ this.spinEnabled = spin;
92
+ // Non-string values (corrupt config) fall back to "Working" before `spinLabel` reaches `visibleWidth(label)`.
93
+ const safeSpinLabel =
94
+ typeof spinLabel === "string" ? spinLabel : "Working";
95
+ this.spinLabel = safeSpinLabel;
96
+ this.borderSpinner = spin ? new BorderTypeSpinner(this.isIdle, spinStyle) : undefined;
97
+ if (spin) {
98
+ // The style's native per-tick tempo × the `editorSpinSpeed` multiplier, 32 ms tick floor (the floor also caps a 30 ms native style under `fast` at 32); unknown raw strings fall back to the typing native (the normalize fallback).
99
+ const nativeMs = SPIN_INTERVALS[spinStyle as SpinnerStyle];
100
+ const spinTickMs = Math.max(
101
+ 32,
102
+ (nativeMs ?? SPIN_INTERVALS["typing"]) * (SPIN_SPEED_MULT[spinSpeed] ?? 1),
103
+ );
104
+ this.spinTimer = setInterval(() => this.tickSpin(), spinTickMs);
105
+ this.onSpinInterval?.(this.spinTimer);
106
+ }
107
+ }
108
+
109
+ /** Clears the spinner timer and drops the module handle; idempotent. Pi 0.85.1 never calls dispose on a replaced editor — module-scope reaping is the operative safety net. */
110
+ dispose(): void {
111
+ if (this.spinTimer) {
112
+ clearInterval(this.spinTimer);
113
+ this.spinTimer = undefined;
114
+ this.onSpinInterval?.(undefined);
115
+ }
116
+ }
117
+
118
+ // ── Prompt spin ───────────────────────────────────────
119
+
120
+ private tickSpin(): void {
121
+ if (this.borderSpinner && this.borderSpinner.tick()) {
122
+ this.tui.requestRender();
123
+ }
124
+ }
125
+
126
+ /** The 4-cell window that replaces a segment of the border (mechanism in `BorderTypeSpinner`): cells fill cell-by-cell left→right, the current step's stage chars rendered in the spin (accent) palette (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿; EAW: the width-1 shading ░ → █) — the 2×4 dot block grows across the window — the not-yet-reached cells are plain spaces (the border line breaks) — plain " " (U+0020). The empty clear step (step 0) is all spaces; hold steps clamp to the last (fully grown) frame. The window sits one leading space after `┌` at start 0 (right at the corner), and the label follows it typed as `" " + spinLabel + " "` — its own leading and trailing spaces, with the trailing one providing the margin to the trailing dashes. */
127
+ private typeStrip(): string {
128
+ const p = resolvePalette(this.getTheme());
129
+ if (!this.borderSpinner) return " ".repeat(SPIN_TYPE_CELLS);
130
+ return this.borderSpinner.frame().split("").map(
131
+ (c) => (c === " " ? " " : p.spin(c)),
132
+ ).join("");
56
133
  }
57
134
 
58
135
  // ── Quit hint ─────────────────────────────────────────────
@@ -146,8 +223,63 @@ export class HephaestusEditor extends CustomEditor {
146
223
  ),
147
224
  );
148
225
 
149
- const topLine =
150
- p.frame("┌") + p.frame("─".repeat(inner)) + p.frame("┐");
226
+ // Top border row: while busy, a 4-cell window at start 0 replaces a
227
+ // segment of the `─` border and the block sits directly after `┌` —
228
+ // the window's leading padding space is the first cell after the
229
+ // corner, then the window; the config label (`editorSpinLabel`,
230
+ // default "Working") follows the window typed as `Working ` — one
231
+ // space before AND after the label — when the box is wide enough
232
+ // (inner >= 0 + 1 + 4 + 1 + labelW + 1 + 2 = labelW + 9, where
233
+ // labelW = visibleWidth(label) — 9 for the default label; a CJK
234
+ // label's visible width exceeds its char length — a margin of 2
235
+ // after the trailing space; empty label or too-narrow
236
+ // box → window only, not standalone), plain when too narrow to fit
237
+ // (inner < 7, the minimum busy row is start 0 + 1 leading space +
238
+ // 4 cells + 2 trailing margin). The window fills cell-by-cell
239
+ // left→right, each cell walking the 8 chart-order stages in 2-step
240
+ // line pairs (⠁⠉ / ⠋⠛ / ⠟⠿ / ⡿⣿; EAW: the width-1 shading ░ → █), so
241
+ // the 2×4 dot block grows across the window line by line, followed
242
+ // by the label (shown when the box hosts it; over-long labels never
243
+ // sink the row — Math.max keeps the trailing ≥ 0 and the
244
+ // window-only tier handles them), then holds — on the empty clear
245
+ // step (step 38) the window cells are spaces and the label stays
246
+ // up (the border line breaks there); the leading space + window +
247
+ // label's columns replace trailing dashes, so the row width stays
248
+ // constant (the trailing run shortens accordingly: inner − 0 − 1 −
249
+ // 4 − (1 + labelW + 1) when the label shows, inner − 1 − 4
250
+ // otherwise).
251
+ const borderRun = (() => {
252
+ const busy = this.spinEnabled && !this.isIdle();
253
+ const label = this.spinLabel;
254
+ // Visible width (not `.length`): a 4-char CJK label is 8 cells wide — `label.length` would short the row.
255
+ const labelW = visibleWidth(label);
256
+ const labelShown =
257
+ busy &&
258
+ label !== "" &&
259
+ inner >=
260
+ SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + 1 + labelW + 1 + 2; // labelFit = 0 + 1 (left padding) + 4 (window) + (" " + label + " " = 1 + labelW + 1) + 2 (trailing margin) = labelW + 9
261
+ if (!busy || inner < SPIN_TYPE_START + 1 + SPIN_TYPE_CELLS + 2) { // inner < 7 → plain (no window, no label)
262
+ return p.frame("─".repeat(inner));
263
+ }
264
+ const start = SPIN_TYPE_START; // 0 — the block sits directly after `┌` whenever it shows
265
+ // Label cost = its own leading space + the label + its own trailing
266
+ // space; the Math.max floor means an over-long label can never
267
+ // sink the trailing below zero (those boxes degraded to
268
+ // window-only above).
269
+ const labelCost = labelShown ? 1 + labelW + 1 : 0;
270
+ return (
271
+ p.frame("─".repeat(start)) +
272
+ " " +
273
+ this.typeStrip() +
274
+ (labelShown ? p.time(" " + label + " ") : "") +
275
+ p.frame(
276
+ "─".repeat(
277
+ Math.max(0, inner - start - 1 - SPIN_TYPE_CELLS - labelCost),
278
+ ),
279
+ )
280
+ );
281
+ })();
282
+ const topLine = p.frame("┌") + borderRun + p.frame("┐");
151
283
  const botLine =
152
284
  p.frame("└") + p.frame("─".repeat(inner)) + p.frame("┘");
153
285