diff --git a/README.md b/README.md index 0bcf701..f4eb9a2 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ Then `/reload` (or restart pi) and run `/cursor` to open the settings panel. ## Features +- **Native working rule** (v0.2.4): while the agent streams, the editor's top border renders as pi's native `── ⠸ Working ──…` label-in-rule (braille spinner, accent/muted themed) — restoring the default-editor chrome that custom editors lose, with pi's separate bare loader hidden to avoid duplication. - **Focused styles:** `block` (pi native, default), `bar` (▎), `underline`. - **Unfocused styles:** `hollow` (□ sharp hollow block, default), `outline` (▢ rounded), `dim` (faint block), `underline`, `hide`, **`highlight`** (char-preserving colored undercurl). - **Cursor color** (v0.2.0): `accent` (follows the pi theme, truecolor) or an explicit `#RRGGBB`. diff --git a/extensions/cursor.ts b/extensions/cursor.ts index 5e8357e..d270cb2 100644 --- a/extensions/cursor.ts +++ b/extensions/cursor.ts @@ -125,6 +125,10 @@ export default function (pi: ExtensionAPI): void { prevEditorFactory = ctx.ui.getEditorComponent?.() ?? null; const blinkController = new BlinkController(); blink = blinkController; + // #6: cursor's custom editor replaces the default editor, which loses pi's native + // `── ⠸ Working ──…` border rule — so cursor draws it (editor.setStreaming) and hides + // pi's separate bare loader line to avoid duplication. + ctx.ui.setWorkingVisible(false); ctx.ui.setEditorComponent((tui: any, theme: any, keybindings: any) => { const wrapped = prevEditorFactory ? prevEditorFactory(tui, theme, keybindings) : null; const ed = new CursorEditor(tui, theme, keybindings, { wrapped, blink: blinkController, getTheme: () => ctx.ui.theme }); @@ -141,9 +145,20 @@ export default function (pi: ExtensionAPI): void { }); }); + pi.on("agent_start", async () => { + editor?.setStreaming(true); + }); + pi.on("agent_end", async () => { + editor?.setStreaming(false); + }); + pi.on("agent_settled", async () => { + editor?.setStreaming(false); + }); + pi.on("session_shutdown", async () => { configWatcher?.close(); configWatcher = undefined; + editor?.setStreaming(false); editor?.restoreCursor?.(); await provider?.stop(); provider = null; diff --git a/lib/editor.ts b/lib/editor.ts index 962d37d..6f97143 100644 --- a/lib/editor.ts +++ b/lib/editor.ts @@ -2,6 +2,7 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent"; import { transformFocused, transformUnfocused, decscusr, osc12, themeAccentHex } from "./render.ts"; import type { CursorConfig, CursorMode } from "./defaults.ts"; import { BlinkController } from "./state.ts"; +import { WORKING_INTERVAL_MS, workingBorderLine, stripAnsi } from "./working-line.ts"; type Theme = { getFgAnsi(color: string): string; getColorMode(): "truecolor" | "256color" }; @@ -13,6 +14,8 @@ export interface CursorEditorDeps { * The real Theme exposes `getFgAnsi`/`getColorMode` for truecolor cursor * colors and must be read live so theme switches are reflected. */ getTheme: () => Theme; + /** Injectable timer scheduler (tests). Default: global setInterval/clearInterval. */ + scheduler?: { setInterval(callback: () => void, ms: number): unknown; clearInterval(id: unknown): void }; } export function composeRender( @@ -33,6 +36,10 @@ export class CursorEditor extends CustomEditor { private cfg: CursorConfig; private paneFocused = true; private deps: CursorEditorDeps; + // #6: native working-rule state — replaces the top border with `── ⠸ Working ──…` while streaming. + private streaming = false; + private frame = 0; + private spinnerTimer: unknown = null; constructor(tui: any, theme: any, keybindings: any, deps: CursorEditorDeps) { super(tui, theme, keybindings, {}); @@ -108,6 +115,31 @@ export class CursorEditor extends CustomEditor { this.tui?.requestRender?.(); } + /** #6: while streaming, render the top border as the native `── ⠸ Working ──…` rule + * (pi's default editor embeds the label in the border; custom editors lose that). + * Owns a spinner interval — always pair with setStreaming(false) (timer hygiene). */ + setStreaming(active: boolean): void { + if (this.streaming === active) return; + this.streaming = active; + const sched = this.deps.scheduler ?? { + setInterval: (cb: () => void, ms: number) => setInterval(cb, ms), + clearInterval: (id: unknown) => clearInterval(id as ReturnType), + }; + if (active) { + this.frame = 0; + this.spinnerTimer = sched.setInterval(() => { + this.frame++; + this.invalidate?.(); + this.tui?.requestRender?.(); + }, WORKING_INTERVAL_MS); + } else { + sched.clearInterval(this.spinnerTimer); + this.spinnerTimer = null; + } + this.invalidate?.(); + this.tui?.requestRender?.(); + } + handleInput(data: string): void { if (this.deps.wrapped) this.deps.wrapped.handleInput(data); else super.handleInput(data); @@ -115,6 +147,20 @@ export class CursorEditor extends CustomEditor { render(width: number): string[] { const lines = this.deps.wrapped ? this.deps.wrapped.render(width) : super.render(width); - return composeRender(lines, this.paneFocused, this.cfg, this.deps.getTheme(), this.deps.blink.visible, this.cfg.cursorMode); + const out = composeRender(lines, this.paneFocused, this.cfg, this.deps.getTheme(), this.deps.blink.visible, this.cfg.cursorMode); + // #6: streaming rule — replace the plain top border with `── ⠸ Working ──…`. + // Only when cursor owns the full render (no wrapped foreign editor) and line 0 is a pure rule. + if (!this.streaming || this.deps.wrapped || out.length === 0) return out; + const stripped = stripAnsi(out[0]!); + if (!/^─+$/.test(stripped)) return out; + const theme = this.deps.getTheme(); + const self = this as unknown as { borderColor?: (s: string) => string }; + const borderColor = typeof self.borderColor === "function" ? self.borderColor : (s: string) => s; + out[0] = workingBorderLine(width, this.frame, { + spinner: (s) => theme.getFgAnsi("accent") + s + "\x1b[0m", + message: (s) => theme.getFgAnsi("muted") + s + "\x1b[0m", + border: borderColor, + }); + return out; } } \ No newline at end of file diff --git a/lib/working-line.ts b/lib/working-line.ts new file mode 100644 index 0000000..0ff6bab --- /dev/null +++ b/lib/working-line.ts @@ -0,0 +1,35 @@ +import { visibleWidth } from "@earendil-works/pi-tui"; + +/** pi Loader default braille frames (pi-tui components/loader.js) — same animation as the native indicator. */ +export const WORKING_FRAMES = ["⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"]; +export const WORKING_MESSAGE = "Working"; +export const WORKING_INTERVAL_MS = 80; + +/** Strip ANSI SGR sequences — local copy so we don't depend on newer pi-tui exports. */ +export function stripAnsi(s: string): string { + // eslint-disable-next-line no-control-regex + return s.replace(/\x1b\[[0-9;]*[A-Za-z]/g, ""); +} + +export interface WorkingLineColors { + spinner: (s: string) => string; + message: (s: string) => string; + border: (s: string) => string; +} + +/** Native streaming rule (pi default-editor style): `── ⠋ Working ────────`. + * Cursor's custom editor replaces the default editor, which loses this label-in-border + * composition (cursor#6) — this rebuilds it. Pure: caller supplies the color functions; + * the rendered line's visible width === `width` (ANSI-aware via pi-tui visibleWidth). */ +export function workingBorderLine( + width: number, + frame: number, + colors: WorkingLineColors, +): string { + const w = Math.max(0, width); + const frames = WORKING_FRAMES; + const spin = frames[((frame % frames.length) + frames.length) % frames.length]!; + const label = `${colors.border("── ")}${colors.spinner(spin)} ${colors.message(WORKING_MESSAGE)}${colors.border(" ")}`; + const remaining = w - visibleWidth(label); + return remaining >= 0 ? label + colors.border("─".repeat(remaining)) : label; +} diff --git a/tests/_probe.ts b/tests/_probe.ts new file mode 100644 index 0000000..7fc8dad --- /dev/null +++ b/tests/_probe.ts @@ -0,0 +1,12 @@ +import { CursorEditor } from "../lib/editor.ts"; +import { DEFAULT_CONFIG } from "../lib/defaults.ts"; +console.log("imports done"); +const THEME: any = { getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;5;7m" : c === "muted" ? "\x1b[38;5;8m" : ""), getColorMode: () => "256color" as const, borderColor: (s: string) => `\x1b[38;5;8m${s}\x1b[0m`, fg: (c: string, s: string) => s }; +console.log("constructing…"); +const ed = new CursorEditor({ requestRender: () => {}, terminal: { rows: 40 } } as any, THEME, {} as any, { wrapped: null, blink: { setActive: () => {}, visible: true, stop: () => {}, start: () => {} } as any, getTheme: () => THEME }); +console.log("constructed"); +ed.updateConfig(DEFAULT_CONFIG); +console.log("config applied, rendering…"); +const out = ed.render(80); +console.log("rendered:", JSON.stringify(out.slice(0, 1))); +process.exit(0); diff --git a/tests/working-line.test.ts b/tests/working-line.test.ts new file mode 100644 index 0000000..371dbb6 --- /dev/null +++ b/tests/working-line.test.ts @@ -0,0 +1,34 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { WORKING_FRAMES, WORKING_MESSAGE, workingBorderLine } from "../lib/working-line.ts"; + +// No-op colors: assertions stay on visible composition. +const plain = (s: string) => s; +const colors = { spinner: plain, message: plain, border: plain }; + +test("workingBorderLine: native rule — '── ⠋ Working ' + dashes filling to width", () => { + const line = workingBorderLine(80, 0, colors); + assert.equal(line, `── ${WORKING_FRAMES[0]} ${WORKING_MESSAGE} ${"─".repeat(80 - 3 - 1 - 1 - WORKING_MESSAGE.length - 1)}`); +}); + +test("workingBorderLine: visible width === width for every frame (ANSI-safe)", () => { + const accent = (s: string) => `\x1b[38;5;7m${s}\x1b[0m`; + const muted = (s: string) => `\x1b[38;5;8m${s}\x1b[0m`; + const border = (s: string) => `\x1b[38;5;8m${s}\x1b[0m`; + for (let frame = 0; frame < WORKING_FRAMES.length; frame++) { + const line = workingBorderLine(140, frame, { spinner: accent, message: muted, border }); + assert.equal(visibleWidth(line), 140, `frame ${frame}`); + } +}); + +test("workingBorderLine: frame cycles through pi's braille set (negative-safe)", () => { + assert.equal(workingBorderLine(80, 1, colors).slice(3, 4), WORKING_FRAMES[1 % WORKING_FRAMES.length]); + assert.equal(workingBorderLine(80, -1, colors).slice(3, 4), WORKING_FRAMES[WORKING_FRAMES.length - 1]); +}); + +test("workingBorderLine: narrower than the label → label only, no negative padding", () => { + const line = workingBorderLine(8, 0, colors); + assert.ok(line.startsWith(`── ${WORKING_FRAMES[0]} ${WORKING_MESSAGE}`), "keeps the label content"); + assert.ok(!line.trimEnd().endsWith("───"), "no trailing dash run added"); +}); diff --git a/tests/working-rule.test.ts b/tests/working-rule.test.ts new file mode 100644 index 0000000..94b1650 --- /dev/null +++ b/tests/working-rule.test.ts @@ -0,0 +1,95 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { visibleWidth } from "@earendil-works/pi-tui"; +import { CursorEditor } from "../lib/editor.ts"; +import { WORKING_FRAMES, WORKING_MESSAGE, workingBorderLine, stripAnsi } from "../lib/working-line.ts"; +import { DEFAULT_CONFIG } from "../lib/defaults.ts"; +import { BlinkController, type Scheduler } from "../lib/state.ts"; + +// Full-ish fake theme: EditorTheme borderColor for the base Editor + real-theme fg for the rule. +const THEME = { + getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;5;7m" : c === "muted" ? "\x1b[38;5;8m" : ""), + getColorMode: () => "256color" as const, + borderColor: (s: string) => `\x1b[38;5;8m${s}\x1b[0m`, + fg: (c: string, s: string) => (c === "accent" ? `\x1b[38;5;7m${s}\x1b[0m` : c === "muted" ? `\x1b[38;5;8m${s}\x1b[0m` : s), +}; + +// Manual scheduler: capture the interval callback so frames advance deterministically. +function manualScheduler() { + let cb: (() => void) | null = null; + return { + scheduler: { + setInterval: (fn: () => void, _ms: number) => { cb = fn; return 1; }, + clearInterval: () => { cb = null; }, + } satisfies Scheduler, + tick: () => cb?.(), + running: () => cb !== null, + }; +} + +function makeBareEditor() { + const blink = new BlinkController(manualScheduler().scheduler); + const tui = { requestRender: () => {}, terminal: { rows: 40 } }; + const ed = new CursorEditor(tui as any, THEME as any, {} as any, { + wrapped: null, + blink, + getTheme: () => THEME as any, + }); + ed.updateConfig(DEFAULT_CONFIG); + return ed; +} + +test("setStreaming(true) → top border becomes the native working rule; false restores plain border", () => { + const ed = makeBareEditor(); + const before = ed.render(80); + assert.match(stripAnsi(before[0]!), /^─+$/, "idle: plain top border"); + try { + ed.setStreaming(true); + const during = ed.render(80); + assert.equal(during[0], workingBorderLine(80, 0, { + spinner: (s) => THEME.fg("accent", s), + message: (s) => THEME.fg("muted", s), + border: (s) => THEME.borderColor(s), + }), "streaming: label-in-rule on line 0"); + const rest = during.slice(1).map((l) => stripAnsi(l)); + assert.ok(rest.some((l) => /^─+$/.test(l)), "bottom border still present"); + } finally { + ed.setStreaming(false); // never leak the real interval — it would hang the test runner + } + assert.equal(stripAnsi(ed.render(80)[0]!), "─".repeat(80), "idle again after stop"); +}); + +test("spinner frames advance via the scheduler ticks", () => { + const sched = manualScheduler(); + const blink = new BlinkController(sched.scheduler); + const tui = { requestRender: () => {}, terminal: { rows: 40 } }; + const ed = new CursorEditor(tui as any, THEME as any, {} as any, { + wrapped: null, blink, getTheme: () => THEME as any, scheduler: sched.scheduler, + }); + ed.updateConfig(DEFAULT_CONFIG); + ed.setStreaming(true); + assert.ok(sched.running(), "interval running while streaming"); + const f0 = ed.render(80)[0]!; + sched.tick(); + sched.tick(); + const f2 = ed.render(80)[0]!; + assert.notEqual(f0, f2, "frame advanced"); + assert.equal(visibleWidth(f2), 80, "rule still fills width"); + ed.setStreaming(false); + assert.ok(!sched.running(), "interval cleared on stop (timer hygiene)"); +}); + +test("wrapped editor present → render untouched by streaming (respect foreign composition)", () => { + const sched = manualScheduler(); + const blink = new BlinkController(sched.scheduler); + const tui = { requestRender: () => {}, terminal: { rows: 40 } }; + const ed = new CursorEditor(tui as any, THEME as any, {} as any, { + wrapped: { render: () => [`── wrapped ──`], handleInput: () => {} }, + blink, + getTheme: () => THEME as any, + scheduler: sched.scheduler, + }); + ed.updateConfig(DEFAULT_CONFIG); + ed.setStreaming(true); + assert.deepEqual(ed.render(80), [`── wrapped ──`]); +});