diff --git a/AGENTS.md b/AGENTS.md index c2131b6..f5aefd8 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -2,8 +2,10 @@ A pi coding-agent extension that customizes the editor cursor and makes it focus-aware across terminal/multiplexer stacks (tmux + cmux + herdr in v0.1.1; -static fallback elsewhere; zellij/screen/wezterm + bare-terminal DEC 1004 in -later releases). +static fallback elsewhere; v0.2.0 adds truecolor + theme-aware cursor colors, +a char-preserving `highlight` unfocused style, and an opt-in `hardware` cursor +mode for Ghostty; zellij/screen/wezterm + bare-terminal DEC 1004 in later +releases). ## Build & test @@ -21,6 +23,8 @@ pnpm test:run # node:test via tsx - `/cursor unfocused dim|hollow|outline|underline|hide` - `/cursor blink on [ms] | off` - `/cursor provider auto|tmux|cmux|herdr|static` +- `/cursor color accent|#RRGGBB` (v0.2.0) +- `/cursor mode fake|hardware` (v0.2.0) - `/cursor status` - `/cursor reset` (also `r` in the panel) @@ -31,8 +35,8 @@ pnpm test:run # node:test via tsx | `extensions/cursor.ts` | entry: `/cursor` command + session lifecycle | | `lib/defaults.ts` | style enums + default config | | `lib/config.ts` | load/save/normalize config (`~/.pi/agent/cursor.json`) | -| `lib/render.ts` | ANSI cursor-cell transforms per style | -| `lib/editor.ts` | `CursorEditor` (CustomEditor subclass) + composition | +| `lib/render.ts` | ANSI cursor-cell transforms per style + truecolor color helpers + `highlight` undercurl + hardware bare-char | +| `lib/editor.ts` | `CursorEditor` (CustomEditor subclass) + composition + hardware-mode side effects (DECSCUSR/OSC12, `setShowHardwareCursor`, `restoreCursor`) | | `lib/panel.ts` | `/cursor` SettingsList rows | | `lib/state.ts` | blink timer | | `lib/focus/index.ts` | `FocusProvider` interface + auto-detect | @@ -56,6 +60,16 @@ pnpm test:run # node:test via tsx `docs/events.md`, `docs/cli-contract.md`); uses the `debug.terminal.is_focused` RPC polled every ~300 ms. Unverified without a live cmux session in v0.1.1; debug-build glob socket discovery not implemented. +- v0.2.0 truecolor cursor colors resolve via pi's `Theme.getFgAnsi("accent"/"dim")` + (truecolor end-to-end through tmux `Tc`); `cursorColor` hex override emits + `\x1b[38;2;R;G;Bm`. `highlight` unfocused style = char-preserving colored + undercurl (`4:3` + `58:2::R:G:B`), 256-color fallback = plain underline. +- v0.2.0 `hardware` cursor mode emits DECSCUSR (`\x1b[ q`) + OSC 12 + (`\x1b]12;\x07`) for the focused state via `tui.terminal.write`, toggles + `tui.setShowHardwareCursor`, and `restoreCursor()` on `session_shutdown` + (resets shape/color — no terminal state leak). Native DECSCUSR blink replaces + the fake BlinkController in hardware+blink. 256-color theme → OSC 12 skipped. + ⚠️ Unverified against a live Ghostty+tmux pane; Ghostty-targeted. - herdr adapter is built from `herdr.dev/docs/socket-api` (verified against `herdr api schema --json`); unverified without a live herdr session in v0.1. - Bare-terminal (no multiplexer) = static mode in v0.1 (pi-tui upstream gap; diff --git a/README.md b/README.md index 1ab895e..f728ccf 100644 --- a/README.md +++ b/README.md @@ -15,8 +15,10 @@ Then `/reload` (or restart pi) and run `/cursor` to open the settings panel. ## Features - **Focused styles:** `block` (pi native, default), `bar` (▎), `underline`. -- **Unfocused styles:** `hollow` (□ sharp hollow block, default), `outline` (▢ rounded), `dim` (faint block), `underline`, `hide`. -- **Blink** (opt-in, default off) — pauses when the pane is unfocused. +- **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`. +- **Cursor mode** (v0.2.0): `fake` (pi's fake cursor, default) or `hardware` (native terminal cursor via DECSCUSR + OSC 12 — Ghostty-targeted). +- **Blink** (opt-in, default off) — pauses when the pane is unfocused. In `hardware` mode, native DECSCUSR blink replaces the fake blink. - **Focus detection** via a pluggable `FocusProvider`: - **tmux** — pane-focus-in/out hooks + `fs.watch` (push). - **cmux** — cmux v2 socket API `debug.terminal.is_focused` RPC (poll). @@ -30,9 +32,11 @@ Then `/reload` (or restart pi) and run `/cursor` to open the settings panel. /cursor # open the settings panel (TUI) or print status /cursor on | off # master switch /cursor focused block|bar|underline -/cursor unfocused dim|hollow|outline|underline|hide +/cursor unfocused dim|hollow|outline|underline|hide|highlight /cursor blink on [ms] | off # ms ∈ {400,500,600,800,1000} /cursor provider auto|tmux|cmux|herdr|static +/cursor color accent|#RRGGBB +/cursor mode fake|hardware /cursor status # print config + active provider + detected env /cursor reset # reset to defaults (also: `r` in the panel) ``` @@ -70,9 +74,22 @@ No multiplexer detected (bare Ghostty/Kitty/iTerm2/Alacritty, or unknown). The c > **Bare-terminal focus detection (DEC 1004) is not in v0.1** — pi 0.80.x doesn't enable `?1004h` or parse `\x1b[I`/`\x1b[O`. Tracked for a later release. +## Cursor color (v0.2.0) + +`/cursor color accent|#RRGGBB`. `accent` (default) resolves the cursor color from pi's loaded theme via `theme.getFgAnsi("accent")` — truecolor end-to-end through tmux (`Tc`) on capable stacks (Ghostty + tmux). An explicit `#RRGGBB` override emits `\x1b[38;2;R;G;Bm` directly. The unfocused color is derived automatically: `accent` → the theme's `dim` color; a hex → the same hex dimmed 50%. + +## Cursor modes (v0.2.0) + +`/cursor mode fake|hardware` (default `fake`). + +- **`fake`** — pi's render-transformed fake cursor (the v0.1 path). All focused + unfocused styles apply. +- **`hardware`** — drives the terminal's *native* cursor for the focused state: DECSCUSR (`\x1b[ q`) sets the shape (block/underline/bar), OSC 12 (`\x1b]12;\x07`) sets the color, and pi positions the real cursor via `tui.setShowHardwareCursor(true)`. The focused cell renders as a bare char so only the native cursor shows. On focus loss the hardware cursor is hidden and the unfocused fake-cursor transform takes over (with the cursor color + any unfocused style incl. `highlight`). On `session_shutdown` the terminal cursor is restored to its default shape/color (no state leak). + +> **⚠️ v0.2.0 hardware mode + truecolor are built from the pi-tui source + spec, unit-tested against mocks, but not verified against a live Ghostty+tmux pane in this release.** Hardware mode is Ghostty-targeted (DECSCUSR + OSC 12 are standard but only verified on Ghostty here); other terminals get the `fake` default. In 256-color theme mode, OSC 12 is skipped (no exact hex) and the terminal uses its configured cursor color. See `lib/editor.ts` + `lib/render.ts`. + ## The char-hidden constraint -A fake cursor *is* the cell — ANSI has no partial-cell overlay. So the **`bar`** (focused) and **`hollow`**/**`outline`** (unfocused) styles render a glyph that **hides the character at the cursor position** while the cursor sits on it; the character reappears when the cursor moves. This is a terminal limitation, not a bug. `block`, `underline`, `dim`, and `hide` preserve the character. +A fake cursor *is* the cell — ANSI has no partial-cell overlay. So the **`bar`** (focused) and **`hollow`**/**`outline`** (unfocused) styles render a glyph that **hides the character at the cursor position** while the cursor sits on it; the character reappears when the cursor moves. This is a terminal limitation, not a bug. `block`, `underline`, `dim`, `hide`, and **`highlight`** (char-preserving colored undercurl) preserve the character. In `hardware` mode the focused state uses the native terminal cursor (no fake cell), sidestepping this entirely. ## Compatibility diff --git a/docs/superpowers/plans/2026-07-19-ghostty-tmux-deep.md b/docs/superpowers/plans/2026-07-19-ghostty-tmux-deep.md new file mode 100644 index 0000000..11dd302 --- /dev/null +++ b/docs/superpowers/plans/2026-07-19-ghostty-tmux-deep.md @@ -0,0 +1,1187 @@ +# @getpipher/cursor v0.2.0 — Ghostty + tmux Deep Integration Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Ship `@getpipher/cursor@0.2.0` with truecolor + theme-aware cursor colors (A), a char-preserving `highlight` unfocused style (B), and an opt-in hardware-cursor mode (C) — all deepening the Ghostty + tmux stack. + +**Architecture:** A threads pi's `Theme` into `render.ts` so cursor cell colors resolve via `theme.getFgAnsi()` (truecolor end-to-end). B adds a styled-underline unfocused style. C drives Ghostty's native cursor (DECSCUSR + OSC 12) via `tui.setShowHardwareCursor()` for the focused state, reusing A+B's fake-cursor transform for the unfocused state. The v0.1.1 fake-cursor path remains the default. + +**Tech Stack:** TypeScript (strict, `noUncheckedIndexedAccess`), `tsx` + `node:test`/`node:assert/strict` (NOT vitest), pi `@earendil-works/pi-coding-agent` + `@earendil-works/pi-tui` `^0.80.x`. Relative imports use `.ts` extensions. + +## Global Constraints + +- Stack: `tsx` + `node:test`/`node:assert/strict`. No vitest, no eslint. peerDeps `*`. tsconfig `types: ["node"]`. +- `CURSOR_MARKER` imported from `@earendil-works/pi-tui` — never hardcode. +- One commit per task (`feat:`/`fix:`/`chore:`/`docs:`). No AI attribution. MIT, author RECTOR. +- 80%+ coverage on new code. `pnpm typecheck` + `pnpm test:run` green after every task. +- Branch: `feat/ghostty-tmux-deep` (already created). Tag `v0.2.0` (signed: `git tag -s v0.2.0 -m "..."`). +- Defaults MUST reproduce v0.1.1 behavior exactly: `cursorColor: "accent"`, `cursorMode: "fake"` → no visual regression. +- pi-tui facts (spike-confirmed): `TUI.setShowHardwareCursor(bool)` is public; `positionHardwareCursor()` writes only `\x1b[?25h` per frame (never DECSCUSR/OSC12, so our modes persist); `extractCursorPosition()` strips `CURSOR_MARKER` but leaves the styled cell. +- `Theme` API: `theme.getFgAnsi(color: ThemeColor): string` (returns `\x1b[38;2;R;G;Bm` truecolor or `\x1b[38;5;Nm` 256); `theme.getColorMode(): "truecolor" | "256color"`. `ThemeColor` includes `"accent"`, `"muted"`, `"dim"`, `"border"`. +- Build/test: `pnpm install` · `pnpm typecheck` · `pnpm test:run`. + +--- + +## File Structure + +| file | responsibility | change | +|---|---|---| +| `lib/defaults.ts` | style enums + default config | add `cursorColor`, `cursorMode`, `"highlight"` | +| `lib/config.ts` | load/save/normalize/cycle config | parse/validate new fields; cycleValue new keys | +| `lib/render.ts` | ANSI cursor-cell transforms + color/escape helpers | thread `Theme`; truecolor colors; `highlight`; hardware bare-char; new helpers | +| `lib/editor.ts` | `CursorEditor` composition + hardware-mode side effects | pass theme; emit DECSCUSR/OSC12; toggle `setShowHardwareCursor`; shutdown restore | +| `lib/panel.ts` | `/cursor` SettingsList rows | new rows `cursorColor` + `cursorMode` | +| `extensions/cursor.ts` | entry: `/cursor` command + lifecycle | parse `color` + `mode` subcommands; pass `tui` ref to editor | +| `lib/state.ts` | blink timer | **no change** (editor skips it in hardware-focused mode) | +| `lib/focus/*` | focus providers | **no change** | + +New pure helpers in `render.ts` (Task 2): `hexToAnsi`, `dimHex`, `parseAnsiFgToHex`, `decscusr`, `osc12`, `resolveFocusedColor`, `resolveUnfocusedColor`. + +--- + +## Task 1: Config schema — `cursorColor`, `cursorMode`, `highlight` + +**Files:** +- Modify: `lib/defaults.ts` +- Modify: `lib/config.ts` +- Test: `tests/defaults.test.ts` +- Test: `tests/config.test.ts` + +**Interfaces:** +- Produces: `CursorConfig.cursorColor: string` (`"accent"` | `"#RRGGBB"`), `CursorConfig.cursorMode: "fake" | "hardware"`, `UnfocusedStyle` gains `"highlight"`. `DEFAULT_CONFIG` adds `cursorColor: "accent"`, `cursorMode: "fake"`. `UNFOCUSED_STYLES` gains `"highlight"`. `normalizeConfig` + `cycleValue` handle the new keys. + +- [ ] **Step 1: Write the failing tests (defaults)** + +Append to `tests/defaults.test.ts`: + +```typescript +test("default config has v0.2.0 additions", () => { + assert.equal(DEFAULT_CONFIG.cursorColor, "accent"); + assert.equal(DEFAULT_CONFIG.cursorMode, "fake"); +}); + +test("unfocused styles enum includes highlight", () => { + assert.deepEqual(UNFOCUSED_STYLES, ["dim", "hollow", "outline", "underline", "hide", "highlight"]); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `DEFAULT_CONFIG.cursorColor` undefined; `UNFOCUSED_STYLES` missing `highlight`. + +- [ ] **Step 3: Update `lib/defaults.ts`** + +Replace the `UnfocusedStyle` + `FocusProviderName` + `UNFOCUSED_STYLES` + `CursorConfig` + `DEFAULT_CONFIG` blocks: + +```typescript +export type FocusedStyle = "block" | "bar" | "underline"; +export type UnfocusedStyle = "dim" | "hollow" | "outline" | "underline" | "hide" | "highlight"; +export type FocusProviderName = "auto" | "tmux" | "cmux" | "herdr" | "static"; +export type CursorMode = "fake" | "hardware"; + +export const FOCUSED_STYLES: readonly FocusedStyle[] = ["block", "bar", "underline"]; +export const UNFOCUSED_STYLES: readonly UnfocusedStyle[] = ["dim", "hollow", "outline", "underline", "hide", "highlight"]; +export const BLINK_RATES: readonly number[] = [400, 500, 600, 800, 1000]; +export const FOCUS_PROVIDERS: readonly FocusProviderName[] = ["auto", "tmux", "cmux", "herdr", "static"]; +export const CURSOR_MODES: readonly CursorMode[] = ["fake", "hardware"]; + +export interface CursorConfig { + enabled: boolean; + focusedStyle: FocusedStyle; + unfocusedStyle: UnfocusedStyle; + blink: boolean; + blinkRate: number; + focusProvider: FocusProviderName; + cursorColor: string; // "accent" | "#RRGGBB" + cursorMode: CursorMode; +} + +export const DEFAULT_CONFIG: CursorConfig = { + enabled: true, + focusedStyle: "block", + unfocusedStyle: "hollow", + blink: false, + blinkRate: 600, + focusProvider: "auto", + cursorColor: "accent", + cursorMode: "fake", +}; +``` + +- [ ] **Step 4: Write the failing tests (config normalize/cycle)** + +Append to `tests/config.test.ts`: + +```typescript +test("normalizeConfig accepts cursorColor accent + hex + cursorMode + highlight", () => { + const { config, fixed } = normalizeConfig({ + cursorColor: "#cba6f7", + cursorMode: "hardware", + unfocusedStyle: "highlight", + }); + assert.equal(fixed, false); + assert.equal(config.cursorColor, "#cba6f7"); + assert.equal(config.cursorMode, "hardware"); + assert.equal(config.unfocusedStyle, "highlight"); +}); + +test("normalizeConfig rejects bad hex → falls back to accent (fixed=true)", () => { + const { config, fixed } = normalizeConfig({ cursorColor: "#xyz" }); + assert.equal(fixed, true); + assert.equal(config.cursorColor, "accent"); +}); + +test("normalizeConfig rejects unknown cursorMode → fake (fixed=true)", () => { + const { config, fixed } = normalizeConfig({ cursorMode: "weird" as any }); + assert.equal(fixed, true); + assert.equal(config.cursorMode, "fake"); +}); + +test("cycleValue cycles cursorMode fake → hardware → fake", () => { + assert.equal(cycleValue({ ...DEFAULT_CONFIG }, "cursorMode").cursorMode, "hardware"); + assert.equal(cycleValue({ ...DEFAULT_CONFIG, cursorMode: "hardware" }, "cursorMode").cursorMode, "fake"); +}); +``` + +(Ensure `normalizeConfig` + `cycleValue` are imported in the test file.) + +- [ ] **Step 5: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `normalizeConfig` doesn't read `cursorColor`/`cursorMode`; `cycleValue` has no `cursorMode` branch. + +- [ ] **Step 6: Update `lib/config.ts`** + +Add `isHexColor` + `isCursorMode` validators near the existing `isProvider`, and extend `normalizeConfig` + `cycleValue`: + +```typescript +import { + DEFAULT_CONFIG, + FOCUSED_STYLES, + UNFOCUSED_STYLES, + BLINK_RATES, + FOCUS_PROVIDERS, + CURSOR_MODES, + type CursorConfig, + type FocusedStyle, + type UnfocusedStyle, + type FocusProviderName, + type CursorMode, +} from "./defaults.ts"; + +function isHexColor(v: unknown): v is string { + return typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v); +} +function isCursorMode(v: unknown): v is CursorMode { + return CURSOR_MODES.includes(v as CursorMode); +} +``` + +In `normalizeConfig`, after the existing `isProvider` block, add: + +```typescript + if (r.cursorColor === "accent" || isHexColor(r.cursorColor)) cfg.cursorColor = r.cursorColor; + else fixed = true; + if (isCursorMode(r.cursorMode)) cfg.cursorMode = r.cursorMode; + else fixed = true; +``` + +Extend the `CycleableKey` type and `cycleValue`: + +```typescript +type CycleableKey = "focusedStyle" | "unfocusedStyle" | "blinkRate" | "focusProvider" | "cursorMode"; +export function cycleValue(cfg: CursorConfig, key: CycleableKey): CursorConfig { + if (key === "focusedStyle") { + const i = FOCUSED_STYLES.indexOf(cfg.focusedStyle); + return { ...cfg, focusedStyle: FOCUSED_STYLES[(i + 1) % FOCUSED_STYLES.length]! }; + } + if (key === "unfocusedStyle") { + const i = UNFOCUSED_STYLES.indexOf(cfg.unfocusedStyle); + return { ...cfg, unfocusedStyle: UNFOCUSED_STYLES[(i + 1) % UNFOCUSED_STYLES.length]! }; + } + if (key === "blinkRate") { + const i = BLINK_RATES.indexOf(cfg.blinkRate); + return { ...cfg, blinkRate: BLINK_RATES[(i + 1) % BLINK_RATES.length]! }; + } + if (key === "cursorMode") { + const i = CURSOR_MODES.indexOf(cfg.cursorMode); + return { ...cfg, cursorMode: CURSOR_MODES[(i + 1) % CURSOR_MODES.length]! }; + } + const i = FOCUS_PROVIDERS.indexOf(cfg.focusProvider); + return { ...cfg, focusProvider: FOCUS_PROVIDERS[(i + 1) % FOCUS_PROVIDERS.length]! }; +} +``` + +- [ ] **Step 7: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS — all tests green, typecheck clean. + +- [ ] **Step 8: Commit** + +```bash +git add lib/defaults.ts lib/config.ts tests/defaults.test.ts tests/config.test.ts +git commit -m "feat: add cursorColor, cursorMode, highlight to config schema (v0.2.0 T1)" +``` + +--- + +## Task 2: Render color/escape helpers + +**Files:** +- Modify: `lib/render.ts` (add helpers — keep existing transforms for now) +- Test: `tests/render-helpers.test.ts` (new) + +**Interfaces:** +- Produces (all exported from `render.ts`): + - `hexToAnsi(hex: string): string` — `#RRGGBB` → `\x1b[38;2;R;G;Bm` + - `dimHex(hex: string): string` — 50% dim → `#RRGGBB` + - `parseAnsiFgToHex(prefix: string): string` — `\x1b[38;2;R;G;Bm` → `#RRGGBB`; `\x1b[38;5;Nm` → `""` (inexact) + - `decscusr(shape: "block" | "underline" | "bar", blink: boolean): string` + - `osc12(hex: string): string` — `\x1b]12;#RRGGBB\x07` (or `""` if hex empty) + - `resolveFocusedColor(cfg: CursorConfig, theme: Theme): string` — ANSI prefix for focused color + - `resolveUnfocusedColor(cfg: CursorConfig, theme: Theme): string` — ANSI prefix for unfocused color +- Consumes: `CursorConfig` (Task 1), `Theme` type from `@earendil-works/pi-coding-agent`. + +- [ ] **Step 1: Write the failing tests** + +Create `tests/render-helpers.test.ts`: + +```typescript +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + hexToAnsi, + dimHex, + parseAnsiFgToHex, + decscusr, + osc12, + resolveFocusedColor, + resolveUnfocusedColor, +} from "../lib/render.ts"; +import { DEFAULT_CONFIG } from "../lib/defaults.ts"; + +// Minimal mock Theme matching the real Theme's getFgAnsi/getColorMode surface. +function mockTheme(getFgAnsi: (c: string) => string, mode: "truecolor" | "256color" = "truecolor"): any { + return { getFgAnsi, getColorMode: () => mode }; +} + +test("hexToAnsi emits truecolor fg", () => { + assert.equal(hexToAnsi("#cba6f7"), "\x1b[38;2;203;166;247m"); +}); + +test("dimHex halves each channel", () => { + assert.equal(dimHex("#cba6f7"), "#5d537b"); +}); + +test("parseAnsiFgToHex parses truecolor prefix", () => { + assert.equal(parseAnsiFgToHex("\x1b[38;2;203;166;247m"), "#cba6f7"); +}); + +test("parseAnsiFgToHex returns empty for 256 prefix (inexact)", () => { + assert.equal(parseAnsiFgToHex("\x1b[38;5;7m"), ""); +}); + +test("parseAnsiFgToHex returns empty for non-matching string", () => { + assert.equal(parseAnsiFgToHex("garbage"), ""); +}); + +test("decscusr steady + blink variants", () => { + assert.equal(decscusr("block", false), "\x1b[2 q"); + assert.equal(decscusr("block", true), "\x1b[1 q"); + assert.equal(decscusr("underline", false), "\x1b[4 q"); + assert.equal(decscusr("underline", true), "\x1b[3 q"); + assert.equal(decscusr("bar", false), "\x1b[6 q"); + assert.equal(decscusr("bar", true), "\x1b[5 q"); +}); + +test("osc12 emits cursor-color sequence for hex", () => { + assert.equal(osc12("#cba6f7"), "\x1b]12;#cba6f7\x07"); +}); + +test("osc12 returns empty for empty hex", () => { + assert.equal(osc12(""), ""); +}); + +test("resolveFocusedColor: accent → theme accent ansi", () => { + const theme = mockTheme((c) => (c === "accent" ? "\x1b[38;2;203;166;247m" : "")); + assert.equal(resolveFocusedColor(DEFAULT_CONFIG, theme), "\x1b[38;2;203;166;247m"); +}); + +test("resolveFocusedColor: hex override → hexToAnsi", () => { + const cfg = { ...DEFAULT_CONFIG, cursorColor: "#ff5555" }; + const theme = mockTheme(() => ""); + assert.equal(resolveFocusedColor(cfg, theme), "\x1b[38;2;255;85;85m"); +}); + +test("resolveUnfocusedColor: accent → theme dim ansi", () => { + const theme = mockTheme((c) => (c === "dim" ? "\x1b[38;2;88;91;112m" : "")); + assert.equal(resolveUnfocusedColor(DEFAULT_CONFIG, theme), "\x1b[38;2;88;91;112m"); +}); + +test("resolveUnfocusedColor: hex override → dimmed hex truecolor", () => { + const cfg = { ...DEFAULT_CONFIG, cursorColor: "#cba6f7" }; + const theme = mockTheme(() => ""); + assert.equal(resolveUnfocusedColor(cfg, theme), "\x1b[38;2;93;83;123m"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — none of the helpers are exported from `render.ts` yet. + +- [ ] **Step 3: Add the helpers to `lib/render.ts`** + +At the top of `lib/render.ts`, add the `Theme` import + helpers (keep the existing transforms intact for now — they're refactored in Task 3): + +```typescript +import { CURSOR_MARKER } from "@earendil-works/pi-tui"; +import type { Theme } from "@earendil-works/pi-coding-agent"; +import type { CursorConfig, FocusedStyle, UnfocusedStyle } from "./defaults.ts"; + +// --- Color/escape helpers (v0.2.0) --- + +/** #RRGGBB → \x1b[38;2;R;G;Bm. Returns "" if the hex is malformed. */ +export function hexToAnsi(hex: string): string { + const m = /^#([0-9a-fA-F]{6})$/.exec(hex); + if (!m) return ""; + const n = m[1]!; + const r = parseInt(n.slice(0, 2), 16); + const g = parseInt(n.slice(2, 4), 16); + const b = parseInt(n.slice(4, 6), 16); + return `\x1b[38;2;${r};${g};${b}m`; +} + +/** 50%-dim a #RRGGBB hex toward black. */ +export function dimHex(hex: string): string { + const m = /^#([0-9a-fA-F]{6})$/.exec(hex); + if (!m) return ""; + const n = m[1]!; + const dim = (s: string) => Math.floor(parseInt(s, 16) * 0.5).toString(16).padStart(2, "0"); + return `#${dim(n.slice(0, 2))}${dim(n.slice(2, 4))}${dim(n.slice(4, 6))}`; +} + +/** Parse a truecolor fg ANSI prefix back to #RRGGBB. Returns "" for 256 or non-matching. */ +export function parseAnsiFgToHex(prefix: string): string { + const m = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(prefix); + if (!m) return ""; + const r = Number(m[1]).toString(16).padStart(2, "0"); + const g = Number(m[2]).toString(16).padStart(2, "0"); + const b = Number(m[3]).toString(16).padStart(2, "0"); + return `#${r}${g}${b}`; +} + +/** DECSCUSR cursor-shape escape. Steady: 2/4/6; blinking: 1/3/5. */ +export function decscusr(shape: "block" | "underline" | "bar", blink: boolean): string { + const steady: Record = { block: 2, underline: 4, bar: 6 }; + const n = steady[shape] - (blink ? 1 : 0); + return `\x1b[${n} q`; +} + +/** OSC 12 cursor-color. Empty hex → "" (skip, terminal uses its default). */ +export function osc12(hex: string): string { + return hex ? `\x1b]12;${hex}\x07` : ""; +} + +/** Resolve the focused cursor color to an ANSI fg prefix. */ +export function resolveFocusedColor(cfg: CursorConfig, theme: Theme): string { + if (cfg.cursorColor === "accent") return theme.getFgAnsi("accent"); + return hexToAnsi(cfg.cursorColor); +} + +/** Resolve the unfocused cursor color: accent → theme "dim"; hex → dimmed hex. */ +export function resolveUnfocusedColor(cfg: CursorConfig, theme: Theme): string { + if (cfg.cursorColor === "accent") return theme.getFgAnsi("dim"); + return hexToAnsi(dimHex(cfg.cursorColor)); +} + +/** Resolve the accent color to a hex (for OSC 12). "" in 256-color mode (inexact). */ +export function themeAccentHex(theme: Theme): string { + if (theme.getColorMode() !== "truecolor") return ""; + return parseAnsiFgToHex(theme.getFgAnsi("accent")); +} +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. (If `Theme` import errors, use `type Theme = { getFgAnsi(c: string): string; getColorMode(): "truecolor" | "256color" }` as a local structural type instead of importing — see Task 3 note.) + +- [ ] **Step 5: Commit** + +```bash +git add lib/render.ts tests/render-helpers.test.ts +git commit -m "feat: add cursor color/escape helpers (hexToAnsi, dimHex, decscusr, osc12, resolveFocused/UnfocusedColor) (v0.2.0 T2)" +``` + +--- + +## Task 3: Thread `Theme` into render + truecolor colors (fake mode) + +**Files:** +- Modify: `lib/render.ts` (`transformFocused`/`transformUnfocused` + `focusedCell`/`unfocusedCell`) +- Modify: `lib/editor.ts` (`composeRender` + `CursorEditor` store + pass theme) +- Test: `tests/render.test.ts` (extend) + +**Interfaces:** +- `composeRender(lines, focused, cfg, theme, blinkVisible, cursorMode)` — new signature adds `theme` + `cursorMode`. +- `transformFocused(lines, cfg, theme, blinkVisible, cursorMode)` / `transformUnfocused(lines, cfg, theme)` — new signatures. +- `CursorEditor` stores `theme` (from constructor) + `cursorMode` (from cfg) and passes them to `composeRender`. +- Consumes: `resolveFocusedColor`/`resolveUnfocusedColor` (Task 2). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/render.test.ts` (add imports for `DEFAULT_CONFIG` + the new signature; add a `mockTheme`): + +```typescript +import { transformFocused, transformUnfocused } from "../lib/render.ts"; +import { DEFAULT_CONFIG, type CursorConfig } from "../lib/defaults.ts"; + +function mockTheme(getFgAnsi: (c: string) => string, mode: "truecolor" | "256color" = "truecolor"): any { + return { getFgAnsi, getColorMode: () => mode }; +} +const THEME = mockTheme((c) => (c === "accent" ? "\x1b[38;2;203;166;247m" : c === "dim" ? "\x1b[38;2;88;91;112m" : ""), "truecolor"); +const MARKER = "\x1b_pi:c\x07"; // CURSOR_MARKER — import from pi-tui in real test if available + +test("focused bar uses theme accent truecolor (not 256-color 7)", () => { + const line = `hello${MARKER}\x1b[7m \x1b[0mworld`; + const out = transformFocused([line], { ...DEFAULT_CONFIG, focusedStyle: "bar" }, THEME, true, "fake"); + assert.ok(out[0]!.includes("\x1b[38;2;203;166;247m"), "bar uses accent truecolor"); + assert.ok(!out[0]!.includes("\x1b[38;5;7m"), "no 256-color 7"); +}); + +test("unfocused hollow uses theme dim truecolor (not 256-color 8)", () => { + const line = `hi${MARKER}\x1b[7m \x1b[0m`; + const out = transformUnfocused([line], { ...DEFAULT_CONFIG, unfocusedStyle: "hollow" }, THEME); + assert.ok(out[0]!.includes("\x1b[38;2;88;91;112m"), "hollow uses dim truecolor"); + assert.ok(!out[0]!.includes("\x1b[38;5;8m"), "no 256-color 8"); +}); +``` + +(Use the real `CURSOR_MARKER` import in the test if `@earendil-works/pi-tui` resolves it; otherwise hardcode the literal as shown — the existing `render.test.ts` already imports `CURSOR_MARKER` from pi-tui, so mirror that.) + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `transformFocused`/`transformUnfocused` don't accept `theme`/`cursorMode` yet. + +- [ ] **Step 3: Update `lib/render.ts` signatures + cell functions** + +Replace the existing `focusedCell`/`unfocusedCell` + `transformFocused`/`transformUnfocused` with theme-aware versions. The `CELL_RE` + `rewriteCursorCell` + `transformLine` helpers stay unchanged. Update the glyph constants block + cell functions: + +```typescript +const BAR = "\u258E"; +const HOLLOW = "\u25A1"; +const OUTLINE = "\u25A2"; + +function focusedCell(style: FocusedStyle, ch: string, colorAnsi: string): string { + switch (style) { + case "block": + return `\x1b[7m${ch}\x1b[0m`; // unchanged from pi + case "underline": + return `\x1b[4m${ch}\x1b[0m`; + case "bar": + return `${colorAnsi}${BAR}\x1b[39m`; // char hidden, accent-colored + } +} + +function unfocusedCell(style: UnfocusedStyle, ch: string, colorAnsi: string, truecolor: boolean): string { + switch (style) { + case "dim": + return `\x1b[2;7m${ch}\x1b[0m`; + case "hollow": + return `${colorAnsi}${HOLLOW}\x1b[39m`; // char hidden, dim-colored + case "outline": + return `${colorAnsi}${OUTLINE}\x1b[39m`; // char hidden, dim-colored + case "underline": + return `\x1b[4;2m${ch}\x1b[0m`; + case "hide": + return ch; + case "highlight": + // char-preserving colored styled underline (Task 5 fills the full impl; + // here it's a plain colored underline placeholder that Task 5 upgrades). + return `${colorAnsi}\x1b[4m${ch}\x1b[0m`; + } +} + +export function transformFocused( + lines: string[], + cfg: CursorConfig, + theme: Theme, + blinkVisible: boolean, + cursorMode: "fake" | "hardware" = "fake", +): string[] { + if (!cfg.enabled) return lines; + // Hardware focused: keep marker (so pi positions the real cursor), drop the + // reverse-video cell → bare char. Only the native hardware cursor shows. + if (cursorMode === "hardware") { + return lines.map((l) => (l.includes(CURSOR_MARKER) ? rewriteCursorCell(l, (ch) => ch) : l)); + } + // block + visible = pi-native passthrough (keep CURSOR_MARKER + reverse-video cell unchanged) + if (cfg.focusedStyle === "block" && blinkVisible) return lines; + const colorAnsi = resolveFocusedColor(cfg, theme); + const fn = blinkVisible + ? (ch: string) => focusedCell(cfg.focusedStyle, ch, colorAnsi) + : (ch: string) => ch; + return lines.map((l) => transformLine(l, fn)); +} + +export function transformUnfocused(lines: string[], cfg: CursorConfig, theme: Theme): string[] { + if (!cfg.enabled) return lines; + const colorAnsi = resolveUnfocusedColor(cfg, theme); + const truecolor = theme.getColorMode() === "truecolor"; + const fn = (ch: string) => unfocusedCell(cfg.unfocusedStyle, ch, colorAnsi, truecolor); + return lines.map((l) => transformLine(l, fn)); +} +``` + +Note: if `import type { Theme }` from pi-coding-agent fails to resolve under `noUncheckedIndexedAccess`/strict, define a local structural type in `render.ts` instead: +```typescript +type Theme = { getFgAnsi(color: string): string; getColorMode(): "truecolor" | "256color" }; +``` +Use the same local type in `editor.ts`. Prefer the real import if it resolves; the local type is the fallback. + +- [ ] **Step 4: Update `lib/editor.ts` to pass theme + cursorMode** + +Replace `composeRender` + the `CursorEditor` constructor + `render`: + +```typescript +import { CustomEditor } from "@earendil-works/pi-coding-agent"; +import { transformFocused, transformUnfocused } from "./render.ts"; +import type { CursorConfig, CursorMode } from "./defaults.ts"; +import { BlinkController } from "./state.ts"; + +type Theme = { getFgAnsi(color: string): string; getColorMode(): "truecolor" | "256color" }; + +export interface CursorEditorDeps { + wrapped: { render(width: number): string[]; handleInput(data: string): void } | null; + blink: BlinkController; +} + +export function composeRender( + lines: string[], + focused: boolean, + cfg: CursorConfig, + theme: Theme, + blinkVisible: boolean, + cursorMode: CursorMode, +): string[] { + if (!cfg.enabled) return lines; + return focused + ? transformFocused(lines, cfg, theme, blinkVisible, cursorMode) + : transformUnfocused(lines, cfg, theme); +} + +export class CursorEditor extends CustomEditor { + private cfg: CursorConfig; + private paneFocused = true; + private deps: CursorEditorDeps; + private theme: Theme; + + constructor(tui: any, theme: Theme, keybindings: any, deps: CursorEditorDeps) { + super(tui, theme, keybindings, {}); + this.deps = deps; + this.theme = theme; + this.cfg = { + enabled: true, + focusedStyle: "block", + unfocusedStyle: "dim", + blink: false, + blinkRate: 600, + focusProvider: "auto", + cursorColor: "accent", + cursorMode: "fake", + }; + } + + updateConfig(cfg: CursorConfig): void { + this.cfg = cfg; + this.invalidate?.(); + } + + setFocus(focused: boolean): void { + if (this.paneFocused === focused) return; + this.paneFocused = focused; + this.deps.blink.setActive(focused); + this.invalidate?.(); + this.tui?.requestRender?.(); + } + + onBlinkToggle(): void { + 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.theme, this.deps.blink.visible, this.cfg.cursorMode); + } +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. Existing render tests that call `transformFocused(lines, cfg, blinkVisible)` will FAIL (signature changed) — update them to pass `theme` + `cursorMode`. Fix any existing `tests/render.test.ts` calls + `tests/editor.test.ts` `composeRender` calls to the new signature (add a `mockTheme` + `"fake"`). + +- [ ] **Step 6: Commit** + +```bash +git add lib/render.ts lib/editor.ts tests/render.test.ts tests/editor.test.ts +git commit -m "feat: thread Theme into render for truecolor cursor colors (v0.2.0 T3)" +``` + +--- + +## Task 4: `cursorColor` hex override wiring + +**Files:** +- Modify: `lib/render.ts` (already wired via `resolveFocusedColor`/`resolveUnfocusedColor` — verify) +- Test: `tests/render.test.ts` (extend) + +**Interfaces:** No new exports. Confirms `cursorColor: "#RRGGBB"` flows through Tasks 2+3 to the emitted escapes. + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/render.test.ts`: + +```typescript +test("cursorColor hex override flows to focused bar", () => { + const theme = mockTheme(() => "\x1b[38;2;0;0;0m"); // theme should be ignored for hex + const cfg = { ...DEFAULT_CONFIG, focusedStyle: "bar", cursorColor: "#ff5555" }; + const line = `x${MARKER}\x1b[7m \x1b[0m`; + const out = transformFocused([line], cfg, theme, true, "fake"); + assert.ok(out[0]!.includes("\x1b[38;2;255;85;85m"), "hex override used"); +}); + +test("cursorColor hex override flows to unfocused hollow (dimmed)", () => { + const theme = mockTheme(() => "\x1b[38;2;0;0;0m"); + const cfg = { ...DEFAULT_CONFIG, unfocusedStyle: "hollow", cursorColor: "#cba6f7" }; + const line = `x${MARKER}\x1b[7m \x1b[0m`; + const out = transformUnfocused([line], cfg, theme); + assert.ok(out[0]!.includes("\x1b[38;2;93;83;123m"), "dimmed hex used (#cba6f7 → #5d537b → 93;83;123)"); +}); +``` + +- [ ] **Step 2: Run tests to verify they pass** + +Run: `pnpm test:run` +Expected: PASS — Tasks 2+3 already wired the override. If FAIL, the wiring is broken; fix `resolveFocusedColor`/`resolveUnfocusedColor` (Task 2). + +- [ ] **Step 3: Commit** + +```bash +git add tests/render.test.ts +git commit -m "test: cursorColor hex override flows to cursor cells (v0.2.0 T4)" +``` + +--- + +## Task 5: `highlight` unfocused style (char-preserving undercurl) + +**Files:** +- Modify: `lib/render.ts` (`unfocusedCell` `highlight` case) +- Test: `tests/render.test.ts` (extend) + +**Interfaces:** No new exports. `unfocusedCell` `highlight` uses styled underline (`\x1b[4:3m` + `\x1b[58:2::R:G:Bm`) in truecolor; plain colored underline (`\x1b[4m` + fg color) in 256 mode. Char preserved (no glyph substitution). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/render.test.ts`: + +```typescript +test("unfocused highlight: truecolor undercurl + colored underline, char preserved", () => { + const theme = mockTheme((c) => (c === "dim" ? "\x1b[38;2;88;91;112m" : ""), "truecolor"); + const cfg = { ...DEFAULT_CONFIG, unfocusedStyle: "highlight" }; + const line = `hi${MARKER}\x1b[7mX\x1b[0m`; + const out = transformUnfocused([line], cfg, theme); + // char X preserved (not replaced by a glyph) + assert.ok(out[0]!.includes("X"), "char preserved"); + // undercurl + colored underline (parse R;G;B from the dim ansi) + assert.ok(out[0]!.includes("\x1b[4:3m"), "undercurl"); + assert.ok(out[0]!.includes("\x1b[58:2::88:91:112m"), "colored underline (RGB from dim)"); +}); + +test("unfocused highlight: 256-color fallback = plain colored underline, char preserved", () => { + const theme = mockTheme((c) => (c === "dim" ? "\x1b[38;5;8m" : ""), "256color"); + const cfg = { ...DEFAULT_CONFIG, unfocusedStyle: "highlight" }; + const line = `hi${MARKER}\x1b[7mX\x1b[0m`; + const out = transformUnfocused([line], cfg, theme); + assert.ok(out[0]!.includes("X"), "char preserved"); + assert.ok(out[0]!.includes("\x1b[4m"), "plain underline"); + assert.ok(!out[0]!.includes("\x1b[4:3m"), "no undercurl in 256 mode"); + assert.ok(out[0]!.includes("\x1b[38;5;8m"), "256 fg color"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `highlight` currently emits plain colored underline (Task 3 placeholder), not undercurl/colored-underline. + +- [ ] **Step 3: Update `unfocusedCell` `highlight` case in `lib/render.ts`** + +Replace the `highlight` case: + +```typescript + case "highlight": { + // Char-preserving colored styled underline. Truecolor: undercurl + colored + // underline (SGR 4:3 + 58:2::R:G:B). 256-color: plain underline + 256 fg. + if (!truecolor) return `${colorAnsi}\x1b[4m${ch}\x1b[0m`; + const m = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(colorAnsi); + if (!m) return `${colorAnsi}\x1b[4m${ch}\x1b[0m`; + const rgb = `${m[1]}:${m[2]}:${m[3]}`; + return `\x1b[4:3m\x1b[58:2::${rgb}m${ch}\x1b[0m`; + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add lib/render.ts tests/render.test.ts +git commit -m "feat: char-preserving 'highlight' unfocused style (undercurl + colored underline) (v0.2.0 T5)" +``` + +--- + +## Task 6: Hardware-cursor mode — focused state (DECSCUSR + OSC 12) + +**Files:** +- Modify: `lib/editor.ts` (hardware-mode side effects on focus/config) +- Test: `tests/editor.test.ts` (extend) + +**Interfaces:** +- `CursorEditor` gains: `applyCursorMode()` — emits DECSCUSR + OSC 12 + toggles `tui.setShowHardwareCursor` for the focused state when `cursorMode: "hardware"`; disables the `BlinkController` in hardware-focused (native blink). +- Consumes: `decscusr`, `osc12`, `themeAccentHex` (Task 2), `resolveFocusedColor` (for the OSC 12 hex). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/editor.test.ts`. Use a mock `tui` capturing `setShowHardwareCursor` calls + a `write` spy capturing emitted escapes. (The existing `editor.test.ts` mocks `tui` via the constructor — mirror it; if absent, construct `CursorEditor` with a `tui` mock that records calls.) + +```typescript +import { CursorEditor } from "../lib/editor.ts"; +import { DEFAULT_CONFIG } from "../lib/defaults.ts"; + +function mockTui() { + const writes: string[] = []; + const hw: boolean[] = []; + return { + writes, + hw, + tui: { + setShowHardwareCursor(v: boolean) { hw.push(v); }, + requestRender() {}, + write(s: string) { writes.push(s); }, + }, + }; +} +function mockTheme() { + return { getFgAnsi: (c: string) => c === "accent" ? "\x1b[38;2;203;166;247m" : "", getColorMode: () => "truecolor" as const }; +} + +test("hardware mode: focused emits DECSCUSR steady block + OSC 12 accent + shows hw cursor", () => { + const m = mockTui(); + const blink = new BlinkController(); + const ed = new CursorEditor(m.tui, mockTheme(), {}, { wrapped: null, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware", focusedStyle: "block" }); + // editor is focused by default → applyCursorMode on updateConfig + assert.ok(m.hw.includes(true), "hardware cursor shown"); + assert.ok(m.writes.some((w) => w.includes("\x1b[2 q")), "DECSCUSR steady block"); + assert.ok(m.writes.some((w) => w.includes("\x1b]12;#cba6f7\x07")), "OSC 12 accent hex"); +}); + +test("hardware mode: blink on → blinking DECSCUSR (1) and BlinkController NOT started", () => { + const m = mockTui(); + const blink = new BlinkController(); + const startSpy = blink.start; + let started = 0; + blink.start = () => { started++; }; + const ed = new CursorEditor(m.tui, mockTheme(), {}, { wrapped: null, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware", blink: true, focusedStyle: "underline" }); + assert.ok(m.writes.some((w) => w.includes("\x1b[3 q")), "blinking underline DECSCUSR"); + assert.equal(started, 0, "BlinkController not started (native blink)"); +}); + +test("hardware mode: hex cursorColor → OSC 12 with that hex", () => { + const m = mockTui(); + const blink = new BlinkController(); + const ed = new CursorEditor(m.tui, mockTheme(), {}, { wrapped: null, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware", cursorColor: "#ff5555" }); + assert.ok(m.writes.some((w) => w.includes("\x1b]12;#ff5555\x07")), "OSC 12 override hex"); +}); + +test("hardware mode: 256-color theme → OSC 12 skipped (no exact hex)", () => { + const m = mockTui(); + const blink = new BlinkController(); + const theme256 = { getFgAnsi: () => "\x1b[38;5;7m", getColorMode: () => "256color" as const }; + const ed = new CursorEditor(m.tui, theme256, {}, { wrapped: null, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware" }); + assert.ok(!m.writes.some((w) => w.includes("\x1b]12;")), "OSC 12 skipped in 256 mode"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `CursorEditor` doesn't emit DECSCUSR/OSC12 or call `setShowHardwareCursor`. + +- [ ] **Step 3: Implement `applyCursorMode` in `lib/editor.ts`** + +Add imports + the method. Call `applyCursorMode()` from `updateConfig` and `setFocus`. In hardware focused: emit DECSCUSR (shape from `focusedStyle`, blink from `cfg.blink`), emit OSC 12 (accent→`themeAccentHex`, hex→as-is), `tui.setShowHardwareCursor(true)`, stop the blink controller (native blink). In fake mode OR unfocused: `tui.setShowHardwareCursor(false)` (the unfocused hardware path is finalized in Task 7). + +```typescript +import { decscusr, osc12, themeAccentHex } from "./render.ts"; + +// inside CursorEditor: +private applyCursorMode(): void { + const focused = this.paneFocused; + const hw = this.cfg.cursorMode === "hardware" && focused; + this.tui?.setShowHardwareCursor?.(hw); + if (hw) { + // native blink replaces the fake-cursor blink controller + this.deps.blink.stop(); + const shape = this.cfg.focusedStyle === "block" ? "block" + : this.cfg.focusedStyle === "underline" ? "underline" : "bar"; + this.tui?.write?.(decscusr(shape, this.cfg.blink)); + const hex = this.cfg.cursorColor === "accent" ? themeAccentHex(this.theme) : this.cfg.cursorColor; + const osc = osc12(hex); + if (osc) this.tui?.write?.(osc); + } else { + // fake mode (or hardware-unfocused, finalized in T7): reset to default shape + this.tui?.write?.("\x1b[0 q"); + } +} +``` + +Update `updateConfig` + `setFocus` to call it: + +```typescript + updateConfig(cfg: CursorConfig): void { + this.cfg = cfg; + this.applyCursorMode(); + this.invalidate?.(); + this.tui?.requestRender?.(); + } + + setFocus(focused: boolean): void { + if (this.paneFocused === focused) return; + this.paneFocused = focused; + this.deps.blink.setActive(focused); + this.applyCursorMode(); + this.invalidate?.(); + this.tui?.requestRender?.(); + } +``` + +- [ ] **Step 4: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. (The `tui` mock in the test provides `write`; the real `tui` has it via pi-tui's `Terminal`. If `this.tui?.write` is undefined on the real `CustomEditor.tui`, emit via `process.stdout.write` as a fallback — verify against the real `tui` shape; the existing code uses `this.tui?.requestRender`, so `tui` is the TUI instance which has `terminal.write`, but `write` may be on `tui.terminal`. Adjust to `this.tui?.terminal?.write ?? this.tui?.write` if needed and fix the test mock to match.) + +- [ ] **Step 5: Commit** + +```bash +git add lib/editor.ts tests/editor.test.ts +git commit -m "feat: hardware-cursor focused state (DECSCUSR + OSC 12 + setShowHardwareCursor) (v0.2.0 T6)" +``` + +--- + +## Task 7: Hardware-cursor mode — unfocused state + lifecycle restore + +**Files:** +- Modify: `lib/editor.ts` (`applyCursorMode` unfocused branch + `session_shutdown`/destroy restore) +- Modify: `extensions/cursor.ts` (call editor restore on `session_shutdown`) +- Test: `tests/editor.test.ts` (extend) + +**Interfaces:** +- `CursorEditor.restoreCursor()` — public; emits DECSCUSR `\x1b[0 q` + OSC 12 reset `\x1b]12;\x07` + `tui.setShowHardwareCursor(false)`. Called on `session_shutdown`. +- Unfocused hardware: `applyCursorMode` already sets `setShowHardwareCursor(false)`; render uses `transformUnfocused` (fake unfocused cell with A+B colors/styles) — no change to render needed (Task 3 already routes unfocused → `transformUnfocused`). + +- [ ] **Step 1: Write the failing tests** + +Append to `tests/editor.test.ts`: + +```typescript +test("hardware mode unfocused: setShowHardwareCursor(false) + render uses unfocused transform", () => { + const m = mockTui(); + const blink = new BlinkController(); + const ed = new CursorEditor(m.tui, mockTheme(), {}, { wrapped: null, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware", unfocusedStyle: "hollow" }); + // initial focused → hw on; now lose focus + m.hw.length = 0; + ed.setFocus(false); + assert.ok(m.hw.includes(false), "hardware cursor hidden when unfocused"); + // render still produces the unfocused fake cell (hollow glyph) + const lines = ed.render(40); + // wrapped is null → super.render returns []; composeRender returns [] for empty. Use a wrapped mock instead: +}); + +test("restoreCursor resets DECSCUSR + OSC 12 + hides hardware cursor", () => { + const m = mockTui(); + const blink = new BlinkController(); + const ed = new CursorEditor(m.tui, mockTheme(), {}, { wrapped: null, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware" }); + m.writes.length = 0; + m.hw.length = 0; + ed.restoreCursor(); + assert.ok(m.hw.includes(false), "hardware cursor hidden on restore"); + assert.ok(m.writes.some((w) => w.includes("\x1b[0 q")), "DECSCUSR reset to default"); + assert.ok(m.writes.some((w) => w.includes("\x1b]12;\x07")), "OSC 12 reset"); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `restoreCursor` doesn't exist; unfocused `setShowHardwareCursor(false)` not asserted. + +- [ ] **Step 3: Add `restoreCursor` + finalize the unfocused branch in `lib/editor.ts`** + +```typescript + /** Restore the terminal's default cursor (call on session_shutdown). */ + restoreCursor(): void { + this.tui?.setShowHardwareCursor?.(false); + this.tui?.write?.("\x1b[0 q"); // default cursor shape + this.tui?.write?.("\x1b]12;\x07"); // reset cursor color + } +``` + +(The unfocused `applyCursorMode` branch already does `setShowHardwareCursor(false)` from Task 6; render already routes unfocused → `transformUnfocused`. So this task only adds `restoreCursor` + the shutdown hook.) + +- [ ] **Step 4: Wire `restoreCursor` into `session_shutdown` in `extensions/cursor.ts`** + +In the `pi.on("session_shutdown", …)` handler, before `blink?.stop()`, add: + +```typescript + editor?.restoreCursor?.(); +``` + +- [ ] **Step 5: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. + +- [ ] **Step 6: Commit** + +```bash +git add lib/editor.ts extensions/cursor.ts tests/editor.test.ts +git commit -m "feat: hardware-cursor unfocused + shutdown restoreCursor (v0.2.0 T7)" +``` + +--- + +## Task 8: Panel rows + `/cursor` subcommands (`color`, `mode`) + +**Files:** +- Modify: `lib/panel.ts` (`panelRows` + `applyRowChange` + `rowDisplayValue` + `previewLine` theme) +- Modify: `extensions/cursor.ts` (`parseCursorArgs` `color`/`mode` cases + status + panel wiring) +- Test: `tests/entry.test.ts` (extend) +- Test: `tests/panel.test.ts` (extend) + +**Interfaces:** +- New panel rows: `cursorColor` (cycles `accent` ↔ current hex display), `cursorMode` (fake ↔ hardware). +- New subcommands: `/cursor color accent|#RRGGBB`, `/cursor mode fake|hardware`. +- `parseCursorArgs` returns `{ action: "set", patch: { cursorColor } | { cursorMode } }`. +- `previewLine` needs `theme` to render truecolor previews — thread it. + +- [ ] **Step 1: Write the failing tests (entry parsing)** + +Append to `tests/entry.test.ts`: + +```typescript +test("parseCursorArgs color accent", () => { + assert.deepEqual(parseCursorArgs(["color", "accent"], DEFAULT_CONFIG), { action: "set", patch: { cursorColor: "accent" } }); +}); +test("parseCursorArgs color hex", () => { + assert.deepEqual(parseCursorArgs(["color", "#cba6f7"], DEFAULT_CONFIG), { action: "set", patch: { cursorColor: "#cba6f7" } }); +}); +test("parseCursorArgs color rejects bad hex → throws", () => { + assert.throws(() => parseCursorArgs(["color", "#xyz"], DEFAULT_CONFIG), /Usage/); +}); +test("parseCursorArgs mode hardware", () => { + assert.deepEqual(parseCursorArgs(["mode", "hardware"], DEFAULT_CONFIG), { action: "set", patch: { cursorMode: "hardware" } }); +}); +test("parseCursorArgs mode rejects unknown", () => { + assert.throws(() => parseCursorArgs(["mode", "weird"], DEFAULT_CONFIG), /Usage/); +}); +``` + +- [ ] **Step 2: Run tests to verify they fail** + +Run: `pnpm test:run` +Expected: FAIL — `parseCursorArgs` has no `color`/`mode` cases. + +- [ ] **Step 3: Add `color` + `mode` cases to `parseCursorArgs` in `extensions/cursor.ts`** + +Add imports (`CURSOR_MODES` from defaults) + the cases in the `switch (a)`: + +```typescript + case "color": { + if (b === "accent") return { action: "set", patch: { cursorColor: "accent" } }; + if (/^#[0-9a-fA-F]{6}$/.test(b ?? "")) return { action: "set", patch: { cursorColor: b! } }; + throw new Error(`Usage: /cursor color accent|#RRGGBB`); + } + case "mode": + if (!CURSOR_MODES.includes(b as CursorMode)) throw new Error(`Usage: /cursor mode ${CURSOR_MODES.join("|")}`); + return { action: "set", patch: { cursorMode: b as CursorMode } }; +``` + +(Add `CURSOR_MODES`, `type CursorMode` to the defaults import in `extensions/cursor.ts`.) + +- [ ] **Step 4: Add panel rows in `lib/panel.ts`** + +Add `CURSOR_MODES` + `CursorMode` to the defaults import. Add two rows to `panelRows` (before `activeProvider`): + +```typescript + { + id: "cursorColor", + label: "Cursor color", + currentValue: cfg.cursorColor, + values: ["accent", cfg.cursorColor === "accent" ? "accent" : cfg.cursorColor], + description: "accent follows the pi theme; or set #RRGGBB via /cursor color.", + }, + { + id: "cursorMode", + label: "Cursor mode", + currentValue: cfg.cursorMode, + values: [...CURSOR_MODES], + description: "fake = pi's fake cursor (default). hardware = native terminal cursor (Ghostty).", + }, +``` + +Add `applyRowChange` cases: + +```typescript + case "cursorColor": + return { ...cfg, cursorColor: newValue === "accent" || /^#[0-9a-fA-F]{6}$/.test(newValue) ? newValue : cfg.cursorColor }; + case "cursorMode": + return { ...cfg, cursorMode: newValue as CursorMode }; +``` + +Add `rowDisplayValue` cases: + +```typescript + if (id === "cursorColor") return cfg.cursorColor; + if (id === "cursorMode") return cfg.cursorMode; +``` + +- [ ] **Step 5: Write the failing tests (panel)** + +Append to `tests/panel.test.ts`: + +```typescript +test("panelRows includes cursorColor + cursorMode", () => { + const rows = panelRows(DEFAULT_CONFIG, "static"); + const ids = rows.map((r) => r.id); + assert.ok(ids.includes("cursorColor")); + assert.ok(ids.includes("cursorMode")); +}); +test("applyRowChange cursorMode hardware", () => { + assert.equal(applyRowChange(DEFAULT_CONFIG, "cursorMode", "hardware").cursorMode, "hardware"); +}); +``` + +- [ ] **Step 6: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. (Update the existing `tests/panel.test.ts` row-id list assertion — it enumerates expected ids — to include `cursorColor` + `cursorMode`.) + +- [ ] **Step 7: Thread theme into `previewLine` (so the panel preview shows truecolor)** + +`previewLine` in `lib/panel.ts` calls `transformFocused`/`transformUnfocused` which now need `theme`. The panel handler in `extensions/cursor.ts` has `theme` (from `ctx.ui.custom((tui, theme, …) => …)`). Pass `theme` + `cursorMode` into `previewLine` via getters: + +Update `previewLine` signature to `previewLine(getCfg, focused, getBlinkVisible?, getTheme?, getCursorMode?)` and call `transformFocused([sample], cfg, getTheme?.() ?? mockNoColorTheme, blinkVisible, getCursorMode?.() ?? "fake")` / `transformUnfocused([sample], cfg, getTheme?.() ?? mockNoColorTheme)`. (Use a no-color fallback theme `{ getFgAnsi: () => "", getColorMode: () => "256color" }` when theme isn't supplied, so existing tests that don't pass a theme keep working.) Update the panel handler call sites in `extensions/cursor.ts` to pass `() => theme` + `() => cfg.cursorMode`. + +- [ ] **Step 8: Run tests to verify they pass** + +Run: `pnpm typecheck && pnpm test:run` +Expected: PASS. + +- [ ] **Step 9: Commit** + +```bash +git add lib/panel.ts extensions/cursor.ts tests/entry.test.ts tests/panel.test.ts +git commit -m "feat: /cursor color + mode subcommands + panel rows (v0.2.0 T8)" +``` + +--- + +## Task 9: Docs, version bump, tag v0.2.0 + +**Files:** +- Modify: `README.md` (features, focus-providers unchanged, new color/mode/highlight, hardware-mode caveat) +- Modify: `AGENTS.md` (constraints + module map) +- Modify: `package.json` (`0.1.1` → `0.2.0`, description + keywords) +- Test: full suite + `pnpm pack --dry-run` + +- [ ] **Step 1: Update `README.md`** + +- Features list: add "Truecolor cursor colors (theme accent or `#RRGGBB`)", "Char-preserving `highlight` unfocused style (styled underline, char visible)", "Opt-in `hardware` cursor mode (native Ghostty cursor via DECSCUSR + OSC 12)". +- `/cursor` command block: add `/cursor color accent|#RRGGBB`, `/cursor mode fake|hardware`, add `highlight` to `/cursor unfocused`. +- New "## Cursor color" section: accent follows pi theme (truecolor through tmux); hex override; ⚠️ live-verify deferred. +- New "## Cursor modes" subsection under Focus providers: `fake` (default) vs `hardware` (native cursor; DECSCUSR/OSC12; ⚠️ unverified-live, Ghostty-targeted; shutdown restores default). Note hardware focused shapes limited to block/underline/bar; unfocused still uses the fake-cursor transform. + +- [ ] **Step 2: Update `AGENTS.md`** + +- Constraints: add truecolor-through-tmux note; `highlight` undercurl; hardware-mode DECSCUSR/OSC12 + `setShowHardwareCursor`; shutdown `restoreCursor`. ⚠️ live-verify deferred. +- Module map: `lib/render.ts` → "ANSI transforms + truecolor colors + highlight + hardware bare-char"; `lib/editor.ts` → "+ hardware-mode side effects (DECSCUSR/OSC12) + restoreCursor". +- Add v0.2.0 to the top description. + +- [ ] **Step 3: Bump `package.json`** + +```json + "version": "0.2.0", + "description": "Focus-aware, customizable editor cursor for the pi coding agent (truecolor + Ghostty/tmux deep; tmux + cmux + herdr; static fallback).", +``` +Add `"truecolor"`, `"ghostty"` to keywords. + +- [ ] **Step 4: Verify** + +Run: `pnpm typecheck && pnpm test:run && pnpm pack --dry-run` +Expected: typecheck clean, all tests green, `lib/render.ts`/`lib/editor.ts` etc. in the tarball. + +- [ ] **Step 5: Commit + merge to main + tag** + +```bash +git add README.md AGENTS.md package.json +git commit -m "docs+release: v0.2.0 — truecolor + highlight + hardware-cursor mode" +git checkout main && git merge --ff-only feat/ghostty-tmux-deep && git push origin main +git push origin --delete feat/ghostty-tmux-deep +git tag -s v0.2.0 -m "release: v0.2.0 — Ghostty + tmux deep integration" +git push origin v0.2.0 +``` + +- [ ] **Step 6: Verify the publish** + +Wait for the Release CI run to complete, then: +```bash +gh run list -L 3 +curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/@getpipher/cursor/0.2.0 +curl -s -o /dev/null -w "%{http_code}\n" https://registry.npmjs.org/@getpipher/cursor/-/cursor-0.2.0.tgz +``` +Expected: both `200`. (Don't trust `npm view` alone — registry packument propagation lag.) + +--- + +## Self-Review (run after writing; fix inline) + +**1. Spec coverage:** A (§4) → Tasks 2,3,4. B (§5) → Task 5. C (§6) → Tasks 6,7. Config (§7) → Task 1. Command UX (§8) → Task 8. Docs/release (§14) → Task 9. Testing (§10) → each task. Risks/verify (§12) → Task 9 live-verify checklist. ✅ All spec sections covered. + +**2. Placeholder scan:** No "TBD"/"TODO"/"implement later". The Task 3 `highlight` placeholder is explicitly upgraded in Task 5 (not a hidden placeholder — it's a staged implementation). The `tui.write` shape uncertainty in Task 6 Step 4 is flagged with a concrete fallback (`tui.terminal.write`), not left vague. + +**3. Type consistency:** `CursorMode` defined in Task 1, used in Tasks 3/6/7/8. `composeRender` signature (Task 3) used in `editor.render` (Task 3 Step 4). `applyCursorMode`/`restoreCursor` (Tasks 6/7) — names match. `resolveFocusedColor`/`resolveUnfocusedColor`/`decscusr`/`osc12`/`themeAccentHex` (Task 2) used in Tasks 3/4/5/6 — names match. `CURSOR_MODES` (Task 1) used in Tasks 1/8. ✅ + +**4. Known follow-up (not plan failures):** Tasks 3/6 flag a `tui.write` shape check — verify against the real `CustomEditor.tui` during implementation (the existing code only uses `tui.requestRender`, so `write` access is new; the plan provides the `tui.terminal.write` fallback). Live verification of all v0.2.0 features is deferred to a real Ghostty+tmux pane (⚠️, documented in README/AGENTS). \ No newline at end of file diff --git a/docs/superpowers/specs/2026-07-19-ghostty-tmux-deep-design.md b/docs/superpowers/specs/2026-07-19-ghostty-tmux-deep-design.md new file mode 100644 index 0000000..f4110f5 --- /dev/null +++ b/docs/superpowers/specs/2026-07-19-ghostty-tmux-deep-design.md @@ -0,0 +1,342 @@ +# @getpipher/cursor — SPEC-2 (v0.2.0): Ghostty + tmux Deep Integration + +**Status:** DRAFT → self-reviewed ✅ → pending RECTOR review +**Date:** 2026-07-19 +**Supersedes/adds:** Builds on SPEC.md (v0.1) + the v0.1.1 cmux adapter. v0.2.0 is a +minor feature release (new config fields + new styles + new cursor mode). +**Stack target:** Ghostty terminal + tmux multiplexer (RECTOR's daily stack). +Verified-capable: Ghostty truecolor + styled underlines; tmux `terminal-overrides +",*256col*:Tc"` + `",xterm-ghostty:Tc"` (truecolor passthrough) + `allow-passthrough +on` (escape passthrough) + `focus-events on` (via tmux-sensible). + +--- + +## 1. Overview + +v0.2.0 deepens `@getpipher/cursor` for the Ghostty + tmux stack across three +composing axes: + +- **(A) Truecolor + theme-aware cursor colors** — replace the v0.1 256-color + approximation (`\x1b[38;5;7m`/`38;5;8m`) with exact colors sourced from pi's + loaded `Theme` (`theme.getFgAnsi("accent")`), resolved to the theme's color + mode (`truecolor` on RECTOR's stack). Plus an optional explicit hex override. + The cursor color *follows the app theme* automatically — no Ghostty-config + parsing required. +- **(B) Char-preserving `highlight` unfocused style** — a new unfocused style + that draws a colored styled underline (undercurl / colored underline) on the + cursor cell, preserving the character under it. Leverages Ghostty styled + underlines (`\x1b[4:3m`, `\x1b[58:2::R:G:Bm`) passed through tmux. Solves the + "char-hidden" limitation documented in v0.1 for an *unfocused* state. +- **(C) Hardware-cursor mode** — an opt-in `cursorMode: "hardware"` that drives + Ghostty's *native* cursor for the focused state (DECSCUSR shape + OSC 12 + color + pi's hardware-cursor positioning), falling back to the fake-cursor + transform for the unfocused state. Zero char-hidden limitation for the + focused state; native color + native blink. + +A and B apply to **both** cursor modes (fake + hardware); C selects *who draws +the focused cursor*. The v0.1 proven fake-cursor path remains the default +(`cursorMode: "fake"`). + +## 2. Goals + +1. Cursor colors match the active pi theme exactly (truecolor end-to-end through + tmux) instead of 256-color approximation. +2. A char-preserving unfocused style exists (`highlight`) for users who don't + want the char hidden. +3. A native-hardware-cursor mode exists for Ghostty users who want the real + terminal cursor (native shape/color/blink) in the focused state. +4. All three compose; none regresses the v0.1/v0.1.1 behavior when left at + defaults. +5. 80%+ coverage on new code; `pnpm typecheck` + `pnpm test:run` green; live + verification deferred + flagged ⚠️ (mirrors v0.1 herdr / v0.1.1 cmux caveat). + +## 3. Non-goals (v0.2.0) + +- Bare-terminal (no-mux) hardware-cursor mode — still gated on pi-tui DEC 1004 + upstream support (unchanged from v0.1 §12). +- `cursorMode: "auto"` detection — future; v0.2.0 ships `"fake" | "hardware"` + with explicit opt-in. Auto-detect (pick `hardware` on Ghostty+tmux-passthrough) + is a v0.2.1+ item. +- Separate `unfocusedColor` config field — v0.2.0 derives the unfocused color + from the focused color (auto-dimmed). A separate field is a v0.2.1+ escape + hatch if the auto-dim is insufficient. +- Non-Ghostty hardware-cursor verification — DECSCUSR/OSC12 are standard but + only verified-against Ghostty in this release; other terminals get the fake + default. +- cmux live verification — unaffected by this epic; remains ⚠️ from v0.1.1. + +## 4. Cursor colors (A) + +### 4.1 Theme as the color source + +pi's `Theme` class (`@earendil-works/pi-coding-agent`, re-exported) exposes: + +- `theme.getFgAnsi(color: ThemeColor): string` — returns the ANSI color prefix + resolved to the theme's color mode (`\x1b[38;2;R;G;Bm` for truecolor, + `\x1b[38;5;Nm` for 256). +- `theme.getColorMode(): "truecolor" | "256color"`. +- `ThemeColor` includes `"accent"`, `"muted"`, `"dim"`, `"border"`, … + +The extension already uses `theme.fg("accent", …)` for panel borders. v0.2.0 +threads the `Theme` object into `render.ts` (currently `render.ts` takes only +`CursorConfig`) so the cursor cell colors resolve through `getFgAnsi`. + +**No Ghostty-config parsing.** The cursor color follows the app theme; if pi's +theme matches the terminal (RECTOR's Mocha setup), the cursor matches the +terminal. If they differ, the cursor matches the app — which is the correct +coupling (the cursor is app UI, like borders). + +### 4.2 Config: `cursorColor` + +New field on `CursorConfig`: + +``` +cursorColor: string // "accent" (default) | "#RRGGBB" +``` + +- `"accent"` → use `theme.getFgAnsi("accent")` (focused) and a dimmed variant + for unfocused (see 4.3). +- `"#RRGGBB"` → parse to `{r,g,b}`, emit `\x1b[38;2;R;G;Bm` directly, overriding + the theme. Validated: `/^#[0-9a-fA-F]{6}$/`. Invalid values normalize to + `"accent"` with `fixed=true`. + +New subcommand: `/cursor color accent|#RRGGBB`. New panel row `cursorColor` +— cycles between `accent` and the current custom hex (if one is set); hex entry +is via the subcommand only (cycling arbitrary hex through a SettingsList row is +awkward). When `cursorColor: "accent"` the row reads `accent`; when a hex is +set it reads that hex. + +### 4.3 Unfocused color derivation + +Unfocused color = the focused color dimmed ~50% toward the terminal background: + +- If `cursorColor: "accent"` → `theme.getFgAnsi("dim")` (or `"muted"`, picked + for best contrast — see §4.4 decision). This is already a theme-coordinated + dim color; no manual dimming. +- If `cursorColor: "#RRGGBB"` → compute `{r,g,b}` dimmed 50% + (`r*0.5 | 0`, etc.), emit `\x1b[38;2;R;G;Bm`. + +The unfocused color is a *derived* value, not a config field (non-goal). This +keeps one knob (`cursorColor`) and guarantees focused/unfocused contrast. + +### 4.4 Open decision — `dim` vs `muted` for the unfocused theme color + +Both are `ThemeColor` values. `dim` is typically darker than `muted`. Pick the +one that keeps the unfocused cursor *visible but clearly secondary* against +Mocha's surface. **Decision: `"dim"`** (matches the v0.1 hollow/outline glyph +color `\x1b[38;5;8m` = dark gray → `dim`). Revisit at live-verify. + +## 5. `highlight` unfocused style (B) + +### 5.1 Glyph + escape + +New `UnfocusedStyle: "highlight"`. Renders a **colored styled underline** on the +cursor cell, **preserving the character** (no glyph substitution, no char +hidden): + +- Undercurl: `\x1b[4:3m` + colored-underline `\x1b[58:2::R:G:Bm` + `` + + `\x1b[0m` (or `\x1b[4m` plain underline if styled-underline unsupported). +- Color = the unfocused color (§4.3). + +Falls back gracefully: if the terminal doesn't support `4:3`/`58:2`, it +degrades to a plain colored underline (`\x1b[4m` + fg color) — still +char-preserving. Detected via `theme.getColorMode()` (truecolor → use styled; +256 → plain underline + 256 fg). This is a best-effort capability, not a hard +gate — the escape is standard and Ghostty supports it. + +### 5.2 Placement + +`highlight` is an **unfocused-only** style (RECTOR's confirmed scope). It joins +`dim`/`hollow`/`outline`/`underline`/`hide` as the 6th `UnfocusedStyle`. Added +to `UNFOCUSED_STYLES`, the panel row, and `/cursor unfocused highlight`. + +## 6. Hardware-cursor mode (C) + +### 6.1 Config: `cursorMode` + +``` +cursorMode: "fake" | "hardware" // default "fake" +``` + +New subcommand `/cursor mode fake|hardware`. New panel row `cursorMode`. The +v0.1 proven fake-cursor path is the default; `hardware` is opt-in. + +### 6.2 Mechanism (verified feasible in the v0.2 spike) + +- pi-tui `TUI.setShowHardwareCursor(bool)` is public; the extension already + receives the `tui` instance in `ctx.ui.setEditorComponent((tui, theme, kb) => …)`. +- `positionHardwareCursor()` moves + `\x1b[?25h`-shows the real cursor at the + cursor cell each frame; `extractCursorPosition()` strips `CURSOR_MARKER` but + leaves the styled cell. +- DECSCUSR (`\x1b[ q`: 2=steady block, 4=underline, 6=bar; 1/3/5 = blinking + variants) and OSC 12 (`\x1b]12;\x07`) are terminal *modes* that persist + across pi's render frames (pi only writes `\x1b[?25h`, never resets + shape/color). + +### 6.3 Focused state (hardware mode) + +1. `tui.setShowHardwareCursor(true)`. +2. Emit DECSCUSR for the focused shape: `block`→`2`, `underline`→`4`, `bar`→`6`. + If `blink` config on → blinking variants `1`/`3`/`5`; *disable* the + fake-cursor `BlinkController` in hardware mode (native blink replaces it). +3. Emit OSC 12 with the focused color: resolve `cursorColor` (`"accent"` → + theme accent hex via a new helper `themeAccentHex(theme)` that reads the + accent color; `#RRGGBB` → as-is). **256-color theme edge case:** if + `theme.getColorMode() === "256color"`, `themeAccentHex` can't produce an + exact hex (the accent is a 256 palette index) — in that case **skip OSC 12** + and let the terminal use its configured cursor color (documented). RECTOR's + stack is truecolor, so this only affects non-truecolor terminals. +4. Focused render-transform = **bare char** (drop the fake `\x1b[7m` cell) + so only the native hardware cursor shows. (`focusedCell` in hardware mode + returns `ch` unchanged.) + +### 6.4 Unfocused state (hardware mode) + +1. `tui.setShowHardwareCursor(false)` → hides the real cursor. +2. Render the unfocused fake cell using A's color + any unfocused style + (incl. B's `highlight`). This is the *same* `transformUnfocused` path as fake + mode — hardware mode only changes the focused state. + +### 6.5 Mode-switch + lifecycle + +- On `cursorMode` config change (panel/subcommand/watch): emit the new mode's + setup immediately + `tui.requestRender()`. +- On `session_shutdown`: restore `tui.setShowHardwareCursor(false)` (pi's + default) + emit default DECSCUSR (`\x1b[0 q`) + reset OSC 12 (`\x1b]12;\x07`) + so we don't leave the terminal with a foreign cursor shape/color. This is + the "wrapped-editor restore" hygiene (a v0.1.1 polish item) — addressed here + because hardware mode *requires* it to avoid leaking terminal state. + +### 6.6 Why opt-in (not default) + +Hardware mode is standard DECSCUSR/OSC12, but: (a) the focused shape is +constrained to block/underline/bar (the 3 DECSCUSR shapes) — `hollow`/`outline` +focused shapes are fake-cursor-only; (b) cursor *color* via OSC 12 is less +portable than SGR fg (some terminals ignore it); (c) it changes the terminal's +global cursor state (more invasive than per-cell SGR). Default `"fake"` +preserves the proven path; Ghostty users opt in. + +## 7. Configuration (additions to `CursorConfig`) + +```ts +interface CursorConfig { + // …v0.1/v0.1.1 fields… + cursorColor: string; // "accent" | "#RRGGBB" — default "accent" + cursorMode: "fake" | "hardware"; // default "fake" +} +``` + +`DEFAULT_CONFIG` additions: `cursorColor: "accent"`, `cursorMode: "fake"`. +`FOCUS_PROVIDERS` unchanged. `UnfocusedStyle` gains `"highlight"`; +`UNFOCUSED_STYLES` gains `"highlight"` (6th). `FocusProviderName` unchanged. + +`normalizeConfig` (config.ts): parse + validate `cursorColor` (hex regex or +`"accent"`), `cursorMode` (enum), and the new `unfocusedStyle` value. + +## 8. `/cursor` command UX (additions) + +- `/cursor color accent|#RRGGBB` — set cursor color. +- `/cursor mode fake|hardware` — set cursor mode. +- `/cursor unfocused highlight` — new unfocused style option. +- Panel rows: `cursorColor` (cycles accent → presets), `cursorMode` (fake↔hardware), + plus `unfocusedStyle` row already exists (now includes `highlight`). +- `/cursor status` includes `cursorColor` + `cursorMode`. +- `/cursor reset` restores `cursorColor: "accent"`, `cursorMode: "fake"`. + +## 9. Architecture / files (changes) + +| file | change | +|---|---| +| `lib/defaults.ts` | add `cursorColor`, `cursorMode` fields + `"highlight"` unfocused; defaults + enums | +| `lib/config.ts` | parse/validate `cursorColor` (hex), `cursorMode`, `unfocusedStyle` highlight; cycleValue for new keys | +| `lib/render.ts` | **thread `Theme`**; replace 256-color with `theme.getFgAnsi(...)`; `highlight` underline escapes; hardware-mode bare-char focused; `hexToAnsi` + `themeAccentHex` helpers | +| `lib/editor.ts` | pass `theme` into `transformFocused`/`transformUnfocused`; manage `cursorMode` (emit DECSCUSR/OSC12, toggle `tui.setShowHardwareCursor`); shutdown restore | +| `lib/panel.ts` | new rows `cursorColor` + `cursorMode`; `unfocusedStyle` row includes `highlight`; display values | +| `extensions/cursor.ts` | parse `color` + `mode` subcommands; panel row wiring; pass `tui` ref to editor for hardware mode | +| `lib/state.ts` | `BlinkController` — no change, but editor skips it in hardware-focused mode (native blink) | +| `lib/focus/*` | **no change** (v0.1.1 cmux + v0.1 tmux/herdr/static unchanged) | + +New helpers in `render.ts`: +- `hexToAnsi(hex: string): string` — `#RRGGBB` → `\x1b[38;2;R;G;Bm`. +- `dimHex(hex: string): string` — 50% dim → `#RRGGBB`. +- `themeAccentHex(theme: Theme): string` — read the accent color (via a new + `getFgHex`/reflection on Theme, or by resolving `getFgAnsi("accent")` to RGB + by parsing the emitted escape — see §11 open decision). +- `decscusr(shape, blink): string` — `\x1b[ q`. +- `osc12(colorHex): string` — `\x1b]12;\x07`. + +## 10. Testing + +Stack unchanged: `tsx` + `node:test`/`node:assert/strict` (NOT vitest). 80%+ +coverage on new code. + +- `tests/render.test.ts` (extend): mock `Theme` (`{ getFgAnsi: c => PREFIX[c], + getColorMode: () => "truecolor" }`); assert focused bar/hollow/outline now emit + `theme.getFgAnsi("accent")`/`"dim"`; assert hex-override emits `\x1b[38;2;…m`; + assert `highlight` emits styled-underline + preserves char; assert hardware + focused = bare char. +- `tests/config.test.ts` (extend): `cursorColor` hex validation/normalization; + `cursorMode` enum; `highlight` unfocused cycle. +- `tests/defaults.test.ts` (extend): new defaults + enums. +- `tests/entry.test.ts` (extend): parse `color` + `mode` subcommands. +- `tests/panel.test.ts` (extend): new row ids + display values. +- `tests/editor.test.ts` (new or extend): hardware-mode DECSCUSR/OSC12 emission + on focus flip; `setShowHardwareCursor` toggling; blink disabled in + hardware-focused; shutdown restore (DECSCUSR `\x1b[0 q` + OSC12 reset + + `setShowHardwareCursor(false)`). Mock `tui` (`{ setShowHardwareCursor, requestRender }`) + + capture emitted escape sequences via a spy on the write path. + +## 11. Open decisions (resolve before/during plan) + +- **`themeAccentHex`** — `Theme.fgColors` is private; `getFgAnsi("accent")` + returns the *prefix* (e.g. `\x1b[38;2;203;166;247m` for Mocha mauve), not the + hex. Two options: (a) parse the emitted `\x1b[38;2;R;G;Bm` prefix back to RGB + → hex (works for truecolor). (b) Add a thin accessor. **Decision: (a)** parse + `getFgAnsi` output — no upstream change, works today. **256-color mode:** + parsing `\x1b[38;5;Nm` to hex is inexact; in that mode `themeAccentHex` + returns `""` and hardware mode skips OSC 12 (terminal uses its default cursor + color). No upstream change; documented. +- **`dim` vs `muted`** (§4.4) — `dim` (decided, revisit at live-verify). +- **`highlight` underline variant** — undercurl (`4:3`) vs plain colored + underline (`4` + `58:2`). **Decision: undercurl** (more distinct as a cursor + indicator); fall back to plain if `getColorMode() !== "truecolor"`. +- **Hardware-mode focused `bar` shape** — DECSCUSR `6` = steady bar. Confirm + Ghostty renders it as expected at live-verify (the v0.1 fake `bar` is `▎`; + the hardware bar is a 1-cell-wide vertical bar — slightly different; document). + +## 12. Risks & verification + +- **⚠️ Live-verification deferred** — like v0.1 herdr + v0.1.1 cmux, A/B/C are + unit-tested against mocks (mock Theme, mock tui) but not verified against a + live Ghostty+tmux pane in this release. Flagged in README + AGENTS + code + headers. Live-verify checklist: (1) truecolor renders Mocha-exact through + tmux, (2) `highlight` undercurl preserves char, (3) `hardware` mode shows + native Ghostty cursor with accent color + correct shape, (4) shutdown + restores default cursor. +- **OSC 12 portability** — some terminals ignore cursor-color OSC 12; hardware + mode then shows the terminal's default cursor color. Mitigated by opt-in + + documentation. +- **DECSCUSR shape set** — only block/underline/bar for hardware focused; + `hollow`/`outline` focused styles don't exist (they're unfocused-only in + v0.1 too). No regression. +- **Terminal state leak** — shutdown restore (§6.5) prevents leaving a + foreign cursor shape/color after pi exits. +- **pi-tui render-loop interaction** — the spike confirmed pi only writes + `\x1b[?25h` (show) per frame, never DECSCUSR/OSC12, so our mode settings + persist. Re-verify at live-verify. + +## 13. Success criteria + +- `pnpm typecheck` + `pnpm test:run` green; 80%+ coverage on new code. +- Defaults (`cursorColor: "accent"`, `cursorMode: "fake"`) reproduce v0.1.1 + behavior exactly (no visual regression for existing users). +- On RECTOR's Ghostty+tmux pane, `/cursor color #cba6f7` + `/cursor mode + hardware` + `/cursor unfocused highlight` produce the intended native + + char-preserving + truecolor result (live-verify, ⚠️ deferred). +- Tag `v0.2.0` → release.yml publishes `@getpipher/cursor@0.2.0`. + +## 14. Release + +Minor bump `0.1.1` → `0.2.0` (new features: new config fields, new style, new +mode — not a patch). Commit per-feature across the plan slices; final commit +bumps version + tags `v0.2.0` → CI publishes. One branch `feat/ghostty-tmux-deep`. \ No newline at end of file diff --git a/extensions/cursor.ts b/extensions/cursor.ts index f7f6ee0..3172df9 100644 --- a/extensions/cursor.ts +++ b/extensions/cursor.ts @@ -15,10 +15,12 @@ import { UNFOCUSED_STYLES, BLINK_RATES, FOCUS_PROVIDERS, + CURSOR_MODES, type CursorConfig, type FocusedStyle, type UnfocusedStyle, type FocusProviderName, + type CursorMode, } from "../lib/defaults.ts"; import type { FSWatcher } from "node:fs"; import { loadConfig, saveConfigTracked, watchConfig } from "../lib/config.ts"; @@ -69,6 +71,14 @@ export function parseCursorArgs(args: string[], _cfg: CursorConfig): CursorArgs case "provider": if (!FOCUS_PROVIDERS.includes(b as FocusProviderName)) throw new Error(`Usage: /cursor provider ${FOCUS_PROVIDERS.join("|")}`); return { action: "set", patch: { focusProvider: b as FocusProviderName } }; + case "color": { + if (b === "accent") return { action: "set", patch: { cursorColor: "accent" } }; + if (b !== undefined && /^#[0-9a-fA-F]{6}$/.test(b)) return { action: "set", patch: { cursorColor: b } }; + throw new Error(`Usage: /cursor color accent|#RRGGBB`); + } + case "mode": + if (!CURSOR_MODES.includes(b as CursorMode)) throw new Error(`Usage: /cursor mode ${CURSOR_MODES.join("|")}`); + return { action: "set", patch: { cursorMode: b as CursorMode } }; default: return { action: "usage" }; } @@ -86,7 +96,8 @@ export default function (pi: ExtensionAPI): void { function restartBlink(): void { if (!blink || !editor) return; blink.stop(); - if (cfg.blink) blink.start(cfg.blinkRate, () => editor!.onBlinkToggle()); + // In hardware mode the native DECSCUSR blink drives the cursor; don't run the fake blink. + if (cfg.blink && cfg.cursorMode !== "hardware") blink.start(cfg.blinkRate, () => editor!.onBlinkToggle()); } async function restartProvider(): Promise { @@ -119,7 +130,8 @@ export default function (pi: ExtensionAPI): void { const ed = new CursorEditor(tui, theme, keybindings, { wrapped, blink: blinkController }); editor = ed; ed.updateConfig(cfg); - if (cfg.blink) blinkController.start(cfg.blinkRate, () => ed.onBlinkToggle()); + // In hardware mode the native DECSCUSR blink drives the cursor; don't run the fake blink. + if (cfg.blink && cfg.cursorMode !== "hardware") blinkController.start(cfg.blinkRate, () => ed.onBlinkToggle()); return ed; }); provider = await createProvider(cfg.focusProvider, (focused) => editor?.setFocus(focused)); @@ -132,6 +144,7 @@ export default function (pi: ExtensionAPI): void { pi.on("session_shutdown", async () => { configWatcher?.close(); configWatcher = undefined; + editor?.restoreCursor?.(); await provider?.stop(); provider = null; blink?.stop(); @@ -194,9 +207,9 @@ export default function (pi: ExtensionAPI): void { const getCfg = () => cfg; const getBlinkVisible = () => (cfg.blink ? blink?.visible ?? true : true); container.addChild(new Text(theme.fg("dim", "focused:"), 0, 0)); - container.addChild(previewLine(getCfg, true, getBlinkVisible) as any); + container.addChild(previewLine(getCfg, true, getBlinkVisible, () => theme) as any); container.addChild(new Text(theme.fg("dim", "unfocused:"), 0, 0)); - container.addChild(previewLine(getCfg, false) as any); + container.addChild(previewLine(getCfg, false, undefined, () => theme) as any); container.addChild(new Spacer(1)); container.addChild(settingsList); container.addChild(new Text(theme.fg("dim", "↑↓ navigate • enter edit/cycle • r reset • esc done"), 0, 0)); diff --git a/lib/config.ts b/lib/config.ts index cd6b2e7..e373383 100644 --- a/lib/config.ts +++ b/lib/config.ts @@ -7,10 +7,12 @@ import { UNFOCUSED_STYLES, BLINK_RATES, FOCUS_PROVIDERS, + CURSOR_MODES, type CursorConfig, type FocusedStyle, type UnfocusedStyle, type FocusProviderName, + type CursorMode, } from "./defaults.ts"; export const CONFIG_FILENAME = "cursor.json"; @@ -24,6 +26,12 @@ function isUnfocusedStyle(v: unknown): v is UnfocusedStyle { function isProvider(v: unknown): v is FocusProviderName { return FOCUS_PROVIDERS.includes(v as FocusProviderName); } +function isHexColor(v: unknown): v is string { + return typeof v === "string" && /^#[0-9a-fA-F]{6}$/.test(v); +} +function isCursorMode(v: unknown): v is CursorMode { + return CURSOR_MODES.includes(v as CursorMode); +} export function normalizeConfig(raw: Record): { config: CursorConfig; fixed: boolean } { const cfg: CursorConfig = { ...DEFAULT_CONFIG }; @@ -41,6 +49,10 @@ export function normalizeConfig(raw: Record): { config: CursorC else fixed = true; if (isProvider(r.focusProvider)) cfg.focusProvider = r.focusProvider; else fixed = true; + if (r.cursorColor === "accent" || isHexColor(r.cursorColor)) cfg.cursorColor = r.cursorColor; + else fixed = true; + if (isCursorMode(r.cursorMode)) cfg.cursorMode = r.cursorMode; + else fixed = true; return { config: cfg, fixed }; } @@ -62,7 +74,7 @@ export async function saveConfig(cfg: CursorConfig, dir: string): Promise await writeFile(join(dir, CONFIG_FILENAME), JSON.stringify(cfg, null, 2) + "\n", "utf8"); } -type CycleableKey = "focusedStyle" | "unfocusedStyle" | "blinkRate" | "focusProvider"; +type CycleableKey = "focusedStyle" | "unfocusedStyle" | "blinkRate" | "focusProvider" | "cursorMode"; export function cycleValue(cfg: CursorConfig, key: CycleableKey): CursorConfig { if (key === "focusedStyle") { const i = FOCUSED_STYLES.indexOf(cfg.focusedStyle); @@ -76,6 +88,10 @@ export function cycleValue(cfg: CursorConfig, key: CycleableKey): CursorConfig { const i = BLINK_RATES.indexOf(cfg.blinkRate); return { ...cfg, blinkRate: BLINK_RATES[(i + 1) % BLINK_RATES.length]! }; } + if (key === "cursorMode") { + const i = CURSOR_MODES.indexOf(cfg.cursorMode); + return { ...cfg, cursorMode: CURSOR_MODES[(i + 1) % CURSOR_MODES.length]! }; + } const i = FOCUS_PROVIDERS.indexOf(cfg.focusProvider); return { ...cfg, focusProvider: FOCUS_PROVIDERS[(i + 1) % FOCUS_PROVIDERS.length]! }; } diff --git a/lib/defaults.ts b/lib/defaults.ts index efedb88..3538f09 100644 --- a/lib/defaults.ts +++ b/lib/defaults.ts @@ -1,11 +1,13 @@ export type FocusedStyle = "block" | "bar" | "underline"; -export type UnfocusedStyle = "dim" | "hollow" | "outline" | "underline" | "hide"; +export type UnfocusedStyle = "dim" | "hollow" | "outline" | "underline" | "hide" | "highlight"; export type FocusProviderName = "auto" | "tmux" | "cmux" | "herdr" | "static"; +export type CursorMode = "fake" | "hardware"; export const FOCUSED_STYLES: readonly FocusedStyle[] = ["block", "bar", "underline"]; -export const UNFOCUSED_STYLES: readonly UnfocusedStyle[] = ["dim", "hollow", "outline", "underline", "hide"]; +export const UNFOCUSED_STYLES: readonly UnfocusedStyle[] = ["dim", "hollow", "outline", "underline", "hide", "highlight"]; export const BLINK_RATES: readonly number[] = [400, 500, 600, 800, 1000]; export const FOCUS_PROVIDERS: readonly FocusProviderName[] = ["auto", "tmux", "cmux", "herdr", "static"]; +export const CURSOR_MODES: readonly CursorMode[] = ["fake", "hardware"]; export interface CursorConfig { enabled: boolean; @@ -14,6 +16,8 @@ export interface CursorConfig { blink: boolean; blinkRate: number; focusProvider: FocusProviderName; + cursorColor: string; // "accent" | "#RRGGBB" + cursorMode: CursorMode; } export const DEFAULT_CONFIG: CursorConfig = { @@ -23,4 +27,6 @@ export const DEFAULT_CONFIG: CursorConfig = { blink: false, blinkRate: 600, focusProvider: "auto", + cursorColor: "accent", + cursorMode: "fake", }; \ No newline at end of file diff --git a/lib/editor.ts b/lib/editor.ts index 0affc10..17d3f68 100644 --- a/lib/editor.ts +++ b/lib/editor.ts @@ -1,34 +1,39 @@ import { CustomEditor } from "@earendil-works/pi-coding-agent"; -import { transformFocused, transformUnfocused } from "./render.ts"; -import type { CursorConfig } from "./defaults.ts"; +import { transformFocused, transformUnfocused, decscusr, osc12, themeAccentHex } from "./render.ts"; +import type { CursorConfig, CursorMode } from "./defaults.ts"; import { BlinkController } from "./state.ts"; +type Theme = { getFgAnsi(color: string): string; getColorMode(): "truecolor" | "256color" }; + export interface CursorEditorDeps { wrapped: { render(width: number): string[]; handleInput(data: string): void } | null; blink: BlinkController; } -/** Pure composition of wrapped render output + focus/blink transforms. Exported for testing. */ export function composeRender( lines: string[], focused: boolean, cfg: CursorConfig, + theme: Theme, blinkVisible: boolean, + cursorMode: CursorMode, ): string[] { if (!cfg.enabled) return lines; return focused - ? transformFocused(lines, cfg, blinkVisible) - : transformUnfocused(lines, cfg); + ? transformFocused(lines, cfg, theme, blinkVisible, cursorMode) + : transformUnfocused(lines, cfg, theme); } export class CursorEditor extends CustomEditor { private cfg: CursorConfig; private paneFocused = true; private deps: CursorEditorDeps; + private cursorTheme: Theme; constructor(tui: any, theme: any, keybindings: any, deps: CursorEditorDeps) { super(tui, theme, keybindings, {}); this.deps = deps; + this.cursorTheme = theme as Theme; this.cfg = { enabled: true, focusedStyle: "block", @@ -36,22 +41,60 @@ export class CursorEditor extends CustomEditor { blink: false, blinkRate: 600, focusProvider: "auto", + cursorColor: "accent", + cursorMode: "fake", }; } updateConfig(cfg: CursorConfig): void { this.cfg = cfg; + this.applyCursorMode(); this.invalidate?.(); + this.tui?.requestRender?.(); } setFocus(focused: boolean): void { if (this.paneFocused === focused) return; this.paneFocused = focused; this.deps.blink.setActive(focused); + this.applyCursorMode(); this.invalidate?.(); this.tui?.requestRender?.(); } + /** Emit DECSCUSR/OSC12 + toggle the hardware cursor for the current mode/focus. */ + private applyCursorMode(): void { + const hw = this.cfg.cursorMode === "hardware" && this.paneFocused; + this.tui?.setShowHardwareCursor?.(hw); + if (hw) { + // native blink replaces the fake-cursor blink controller + this.deps.blink.stop(); + const shape: "block" | "underline" | "bar" = + this.cfg.focusedStyle === "underline" ? "underline" : + this.cfg.focusedStyle === "bar" ? "bar" : "block"; + this.writeTerm(decscusr(shape, this.cfg.blink)); + const hex = this.cfg.cursorColor === "accent" ? themeAccentHex(this.cursorTheme) : this.cfg.cursorColor; + const osc = osc12(hex); + if (osc) this.writeTerm(osc); + } else { + // fake mode (or hardware-unfocused): reset to the terminal's default shape + this.writeTerm("\x1b[0 q"); + } + } + + /** Write a raw escape sequence to the terminal (via pi-tui's TUI.terminal.write). */ + private writeTerm(seq: string): void { + const t = this.tui as any; + (t?.terminal?.write ?? t?.write)?.(seq); + } + + /** Restore the terminal's default cursor (call on session_shutdown). */ + restoreCursor(): void { + this.tui?.setShowHardwareCursor?.(false); + this.writeTerm("\x1b[0 q"); // default cursor shape (DECSCUSR reset) + this.writeTerm("\x1b]112\x07"); // OSC 112: reset cursor color to terminal default + } + /** Called by the BlinkController on each toggle so the editor re-renders. */ onBlinkToggle(): void { this.invalidate?.(); @@ -65,6 +108,6 @@ 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.blink.visible); + return composeRender(lines, this.paneFocused, this.cfg, this.cursorTheme, this.deps.blink.visible, this.cfg.cursorMode); } } \ No newline at end of file diff --git a/lib/panel.ts b/lib/panel.ts index adc15d3..d6f28ba 100644 --- a/lib/panel.ts +++ b/lib/panel.ts @@ -5,10 +5,12 @@ import { UNFOCUSED_STYLES, BLINK_RATES, FOCUS_PROVIDERS, + CURSOR_MODES, type CursorConfig, type FocusedStyle, type UnfocusedStyle, type FocusProviderName, + type CursorMode, } from "./defaults.ts"; import { transformFocused, transformUnfocused } from "./render.ts"; @@ -56,7 +58,21 @@ export function panelRows(cfg: CursorConfig, activeProviderLabel: string): Setti label: "Focus provider", currentValue: cfg.focusProvider, values: [...FOCUS_PROVIDERS], - description: "auto = detect (tmux > herdr > static). Manual override available.", + description: "auto = detect (tmux > cmux > herdr > static). Manual override available.", + }, + { + id: "cursorColor", + label: "Cursor color", + currentValue: cfg.cursorColor, + values: ["accent", cfg.cursorColor === "accent" ? "accent" : cfg.cursorColor], + description: "accent follows the pi theme; or set #RRGGBB via /cursor color.", + }, + { + id: "cursorMode", + label: "Cursor mode", + currentValue: cfg.cursorMode, + values: [...CURSOR_MODES], + description: "fake = pi's fake cursor (default). hardware = native terminal cursor (Ghostty).", }, { id: "activeProvider", @@ -82,6 +98,10 @@ export function applyRowChange(cfg: CursorConfig, id: string, newValue: string): return { ...cfg, blinkRate: Number(newValue) }; case "focusProvider": return { ...cfg, focusProvider: newValue as FocusProviderName }; + case "cursorColor": + return { ...cfg, cursorColor: newValue === "accent" || /^#[0-9a-fA-F]{6}$/.test(newValue) ? newValue : cfg.cursorColor }; + case "cursorMode": + return { ...cfg, cursorMode: newValue as CursorMode }; default: return cfg; // activeProvider is read-only } @@ -97,16 +117,20 @@ export function previewLine( getCfg: () => CursorConfig, focused: boolean, getBlinkVisible?: () => boolean, + getTheme?: () => { getFgAnsi(color: string): string; getColorMode(): "truecolor" | "256color" }, ): Component { + // Fallback no-color theme so previewLine works without a theme (T8 threads the real one). + const fallbackTheme = { getFgAnsi: () => "", getColorMode: () => "256color" as const }; const sample = `const result = await fetch(url);${CURSOR_MARKER}\x1b[7m \x1b[0m`;; return { render(_width: number): string[] { const cfg = getCfg(); + const theme = getTheme ? getTheme() : fallbackTheme; if (focused) { const blinkVisible = getBlinkVisible ? getBlinkVisible() : true; - return transformFocused([sample], cfg, blinkVisible); + return transformFocused([sample], cfg, theme, blinkVisible, cfg.cursorMode); } - return transformUnfocused([sample], cfg); + return transformUnfocused([sample], cfg, theme); }, invalidate() {}, }; @@ -119,5 +143,7 @@ export function rowDisplayValue(id: string, cfg: CursorConfig): string { if (id === "focusedStyle") return cfg.focusedStyle; if (id === "unfocusedStyle") return cfg.unfocusedStyle; if (id === "focusProvider") return cfg.focusProvider; + if (id === "cursorColor") return cfg.cursorColor; + if (id === "cursorMode") return cfg.cursorMode; return cfg[id as keyof CursorConfig]?.toString() ?? ""; } \ No newline at end of file diff --git a/lib/render.ts b/lib/render.ts index 51cac94..137b5ef 100644 --- a/lib/render.ts +++ b/lib/render.ts @@ -1,6 +1,70 @@ import { CURSOR_MARKER } from "@earendil-works/pi-tui"; import type { CursorConfig, FocusedStyle, UnfocusedStyle } from "./defaults.ts"; +type Theme = { getFgAnsi(color: string): string; getColorMode(): "truecolor" | "256color" }; + +// --- Color/escape helpers (v0.2.0) --- + +/** #RRGGBB → \x1b[38;2;R;G;Bm. Returns "" if the hex is malformed. */ +export function hexToAnsi(hex: string): string { + const m = /^#([0-9a-fA-F]{6})$/.exec(hex); + if (!m) return ""; + const n = m[1]!; + const r = parseInt(n.slice(0, 2), 16); + const g = parseInt(n.slice(2, 4), 16); + const b = parseInt(n.slice(4, 6), 16); + return `\x1b[38;2;${r};${g};${b}m`; +} + +/** 50%-dim a #RRGGBB hex toward black. */ +export function dimHex(hex: string): string { + const m = /^#([0-9a-fA-F]{6})$/.exec(hex); + if (!m) return ""; + const n = m[1]!; + const dim = (s: string) => Math.floor(parseInt(s, 16) * 0.5).toString(16).padStart(2, "0"); + return `#${dim(n.slice(0, 2))}${dim(n.slice(2, 4))}${dim(n.slice(4, 6))}`; +} + +/** Parse a truecolor fg ANSI prefix back to #RRGGBB. Returns "" for 256 or non-matching. */ +export function parseAnsiFgToHex(prefix: string): string { + const m = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(prefix); + if (!m) return ""; + const r = Number(m[1]).toString(16).padStart(2, "0"); + const g = Number(m[2]).toString(16).padStart(2, "0"); + const b = Number(m[3]).toString(16).padStart(2, "0"); + return `#${r}${g}${b}`; +} + +/** DECSCUSR cursor-shape escape. Steady: 2/4/6; blinking: 1/3/5. */ +export function decscusr(shape: "block" | "underline" | "bar", blink: boolean): string { + const steady: Record<"block" | "underline" | "bar", number> = { block: 2, underline: 4, bar: 6 }; + const n = steady[shape] - (blink ? 1 : 0); + return `\x1b[${n} q`; +} + +/** OSC 12 cursor-color. Empty hex → "" (skip, terminal uses its default). */ +export function osc12(hex: string): string { + return hex ? `\x1b]12;${hex}\x07` : ""; +} + +/** Resolve the focused cursor color to an ANSI fg prefix. */ +export function resolveFocusedColor(cfg: CursorConfig, theme: Theme): string { + if (cfg.cursorColor === "accent") return theme.getFgAnsi("accent"); + return hexToAnsi(cfg.cursorColor); +} + +/** Resolve the unfocused cursor color: accent → theme "dim"; hex → dimmed hex. */ +export function resolveUnfocusedColor(cfg: CursorConfig, theme: Theme): string { + if (cfg.cursorColor === "accent") return theme.getFgAnsi("dim"); + return hexToAnsi(dimHex(cfg.cursorColor)); +} + +/** Resolve the accent color to a hex (for OSC 12). "" in 256-color mode (inexact). */ +export function themeAccentHex(theme: Theme): string { + if (theme.getColorMode() !== "truecolor") return ""; + return parseAnsiFgToHex(theme.getFgAnsi("accent")); +} + // pi renders the cursor as: CURSOR_MARKER + "\x1b[7m" + char + "\x1b[0m" const CELL_RE = /\x1b\[7m([\s\S])\x1b\[0m/; @@ -21,50 +85,73 @@ function transformLine(line: string, fn: (ch: string) => string): string { return line.includes(CURSOR_MARKER) ? rewriteCursorCell(line, fn) : line; } -// bar/hollow/outline glyphs in dim accent (256-color: 7 = light gray for bar, 8 = dark gray for hollow/outline). +// bar/hollow/outline glyphs (color now comes from the theme via resolveFocused/UnfocusedColor). const BAR = "\u258E"; // ▎ const HOLLOW = "\u25A1"; // □ sharp hollow block (matches focused block shape) const OUTLINE = "\u25A2"; // ▢ rounded hollow square -function focusedCell(style: FocusedStyle, ch: string): string { +function focusedCell(style: FocusedStyle, ch: string, colorAnsi: string): string { switch (style) { case "block": return `\x1b[7m${ch}\x1b[0m`; // unchanged from pi case "underline": return `\x1b[4m${ch}\x1b[0m`; case "bar": - return `\x1b[38;5;7m${BAR}\x1b[39m`; // char hidden + return `${colorAnsi}${BAR}\x1b[39m`; // char hidden, accent-colored } } -function unfocusedCell(style: UnfocusedStyle, ch: string): string { +function unfocusedCell(style: UnfocusedStyle, ch: string, colorAnsi: string, truecolor: boolean): string { switch (style) { case "dim": return `\x1b[2;7m${ch}\x1b[0m`; case "hollow": - return `\x1b[38;5;8m${HOLLOW}\x1b[39m`; // char hidden (sharp hollow block) + return `${colorAnsi}${HOLLOW}\x1b[39m`; // char hidden, dim-colored case "outline": - return `\x1b[38;5;8m${OUTLINE}\x1b[39m`; // char hidden (rounded hollow square) + return `${colorAnsi}${OUTLINE}\x1b[39m`; // char hidden, dim-colored case "underline": return `\x1b[4;2m${ch}\x1b[0m`; case "hide": return ch; + case "highlight": { + // Char-preserving colored styled underline. Truecolor: undercurl (4:3) + + // colored underline (58:2::R:G:B). 256-color: plain underline + 256 fg. + if (!truecolor) return `${colorAnsi}\x1b[4m${ch}\x1b[0m`; + const m = /^\x1b\[38;2;(\d+);(\d+);(\d+)m$/.exec(colorAnsi); + if (!m) return `${colorAnsi}\x1b[4m${ch}\x1b[0m`; + const rgb = `${m[1]}:${m[2]}:${m[3]}`; + return `\x1b[4:3m\x1b[58:2::${rgb}m${ch}\x1b[0m`; + } } } -export function transformFocused(lines: string[], cfg: CursorConfig, blinkVisible: boolean): string[] { +export function transformFocused( + lines: string[], + cfg: CursorConfig, + theme: Theme, + blinkVisible: boolean, + cursorMode: "fake" | "hardware" = "fake", +): string[] { if (!cfg.enabled) return lines; + // Hardware focused: keep marker (so pi positions the real cursor), drop the + // reverse-video cell → bare char. Only the native hardware cursor shows. + if (cursorMode === "hardware") { + return lines.map((l) => (l.includes(CURSOR_MARKER) ? rewriteCursorCell(l, (ch) => ch) : l)); + } // block + visible = pi-native passthrough (keep CURSOR_MARKER + reverse-video cell unchanged) if (cfg.focusedStyle === "block" && blinkVisible) return lines; + const colorAnsi = resolveFocusedColor(cfg, theme); // blink off-phase: render the bare char (hide). blink always blinks to invisible. const fn = blinkVisible - ? (ch: string) => focusedCell(cfg.focusedStyle, ch) + ? (ch: string) => focusedCell(cfg.focusedStyle, ch, colorAnsi) : (ch: string) => ch; return lines.map((l) => transformLine(l, fn)); } -export function transformUnfocused(lines: string[], cfg: CursorConfig): string[] { +export function transformUnfocused(lines: string[], cfg: CursorConfig, theme: Theme): string[] { if (!cfg.enabled) return lines; - const fn = (ch: string) => unfocusedCell(cfg.unfocusedStyle, ch); + const colorAnsi = resolveUnfocusedColor(cfg, theme); + const truecolor = theme.getColorMode() === "truecolor"; + const fn = (ch: string) => unfocusedCell(cfg.unfocusedStyle, ch, colorAnsi, truecolor); return lines.map((l) => transformLine(l, fn)); } \ No newline at end of file diff --git a/package.json b/package.json index 19add08..980386a 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "@getpipher/cursor", - "version": "0.1.1", - "description": "Focus-aware, customizable editor cursor for the pi coding agent (tmux + cmux + herdr; static fallback).", + "version": "0.2.0", + "description": "Focus-aware, customizable editor cursor for the pi coding agent (truecolor + Ghostty/tmux deep; tmux + cmux + herdr; static fallback).", "keywords": [ "pi-package", "pi-extension", @@ -10,7 +10,9 @@ "cmux", "herdr", "focus", - "tui" + "tui", + "truecolor", + "ghostty" ], "license": "MIT", "author": "RECTOR ", diff --git a/tests/config.test.ts b/tests/config.test.ts index 71e8aef..b5579a0 100644 --- a/tests/config.test.ts +++ b/tests/config.test.ts @@ -39,4 +39,46 @@ test("cycleValue wraps within enum", () => { assert.equal(cycleValue(DEFAULT_CONFIG, "unfocusedStyle").unfocusedStyle, "outline"); assert.equal(cycleValue(DEFAULT_CONFIG, "blinkRate").blinkRate, 800); assert.equal(cycleValue(DEFAULT_CONFIG, "focusProvider").focusProvider, "tmux"); +}); + +test("normalizeConfig accepts cursorColor accent + hex + cursorMode + highlight", () => { + const { config } = normalizeConfig({ + cursorColor: "#cba6f7", + cursorMode: "hardware", + unfocusedStyle: "highlight", + }); + assert.equal(config.cursorColor, "#cba6f7"); + assert.equal(config.cursorMode, "hardware"); + assert.equal(config.unfocusedStyle, "highlight"); +}); + +test("normalizeConfig full valid v0.2.0 config → fixed=false", () => { + const { fixed } = normalizeConfig({ + enabled: true, + focusedStyle: "block", + unfocusedStyle: "highlight", + blink: false, + blinkRate: 600, + focusProvider: "auto", + cursorColor: "#cba6f7", + cursorMode: "hardware", + }); + assert.equal(fixed, false); +}); + +test("normalizeConfig rejects bad hex → falls back to accent (fixed=true)", () => { + const { config, fixed } = normalizeConfig({ cursorColor: "#xyz" }); + assert.equal(fixed, true); + assert.equal(config.cursorColor, "accent"); +}); + +test("normalizeConfig rejects unknown cursorMode → fake (fixed=true)", () => { + const { config, fixed } = normalizeConfig({ cursorMode: "weird" as any }); + assert.equal(fixed, true); + assert.equal(config.cursorMode, "fake"); +}); + +test("cycleValue cycles cursorMode fake → hardware → fake", () => { + assert.equal(cycleValue({ ...DEFAULT_CONFIG }, "cursorMode").cursorMode, "hardware"); + assert.equal(cycleValue({ ...DEFAULT_CONFIG, cursorMode: "hardware" as const }, "cursorMode").cursorMode, "fake"); }); \ No newline at end of file diff --git a/tests/defaults.test.ts b/tests/defaults.test.ts index a1121f8..b5cae28 100644 --- a/tests/defaults.test.ts +++ b/tests/defaults.test.ts @@ -6,6 +6,7 @@ import { UNFOCUSED_STYLES, BLINK_RATES, FOCUS_PROVIDERS, + CURSOR_MODES, } from "../lib/defaults.ts"; test("default config has expected shape", () => { @@ -16,6 +17,8 @@ test("default config has expected shape", () => { blink: false, blinkRate: 600, focusProvider: "auto", + cursorColor: "accent", + cursorMode: "fake", }); }); @@ -23,14 +26,23 @@ test("focused styles enum", () => { assert.deepEqual(FOCUSED_STYLES, ["block", "bar", "underline"]); }); -test("unfocused styles enum", () => { - assert.deepEqual(UNFOCUSED_STYLES, ["dim", "hollow", "outline", "underline", "hide"]); -}); - test("blink rates enum", () => { assert.deepEqual(BLINK_RATES, [400, 500, 600, 800, 1000]); }); test("focus providers enum", () => { assert.deepEqual(FOCUS_PROVIDERS, ["auto", "tmux", "cmux", "herdr", "static"]); +}); + +test("default config has v0.2.0 additions", () => { + assert.equal(DEFAULT_CONFIG.cursorColor, "accent"); + assert.equal(DEFAULT_CONFIG.cursorMode, "fake"); +}); + +test("unfocused styles enum includes highlight", () => { + assert.deepEqual(UNFOCUSED_STYLES, ["dim", "hollow", "outline", "underline", "hide", "highlight"]); +}); + +test("cursor modes enum", () => { + assert.deepEqual(CURSOR_MODES, ["fake", "hardware"]); }); \ No newline at end of file diff --git a/tests/editor.test.ts b/tests/editor.test.ts index fc92fe0..ebfcbaa 100644 --- a/tests/editor.test.ts +++ b/tests/editor.test.ts @@ -2,24 +2,29 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { CURSOR_MARKER } from "@earendil-works/pi-tui"; import { CursorEditor, composeRender } from "../lib/editor.ts"; -import { DEFAULT_CONFIG } from "../lib/defaults.ts"; +import { DEFAULT_CONFIG, type CursorConfig } from "../lib/defaults.ts"; import { BlinkController, type Scheduler } from "../lib/state.ts"; const cell = (ch: string) => `${CURSOR_MARKER}\x1b[7m${ch}\x1b[0m`; +const THEME = { + getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;5;7m" : c === "dim" ? "\x1b[38;5;8m" : ""), + getColorMode: () => "256color" as const, +}; + // Pure composition test — no instantiation needed. test("composeRender: focused+block = passthrough; focused+dim-underline; unfocused+hide", () => { const lines = [`const x = ${cell("f")}await;`]; assert.deepEqual( - composeRender(lines, true, { ...DEFAULT_CONFIG, focusedStyle: "block" }, true), + composeRender(lines, true, { ...DEFAULT_CONFIG, focusedStyle: "block" }, THEME, true, "fake"), lines, // block + visible = passthrough (keeps marker) ); assert.deepEqual( - composeRender(lines, true, { ...DEFAULT_CONFIG, focusedStyle: "underline" }, true), + composeRender(lines, true, { ...DEFAULT_CONFIG, focusedStyle: "underline" }, THEME, true, "fake"), [`const x = \x1b[4mf\x1b[0mawait;`], ); assert.deepEqual( - composeRender(lines, false, { ...DEFAULT_CONFIG, unfocusedStyle: "hide" }, true), + composeRender(lines, false, { ...DEFAULT_CONFIG, unfocusedStyle: "hide" }, THEME, true, "fake"), [`const x = fawait;`], ); }); @@ -31,7 +36,7 @@ function makeEditor(wrappedRender: (w: number) => string[]) { const blink = new BlinkController(noopScheduler); const wrapped = { render: wrappedRender, handleInput: (_d: string) => {} }; const tui = { requestRender: () => {} }; - const theme = { borderColor: "" }; + const theme = THEME; const ed = new CursorEditor(tui as any, theme as any, {} as any, { wrapped: wrapped as any, blink }); ed.updateConfig(DEFAULT_CONFIG); return { ed, wrapped }; @@ -78,4 +83,102 @@ test("setFocus(true) twice is a no-op (no redundant renders)", () => { assert.equal(renders, 1); ed.setFocus(false); // already false → no-op assert.equal(renders, 1); +}); + +// --- v0.2.0 (C): hardware-cursor mode --- +function mockTui() { + const writes: string[] = []; + const hw: boolean[] = []; + return { + writes, + hw, + tui: { + setShowHardwareCursor(v: boolean) { hw.push(v); }, + requestRender() {}, + terminal: { write(s: string) { writes.push(s); } }, + }, + }; +} +const HW_THEME = { + getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;2;203;166;247m" : ""), + getColorMode: () => "truecolor" as const, +}; + +function makeHwEditor(cfg: Partial = {}) { + const m = mockTui(); + const blink = new BlinkController(noopScheduler); + const wrapped = { render: () => [""], handleInput: (_d: string) => {} }; + const ed = new CursorEditor(m.tui as any, HW_THEME as any, {} as any, { wrapped: wrapped as any, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, ...cfg }); + return { ed, m, blink }; +} + +test("hardware mode: focused emits DECSCUSR steady block + OSC 12 accent + shows hw cursor", () => { + const { m } = makeHwEditor({ cursorMode: "hardware", focusedStyle: "block" }); + assert.ok(m.hw.includes(true), "hardware cursor shown"); + assert.ok(m.writes.some((w) => w.includes("\x1b[2 q")), "DECSCUSR steady block"); + assert.ok(m.writes.some((w) => w.includes("\x1b]12;#cba6f7\x07")), "OSC 12 accent hex"); +}); + +test("hardware mode: blink on → blinking DECSCUSR + BlinkController stopped", () => { + const m = mockTui(); + const blink = new BlinkController(noopScheduler); + let stopped = 0; + const origStop = blink.stop.bind(blink); + blink.stop = () => { stopped++; origStop(); }; + const wrapped = { render: () => [""], handleInput: (_d: string) => {} }; + const ed = new CursorEditor(m.tui as any, HW_THEME as any, {} as any, { wrapped: wrapped as any, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware", blink: true, focusedStyle: "underline" }); + assert.ok(m.writes.some((w) => w.includes("\x1b[3 q")), "blinking underline DECSCUSR"); + assert.ok(stopped >= 1, "blink.stop called (native blink replaces fake blink)"); +}); + +test("hardware mode: hex cursorColor → OSC 12 with that hex", () => { + const { m } = makeHwEditor({ cursorMode: "hardware", cursorColor: "#ff5555" }); + assert.ok(m.writes.some((w) => w.includes("\x1b]12;#ff5555\x07")), "OSC 12 override hex"); +}); + +test("hardware mode: 256-color theme → OSC 12 skipped (no exact hex)", () => { + const m = mockTui(); + const blink = new BlinkController(noopScheduler); + const wrapped = { render: () => [""], handleInput: (_d: string) => {} }; + const theme256 = { getFgAnsi: () => "\x1b[38;5;7m", getColorMode: () => "256color" as const }; + const ed = new CursorEditor(m.tui as any, theme256 as any, {} as any, { wrapped: wrapped as any, blink }); + ed.updateConfig({ ...DEFAULT_CONFIG, cursorMode: "hardware" }); + assert.ok(!m.writes.some((w) => w.includes("\x1b]12;")), "OSC 12 skipped in 256 mode"); +}); + +test("fake mode: no DECSCUSR shape set (resets to default) + hw cursor hidden", () => { + const { m } = makeHwEditor({ cursorMode: "fake" }); + assert.ok(m.hw.includes(false), "hardware cursor hidden in fake mode"); + assert.ok(m.writes.some((w) => w.includes("\x1b[0 q")), "DECSCUSR reset to default"); +}); + +test("restoreCursor resets DECSCUSR + OSC 12 + hides hardware cursor", () => { + const { ed, m } = makeHwEditor({ cursorMode: "hardware" }); + m.writes.length = 0; + m.hw.length = 0; + ed.restoreCursor(); + assert.ok(m.hw.includes(false), "hardware cursor hidden on restore"); + assert.ok(m.writes.some((w) => w.includes("\x1b[0 q")), "DECSCUSR reset to default"); + assert.ok(m.writes.some((w) => w.includes("\x1b]112\x07")), "OSC 112 reset cursor color to default"); +}); + +test("hardware mode unfocused: setShowHardwareCursor(false) on focus loss", () => { + const { ed, m } = makeHwEditor({ cursorMode: "hardware" }); + // initial focused → hw on; now lose focus + m.hw.length = 0; + ed.setFocus(false); + assert.ok(m.hw.includes(false), "hardware cursor hidden when unfocused"); + assert.ok(m.writes.some((w) => w.includes("\x1b[0 q")), "DECSCUSR reset to default when unfocused"); +}); + +test("hardware mode refocus: setShowHardwareCursor(true) + DECSCUSR re-emitted", () => { + const { ed, m } = makeHwEditor({ cursorMode: "hardware", focusedStyle: "underline" }); + ed.setFocus(false); // lose + m.hw.length = 0; + m.writes.length = 0; + ed.setFocus(true); // regain + assert.ok(m.hw.includes(true), "hardware cursor shown on refocus"); + assert.ok(m.writes.some((w) => w.includes("\x1b[4 q")), "DECSCUSR underline re-emitted on refocus"); }); \ No newline at end of file diff --git a/tests/entry.test.ts b/tests/entry.test.ts index 13b4e2a..2434cf7 100644 --- a/tests/entry.test.ts +++ b/tests/entry.test.ts @@ -38,6 +38,20 @@ test("provider auto|tmux|herdr|static", () => { assert.throws(() => parseCursorArgs(["provider", "bogus"], DEFAULT_CONFIG)); }); +test("color accent|#hex", () => { + assert.deepEqual(parseCursorArgs(["color", "accent"], DEFAULT_CONFIG), { action: "set", patch: { cursorColor: "accent" } }); + assert.deepEqual(parseCursorArgs(["color", "#cba6f7"], DEFAULT_CONFIG), { action: "set", patch: { cursorColor: "#cba6f7" } }); + assert.throws(() => parseCursorArgs(["color", "#xyz"], DEFAULT_CONFIG), /Usage/); + assert.throws(() => parseCursorArgs(["color"], DEFAULT_CONFIG), /Usage/); +}); + +test("mode fake|hardware", () => { + assert.deepEqual(parseCursorArgs(["mode", "hardware"], DEFAULT_CONFIG), { action: "set", patch: { cursorMode: "hardware" } }); + assert.deepEqual(parseCursorArgs(["mode", "fake"], DEFAULT_CONFIG), { action: "set", patch: { cursorMode: "fake" } }); + assert.throws(() => parseCursorArgs(["mode", "weird"], DEFAULT_CONFIG), /Usage/); + assert.throws(() => parseCursorArgs(["mode"], DEFAULT_CONFIG), /Usage/); +}); + test("status → status", () => { assert.deepEqual(parseCursorArgs(["status"], DEFAULT_CONFIG), { action: "status" }); }); diff --git a/tests/panel.test.ts b/tests/panel.test.ts index 4706f01..67724d9 100644 --- a/tests/panel.test.ts +++ b/tests/panel.test.ts @@ -4,11 +4,16 @@ import { panelRows, applyRowChange, previewLine } from "../lib/panel.ts"; import { CURSOR_MARKER } from "@earendil-works/pi-tui"; import { DEFAULT_CONFIG, type CursorConfig } from "../lib/defaults.ts"; +const THEME = { + getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;5;7m" : c === "dim" ? "\x1b[38;5;8m" : ""), + getColorMode: () => "256color" as const, +}; + test("panel rows cover all config keys in order", () => { const rows = panelRows(DEFAULT_CONFIG, "static"); assert.deepEqual( rows.map((r) => r.id), - ["enabled", "focusedStyle", "unfocusedStyle", "blink", "blinkRate", "focusProvider", "activeProvider"], + ["enabled", "focusedStyle", "unfocusedStyle", "blink", "blinkRate", "focusProvider", "cursorColor", "cursorMode", "activeProvider"], ); }); @@ -49,6 +54,16 @@ test("applyRowChange focusProvider", () => { assert.equal(applyRowChange(DEFAULT_CONFIG, "focusProvider", "tmux").focusProvider, "tmux"); }); +test("applyRowChange cursorMode hardware", () => { + assert.equal(applyRowChange(DEFAULT_CONFIG, "cursorMode", "hardware").cursorMode, "hardware"); +}); + +test("applyRowChange cursorColor accepts hex + accent, rejects junk", () => { + assert.equal(applyRowChange(DEFAULT_CONFIG, "cursorColor", "#cba6f7").cursorColor, "#cba6f7"); + assert.equal(applyRowChange({ ...DEFAULT_CONFIG, cursorColor: "#cba6f7" }, "cursorColor", "accent").cursorColor, "accent"); + assert.equal(applyRowChange(DEFAULT_CONFIG, "cursorColor", "junk").cursorColor, "accent"); +}); + test("activeProvider applyRowChange is a no-op (read-only)", () => { assert.deepEqual(applyRowChange(DEFAULT_CONFIG, "activeProvider", "tmux"), DEFAULT_CONFIG); }); @@ -62,16 +77,16 @@ test("previewLine focused renders the sample with the focused style cursor", () const ul = previewLine(() => ({ ...DEFAULT_CONFIG, focusedStyle: "underline" }), true); assert.deepEqual(ul.render(80), [`const result = await fetch(url);\x1b[4m \x1b[0m`]); // bar → ▎ glyph at line end (no char eaten) - const bar = previewLine(() => ({ ...DEFAULT_CONFIG, focusedStyle: "bar" }), true); + const bar = previewLine(() => ({ ...DEFAULT_CONFIG, focusedStyle: "bar" }), true, undefined, () => THEME); assert.deepEqual(bar.render(80), [`const result = await fetch(url);\x1b[38;5;7m▎\x1b[39m`]); }); test("previewLine unfocused renders the sample with the unfocused style cursor", () => { // hollow (default) → □ sharp hollow block - const hollow = previewLine(() => DEFAULT_CONFIG, false); + const hollow = previewLine(() => DEFAULT_CONFIG, false, undefined, () => THEME); assert.deepEqual(hollow.render(80), [`const result = await fetch(url);\x1b[38;5;8m□\x1b[39m`]); // outline → ▢ rounded hollow square - const outline = previewLine(() => ({ ...DEFAULT_CONFIG, unfocusedStyle: "outline" }), false); + const outline = previewLine(() => ({ ...DEFAULT_CONFIG, unfocusedStyle: "outline" }), false, undefined, () => THEME); assert.deepEqual(outline.render(80), [`const result = await fetch(url);\x1b[38;5;8m▢\x1b[39m`]); // dim const dim = previewLine(() => ({ ...DEFAULT_CONFIG, unfocusedStyle: "dim" }), false); diff --git a/tests/render-helpers.test.ts b/tests/render-helpers.test.ts new file mode 100644 index 0000000..a1be9d1 --- /dev/null +++ b/tests/render-helpers.test.ts @@ -0,0 +1,81 @@ +import { test } from "node:test"; +import assert from "node:assert/strict"; +import { + hexToAnsi, + dimHex, + parseAnsiFgToHex, + decscusr, + osc12, + resolveFocusedColor, + resolveUnfocusedColor, +} from "../lib/render.ts"; +import { DEFAULT_CONFIG } from "../lib/defaults.ts"; + +// Minimal mock Theme matching the real Theme's getFgAnsi/getColorMode surface. +function mockTheme(getFgAnsi: (c: string) => string, mode: "truecolor" | "256color" = "truecolor"): any { + return { getFgAnsi, getColorMode: () => mode }; +} + +test("hexToAnsi emits truecolor fg", () => { + assert.equal(hexToAnsi("#cba6f7"), "\x1b[38;2;203;166;247m"); +}); + +test("hexToAnsi malformed → empty", () => { + assert.equal(hexToAnsi("#xyz"), ""); + assert.equal(hexToAnsi("cba6f7"), ""); +}); + +test("dimHex halves each channel", () => { + assert.equal(dimHex("#cba6f7"), "#65537b"); +}); + +test("parseAnsiFgToHex parses truecolor prefix", () => { + assert.equal(parseAnsiFgToHex("\x1b[38;2;203;166;247m"), "#cba6f7"); +}); + +test("parseAnsiFgToHex returns empty for 256 prefix (inexact)", () => { + assert.equal(parseAnsiFgToHex("\x1b[38;5;7m"), ""); +}); + +test("parseAnsiFgToHex returns empty for non-matching string", () => { + assert.equal(parseAnsiFgToHex("garbage"), ""); +}); + +test("decscusr steady + blink variants", () => { + assert.equal(decscusr("block", false), "\x1b[2 q"); + assert.equal(decscusr("block", true), "\x1b[1 q"); + assert.equal(decscusr("underline", false), "\x1b[4 q"); + assert.equal(decscusr("underline", true), "\x1b[3 q"); + assert.equal(decscusr("bar", false), "\x1b[6 q"); + assert.equal(decscusr("bar", true), "\x1b[5 q"); +}); + +test("osc12 emits cursor-color sequence for hex", () => { + assert.equal(osc12("#cba6f7"), "\x1b]12;#cba6f7\x07"); +}); + +test("osc12 returns empty for empty hex", () => { + assert.equal(osc12(""), ""); +}); + +test("resolveFocusedColor: accent → theme accent ansi", () => { + const theme = mockTheme((c) => (c === "accent" ? "\x1b[38;2;203;166;247m" : "")); + assert.equal(resolveFocusedColor(DEFAULT_CONFIG, theme), "\x1b[38;2;203;166;247m"); +}); + +test("resolveFocusedColor: hex override → hexToAnsi", () => { + const cfg = { ...DEFAULT_CONFIG, cursorColor: "#ff5555" }; + const theme = mockTheme(() => ""); + assert.equal(resolveFocusedColor(cfg, theme), "\x1b[38;2;255;85;85m"); +}); + +test("resolveUnfocusedColor: accent → theme dim ansi", () => { + const theme = mockTheme((c) => (c === "dim" ? "\x1b[38;2;88;91;112m" : "")); + assert.equal(resolveUnfocusedColor(DEFAULT_CONFIG, theme), "\x1b[38;2;88;91;112m"); +}); + +test("resolveUnfocusedColor: hex override → dimmed hex truecolor", () => { + const cfg = { ...DEFAULT_CONFIG, cursorColor: "#cba6f7" }; + const theme = mockTheme(() => ""); + assert.equal(resolveUnfocusedColor(cfg, theme), "\x1b[38;2;101;83;123m"); +}); \ No newline at end of file diff --git a/tests/render.test.ts b/tests/render.test.ts index 5aa6f06..6c044ae 100644 --- a/tests/render.test.ts +++ b/tests/render.test.ts @@ -2,72 +2,79 @@ import { test } from "node:test"; import assert from "node:assert/strict"; import { CURSOR_MARKER } from "@earendil-works/pi-tui"; import { transformFocused, transformUnfocused } from "../lib/render.ts"; -import { DEFAULT_CONFIG } from "../lib/defaults.ts"; +import { DEFAULT_CONFIG, type CursorConfig } from "../lib/defaults.ts"; + +// Mock theme returning the v0.1 256-color codes so the existing assertions +// (which encode 38;5;7 / 38;5;8) stay valid under the theme-threaded signature. +const THEME = { + getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;5;7m" : c === "dim" ? "\x1b[38;5;8m" : ""), + getColorMode: () => "256color" as const, +}; const cell = (ch: string) => `${CURSOR_MARKER}\x1b[7m${ch}\x1b[0m`; const line = (ch: string) => `const x = ${cell(ch)}await;`; test("focused block = passthrough (no transform)", () => { const l = line("f"); - assert.deepEqual(transformFocused([l], { ...DEFAULT_CONFIG, focusedStyle: "block" }, true), [l]); + assert.deepEqual(transformFocused([l], { ...DEFAULT_CONFIG, focusedStyle: "block" }, THEME, true), [l]); }); test("focused underline = underline SGR on char", () => { assert.deepEqual( - transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "underline" }, true), + transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "underline" }, THEME, true), [`const x = \x1b[4mf\x1b[0mawait;`], ); }); test("focused bar = ▎ glyph replacing cell, char hidden", () => { assert.deepEqual( - transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "bar" }, true), + transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "bar" }, THEME, true), [`const x = \x1b[38;5;7m▎\x1b[39mawait;`], ); }); test("focused blinkVisible=false → hide regardless of style", () => { assert.deepEqual( - transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "block" }, false), + transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "block" }, THEME, false), [`const x = fawait;`], ); assert.deepEqual( - transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "bar" }, false), + transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "bar" }, THEME, false), [`const x = fawait;`], ); }); test("unfocused dim = dim+reverse", () => { assert.deepEqual( - transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "dim" }), + transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "dim" }, THEME), [`const x = \x1b[2;7mf\x1b[0mawait;`], ); }); test("unfocused hollow = □ sharp hollow block, char hidden", () => { assert.deepEqual( - transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "hollow" }), + transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "hollow" }, THEME), [`const x = \x1b[38;5;8m□\x1b[39mawait;`], ); }); test("unfocused outline = ▢ rounded hollow square, char hidden", () => { assert.deepEqual( - transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "outline" }), + transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "outline" }, THEME), [`const x = \x1b[38;5;8m▢\x1b[39mawait;`], ); }); test("unfocused underline = dim underline", () => { assert.deepEqual( - transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "underline" }), + transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "underline" }, THEME), [`const x = \x1b[4;2mf\x1b[0mawait;`], ); }); test("unfocused hide = strip cursor", () => { assert.deepEqual( - transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "hide" }), + transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "hide" }, THEME), [`const x = fawait;`], ); }); @@ -77,11 +84,71 @@ test("multi-line: only the line with the cursor is transformed", () => { const b = line("f"); const c = "line three no cursor"; assert.deepEqual( - transformUnfocused([a, b, c], { ...DEFAULT_CONFIG, unfocusedStyle: "hide" }), + transformUnfocused([a, b, c], { ...DEFAULT_CONFIG, unfocusedStyle: "hide" }, THEME), [a, "const x = fawait;", c], ); }); test("no cursor in any line = passthrough", () => { - assert.deepEqual(transformFocused(["nothing here"], DEFAULT_CONFIG, true), ["nothing here"]); + assert.deepEqual(transformFocused(["nothing here"], DEFAULT_CONFIG, THEME, true), ["nothing here"]); +}); + +// --- v0.2.0 (A): truecolor via theme.getFgAnsi replaces the v0.1 256-color codes --- +const TRUECOLOR_THEME = { + getFgAnsi: (c: string) => (c === "accent" ? "\x1b[38;2;203;166;247m" : c === "dim" ? "\x1b[38;2;88;91;112m" : ""), + getColorMode: () => "truecolor" as const, +}; + +test("focused bar uses theme accent truecolor (not 256-color 7)", () => { + const out = transformFocused([line("f")], { ...DEFAULT_CONFIG, focusedStyle: "bar" }, TRUECOLOR_THEME, true, "fake"); + assert.ok(out[0]!.includes("\x1b[38;2;203;166;247m"), "bar uses accent truecolor"); + assert.ok(!out[0]!.includes("\x1b[38;5;7m"), "no 256-color 7"); +}); + +test("unfocused hollow uses theme dim truecolor (not 256-color 8)", () => { + const out = transformUnfocused([line("f")], { ...DEFAULT_CONFIG, unfocusedStyle: "hollow" }, TRUECOLOR_THEME); + assert.ok(out[0]!.includes("\x1b[38;2;88;91;112m"), "hollow uses dim truecolor"); + assert.ok(!out[0]!.includes("\x1b[38;5;8m"), "no 256-color 8"); +}); + +test("hardware focused: bare char (marker kept, reverse-video dropped)", () => { + const out = transformFocused([line("f")], { ...DEFAULT_CONFIG, cursorMode: "hardware" }, TRUECOLOR_THEME, true, "hardware"); + // char preserved, no reverse-video cell, marker dropped by rewriteCursorCell + assert.ok(out[0]!.includes("fawait;"), "bare char preserved"); + assert.ok(!out[0]!.includes("\x1b[7m"), "no reverse-video"); +}); + +// --- v0.2.0 (A): cursorColor hex override --- +test("cursorColor hex override flows to focused bar", () => { + const theme = { getFgAnsi: () => "\x1b[38;2;0;0;0m", getColorMode: () => "truecolor" as const }; + const cfg: CursorConfig = { ...DEFAULT_CONFIG, focusedStyle: "bar", cursorColor: "#ff5555" }; + const out = transformFocused([line("f")], cfg, theme as any, true, "fake"); + assert.ok(out[0]!.includes("\x1b[38;2;255;85;85m"), "hex override used"); +}); + +test("cursorColor hex override flows to unfocused hollow (dimmed)", () => { + const theme = { getFgAnsi: () => "\x1b[38;2;0;0;0m", getColorMode: () => "truecolor" as const }; + const cfg: CursorConfig = { ...DEFAULT_CONFIG, unfocusedStyle: "hollow", cursorColor: "#cba6f7" }; + const out = transformUnfocused([line("f")], cfg, theme as any); + assert.ok(out[0]!.includes("\x1b[38;2;101;83;123m"), "dimmed hex used (#cba6f7 → 101;83;123)"); +}); + +// --- v0.2.0 (B): highlight unfocused style (char-preserving undercurl) --- +test("unfocused highlight: truecolor undercurl + colored underline, char preserved", () => { + const theme = { getFgAnsi: (c: string) => (c === "dim" ? "\x1b[38;2;88;91;112m" : ""), getColorMode: () => "truecolor" as const }; + const cfg: CursorConfig = { ...DEFAULT_CONFIG, unfocusedStyle: "highlight" }; + const out = transformUnfocused([line("f")], cfg, theme as any); + assert.ok(out[0]!.includes("f"), "char preserved"); + assert.ok(out[0]!.includes("\x1b[4:3m"), "undercurl"); + assert.ok(out[0]!.includes("\x1b[58:2::88:91:112m"), "colored underline (RGB from dim)"); +}); + +test("unfocused highlight: 256-color fallback = plain colored underline, char preserved", () => { + const theme = { getFgAnsi: (c: string) => (c === "dim" ? "\x1b[38;5;8m" : ""), getColorMode: () => "256color" as const }; + const cfg: CursorConfig = { ...DEFAULT_CONFIG, unfocusedStyle: "highlight" }; + const out = transformUnfocused([line("f")], cfg, theme as any); + assert.ok(out[0]!.includes("f"), "char preserved"); + assert.ok(out[0]!.includes("\x1b[4m"), "plain underline"); + assert.ok(!out[0]!.includes("\x1b[4:3m"), "no undercurl in 256 mode"); + assert.ok(out[0]!.includes("\x1b[38;5;8m"), "256 fg color"); }); \ No newline at end of file