Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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`.
Expand Down
15 changes: 15 additions & 0 deletions extensions/cursor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 });
Expand All @@ -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;
Expand Down
48 changes: 47 additions & 1 deletion lib/editor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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" };

Expand All @@ -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(
Expand All @@ -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, {});
Expand Down Expand Up @@ -108,13 +115,52 @@ 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<typeof setInterval>),
};
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);
}

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;
}
}
35 changes: 35 additions & 0 deletions lib/working-line.ts
Original file line number Diff line number Diff line change
@@ -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;
}
12 changes: 12 additions & 0 deletions tests/_probe.ts
Original file line number Diff line number Diff line change
@@ -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);
34 changes: 34 additions & 0 deletions tests/working-line.test.ts
Original file line number Diff line number Diff line change
@@ -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");
});
95 changes: 95 additions & 0 deletions tests/working-rule.test.ts
Original file line number Diff line number Diff line change
@@ -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 ──`]);
});
Loading