aboutsummaryrefslogtreecommitdiffstats
path: root/pi-ext/last-prompt.ts
blob: 10fd264798d7908e3c8d7e6f9f3c6b351e5d4454 (plain) (blame)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
/**
 * Last Prompt Topbar Extension ("last sl0peration")
 *
 * Renders a persistent widget above the editor showing the last user prompt in
 * a rounded sl0p-style frame. Spiced up with:
 *   - an animated running spinner + LIVE elapsed-time counter while running
 *   - the last run's duration shown when idle
 *   - a "sl0peration #N" counter for the session
 *   - a full-frame border that pulses through a color palette while running
 *     (accent border when idle)
 *
 * PERFORMANCE HISTORY (why the animation is safe now):
 * The original regression wasn't the timer itself — it was that a full-frame
 * re-render recomputed EVERY tool box (native inner render + framing) from
 * scratch, so an animation ticking requestRender() several times a second
 * turned into hundreds of MB/s of string churn + typing lag over a large
 * transcript. That is now fixed at the source: slop-tool-boxes.ts memoizes each
 * tool row by width and returns cached lines on unchanged width, so a
 * full-frame re-render is cheap. With that in place a bounded animation timer
 * is fine, and we keep it conservative:
 *   - the timer runs ONLY while a run is active (isRunning). While active, pi is
 *     already streaming tokens and re-rendering constantly, so our extra frames
 *     are marginal — not a new idle render load. It stops the instant the run
 *     settles, so it can never become a runaway.
 *   - it calls the captured tui.requestRender() directly instead of setWidget()
 *     each tick, so we don't rebuild the widget factory/closure every frame.
 *   - render() reads live state (Date.now()-startedAt, tick) so each frame shows
 *     the current elapsed time and spinner/scanline position.
 */

import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import { truncateToWidth, visibleWidth, wrapTextWithAnsi } from "@earendil-works/pi-tui";

const SPINNER = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"];

// Animation cadence while a run is active. 10fps gives a smooth spinner + a
// live sub-second timer. Only ever runs during active runs (see file header).
const FRAME_MS = 100;

// While a run is active the whole frame (corners, dashes, verticals, label)
// pulses by cycling through this palette. theme.fg() only accepts semantic
// tokens, which would look steppy, so we interpolate truecolor RGB directly for
// a smooth glide. Curated sl0p/synthwave anchors; the cycle wraps last->first.
const PULSE: Array<[number, number, number]> = [
  [0xff, 0x8a, 0x1e], // amber
  [0xff, 0x2e, 0x88], // hot pink
  [0xb0, 0x5c, 0xff], // violet
  [0x2e, 0xd6, 0xff], // cyan
];
// Frames for one full trip around the palette (~4s at 10fps). Slow = classy.
const PULSE_PERIOD = 40;

function lerp(a: number, b: number, t: number): number {
  return Math.round(a + (b - a) * t);
}

// phase in [0,1) -> interpolated [r,g,b] around the palette ring.
function pulseRGB(phase: number): [number, number, number] {
  const n = PULSE.length;
  const x = ((phase % 1) + 1) % 1 * n;
  const i = Math.floor(x) % n;
  const j = (i + 1) % n;
  const t = x - Math.floor(x);
  const a = PULSE[i];
  const b = PULSE[j];
  return [lerp(a[0], b[0], t), lerp(a[1], b[1], t), lerp(a[2], b[2], t)];
}

function fgRGB(rgb: [number, number, number], s: string): string {
  return `\x1b[38;2;${rgb[0]};${rgb[1]};${rgb[2]}m${s}\x1b[39m`;
}

function fmtDuration(ms: number): string {
  if (ms < 0) ms = 0;
  return `${(ms / 1000).toFixed(1)}s`;
}

export default function (pi: ExtensionAPI) {
  const WIDGET_ID = "last-prompt-topbar";
  let lastPrompt: string | undefined;
  let isRunning = false;
  let count = 0;
  let startedAt = 0;
  let lastDurationMs: number | undefined;
  let tick = 0;
  let timer: ReturnType<typeof setInterval> | undefined;
  let lastCtx: { ui: { setWidget: Function }; hasUI: boolean } | undefined;
  // Captured from the widget factory so the animation timer can drive frames
  // directly via tui.requestRender() instead of rebuilding the widget each tick.
  let tuiRef: { requestRender?: () => void } | undefined;

  // Bounded animation timer. Runs ONLY while a run is active (see file header for
  // why that makes it cheap now that tool boxes are width-memoized). Each tick
  // just bumps `tick` and asks the TUI to re-render; render() reads live state
  // (Date.now()-startedAt, tick) to show the current elapsed time + spinner.
  function startAnim() {
    if (timer) return;
    timer = setInterval(() => {
      tick++;
      tuiRef?.requestRender?.();
    }, FRAME_MS);
  }
  function stopAnim() {
    if (timer) {
      clearInterval(timer);
      timer = undefined;
    }
  }

  function buildTopBorder(width: number, theme: any, border: (s: string) => string): string {
    const h = "─";
    const tl = border("╭");
    const tr = border("╮");
    const dash = (n: number) => border(h.repeat(Math.max(0, n)));
    const innerWidth = Math.max(0, width - 2);

    // Left label:  ◈ sl0peration #7  — stays STATIC (accent); only the border
    // rails pulse, so the label reads clearly against the moving frame.
    const leftLabel =
      " " +
      theme.fg("accent", "◈") +
      " " +
      theme.fg("accent", theme.bold("sl0peration")) +
      theme.fg("muted", ` #${count}`) +
      " ";

    // Right status segment: animated spinner + LIVE elapsed while running,
    // final duration when idle.
    let rightSeg = "";
    if (isRunning) {
      const spin = SPINNER[tick % SPINNER.length];
      const elapsed = fmtDuration(Date.now() - startedAt);
      rightSeg = ` ${theme.fg("warning", spin)} ${theme.fg("dim", elapsed)} `;
    } else if (lastDurationMs !== undefined) {
      rightSeg = ` ${theme.fg("success", "✓")} ${theme.fg("dim", fmtDuration(lastDurationMs))} `;
    } else {
      rightSeg = ` ${theme.fg("success", "✓")} `;
    }

    const leftLead = 1;
    const rightTrail = 1;
    let midLen = innerWidth - leftLead - rightTrail - visibleWidth(leftLabel) - visibleWidth(rightSeg);
    if (midLen < 1) {
      // Not enough room for the status segment — drop it.
      rightSeg = "";
      midLen = innerWidth - leftLead - rightTrail - visibleWidth(leftLabel);
    }
    if (midLen < 0) {
      // Extreme narrow: just draw a plain border.
      return `${tl}${dash(innerWidth)}${tr}`;
    }
    return `${tl}${dash(leftLead)}${leftLabel}${dash(midLen)}${rightSeg}${dash(rightTrail)}${tr}`;
  }

  function buildBottomBorder(width: number, theme: any, border: (s: string) => string): string {
    const h = "─";
    const bl = border("╰");
    const br = border("╯");
    const innerWidth = Math.max(0, width - 2);
    return `${bl}${border(h.repeat(innerWidth))}${br}`;
  }

  function updateWidget(ctx: { ui: { setWidget: Function }; hasUI: boolean }) {
    if (!ctx.hasUI) return;

    if (!lastPrompt) {
      ctx.ui.setWidget(WIDGET_ID, undefined);
      return;
    }

    ctx.ui.setWidget(WIDGET_ID, (tui: any, theme: any) => {
      tuiRef = tui;
      return {
        render(width: number): string[] {
          // While running, the whole frame pulses through the palette in unison.
          // The phase advances with `tick` (bumped by the animation timer), so a
          // single phase drives every border glyph on this frame. Idle = accent.
          const border: (s: string) => string = isRunning
            ? ((rgb) => (s: string) => fgRGB(rgb, s))(pulseRGB((tick % PULSE_PERIOD) / PULSE_PERIOD))
            : (s: string) => theme.fg("accent", s);
          const v = border("│");

          const topLine = buildTopBorder(width, theme, border);

          // Prompt text with accent marker — wrap up to MAX_LINES
          const MAX_LINES = 3;
          const promptText = lastPrompt!.replace(/\n/g, " ↵ ").replace(/\s+/g, " ").trim();
          const firstPrefix = v + " " + border("❯") + " ";
          const contPrefix = v + "   ";
          const suffix = " " + v;
          const available = width - visibleWidth(firstPrefix) - visibleWidth(suffix);

          const wrapped = wrapTextWithAnsi(promptText, Math.max(1, available));
          const lineCount = Math.min(wrapped.length, MAX_LINES);
          const promptLines: string[] = [];
          for (let i = 0; i < lineCount; i++) {
            const prefix = i === 0 ? firstPrefix : contPrefix;
            let text = wrapped[i];
            if (i === lineCount - 1 && wrapped.length > MAX_LINES) {
              const remaining = wrapped.slice(i).join(" ");
              text = truncateToWidth(remaining, available, "…");
            }
            const padLen = Math.max(0, available - visibleWidth(text));
            promptLines.push(`${prefix}${theme.bold(text)}${" ".repeat(padLen)}${suffix}`);
          }

          const bottomLine = buildBottomBorder(width, theme, border);
          return [topLine, ...promptLines, bottomLine];
        },
        invalidate() {},
      };
    });
  }

  // Capture all user input (normal prompts, steers, and follow-ups)
  pi.on("input", async (event, ctx) => {
    if (event.text) {
      lastPrompt = event.text;
      count++;
      lastCtx = ctx;
      updateWidget(ctx);
    }
  });

  // Hide pi's built-in "Working …" status indicator — the last sl0peration box
  // already shows a live spinner + timer. Empty frames hides it entirely.
  // (pi resets this on runtime reset, so we re-apply on session_start too.)
  function hideWorkingIndicator(ctx: any) {
    try {
      if (ctx?.hasUI && typeof ctx.ui?.setWorkingIndicator === "function") {
        ctx.ui.setWorkingIndicator({ frames: [] });
      }
    } catch {
      /* ignore */
    }
  }

  // Track agent running state
  pi.on("agent_start", async (_event, ctx) => {
    isRunning = true;
    startedAt = Date.now();
    lastCtx = ctx;
    hideWorkingIndicator(ctx);
    startAnim();
    updateWidget(ctx);
  });

  function settle(ctx: any) {
    isRunning = false;
    if (startedAt) lastDurationMs = Date.now() - startedAt;
    lastCtx = ctx;
    stopAnim();
    updateWidget(ctx);
  }

  pi.on("agent_end", async (_event, ctx) => settle(ctx));

  // agent_settled fires when pi will not continue automatically — covers abort/
  // error paths where agent_end may not fire, guaranteeing the timer stops.
  pi.on("agent_settled", async (_event, ctx) => settle(ctx));

  pi.on("session_shutdown", async () => {
    isRunning = false;
    stopAnim();
  });

  // Restore state from session on start/reload
  pi.on("session_start", async (_event, ctx) => {
    lastPrompt = undefined;
    count = 0;
    lastDurationMs = undefined;
    isRunning = false;
    lastCtx = ctx;

    // Walk the session branch: track last user prompt + count sl0perations.
    for (const entry of ctx.sessionManager.getBranch()) {
      if (
        entry.type === "message" &&
        entry.message.role === "user" &&
        Array.isArray(entry.message.content)
      ) {
        for (const block of entry.message.content) {
          if (block.type === "text" && block.text) {
            lastPrompt = block.text;
            count++;
          }
        }
      }
    }

    hideWorkingIndicator(ctx);
    updateWidget(ctx);
  });
}