diff --git a/README.md b/README.md index 9c8e898..88ec31c 100644 --- a/README.md +++ b/README.md @@ -4,10 +4,11 @@ Focused, independently installable plugins for [Herdr](https://herdr.dev/). ## Plugin catalogue -| Plugin | Description | Install | -| -------------------------------------------- | -------------------------------------------------------------- | -------------------------------------------------------------------- | -| [Tab Numbers](plugins/tab-numbers) | Adds each named tab's contiguous position to its label. | `herdr plugin install oullin/herdr-plugins/plugins/tab-numbers` | -| [Tmux Keybindings](plugins/tmux-keybindings) | Applies tmux-style bindings with a toggleable reference panel. | `herdr plugin install oullin/herdr-plugins/plugins/tmux-keybindings` | +| Plugin | Description | Install | +| ------------------------------------------------------ | ---------------------------------------------------------- | ------------------------------------------------------------------------- | +| [Tab Numbers](plugins/tab-numbers) | Adds each named tab's contiguous position to its label. | `herdr plugin install oullin/herdr-plugins/plugins/tab-numbers` | +| [Tmux Keybindings](plugins/tmux-keybindings) | Applies tmux-style bindings with a modal reference dialog. | `herdr plugin install oullin/herdr-plugins/plugins/tmux-keybindings` | +| [Pane Navigation Hints](plugins/pane-navigation-hints) | Shows live pane navigation shortcuts in every pane border. | `herdr plugin install oullin/herdr-plugins/plugins/pane-navigation-hints` | Herdr installs plugins from repository subdirectories, so this repository has one marketplace listing while each plugin remains independently installable. diff --git a/package/core/src/herdr-cli/client/index.ts b/package/core/src/herdr-cli/client/index.ts index e109b89..3f6ec75 100644 --- a/package/core/src/herdr-cli/client/index.ts +++ b/package/core/src/herdr-cli/client/index.ts @@ -4,7 +4,7 @@ import { HerdrPaneClient } from '#herdr-plugin-core/herdr-cli/panes'; import { HerdrTabClient } from '#herdr-plugin-core/herdr-cli/tabs'; import { HerdrCliTransport } from '#herdr-plugin-core/herdr-cli/transport'; import { HerdrWorkspaceClient } from '#herdr-plugin-core/herdr-cli/workspaces'; -import type { Environment, JsonObject, Pane, PluginPaneOptions, Tab, Workspace } from '#herdr-plugin-core/models'; +import type { Environment, JsonObject, Pane, PaneTitleUpdate, PluginPaneOptions, Tab, Workspace } from '#herdr-plugin-core/models'; import type { HerdrClient } from '#herdr-plugin-core/ports'; export class HerdrCliClient implements HerdrClient { @@ -54,10 +54,18 @@ export class HerdrCliClient implements HerdrClient { this.config.reloadConfig(); } + listPanes(workspaceId?: string): readonly Pane[] { + return this.panes.listPanes(workspaceId); + } + getPane(paneId: string): Pane | undefined { return this.panes.getPane(paneId); } + reportPaneTitle(paneId: string, update: PaneTitleUpdate): void { + this.panes.reportPaneTitle(paneId, update); + } + openPluginPane(options: PluginPaneOptions): Pane { return this.panes.openPluginPane(options); } diff --git a/package/core/src/herdr-cli/panes/index.ts b/package/core/src/herdr-cli/panes/index.ts index 3c0b785..96bd7fa 100644 --- a/package/core/src/herdr-cli/panes/index.ts +++ b/package/core/src/herdr-cli/panes/index.ts @@ -1,7 +1,7 @@ import { HerdrCommandError } from '#herdr-plugin-core/errors'; import type { HerdrCliTransport } from '#herdr-plugin-core/herdr-cli/transport'; import { isJsonObject } from '#herdr-plugin-core/json'; -import type { Pane, PluginPaneOptions } from '#herdr-plugin-core/models'; +import type { Pane, PaneTitleUpdate, PluginPaneOptions } from '#herdr-plugin-core/models'; import type { PaneClient } from '#herdr-plugin-core/ports'; export class HerdrPaneClient implements PaneClient { @@ -11,15 +11,34 @@ export class HerdrPaneClient implements PaneClient { this.transport = transport; } + listPanes(workspaceId?: string): readonly Pane[] { + const panes = this.transport.call(['pane', 'list', ...(workspaceId === undefined ? [] : ['--workspace', workspaceId])])['panes']; + + if (!Array.isArray(panes)) { + throw new HerdrCommandError('Herdr did not return a pane list'); + } + + return panes.map((pane) => this.parse(pane)); + } + getPane(paneId: string): Pane | undefined { const args = ['pane', 'get', paneId]; - const result = this.transport.execute(args); - if (result.error || result.status !== 0) { - return undefined; + try { + return this.parse(this.transport.call(args)['pane']); + } catch (error) { + const commandError = error as HerdrCommandError; + + if (commandError?.code === 'pane_not_found') { + return undefined; + } + + throw error; } + } - return this.parse(this.transport.decodeResult(result.stdout, args)['pane']); + reportPaneTitle(paneId: string, update: PaneTitleUpdate): void { + this.transport.run(['pane', 'report-metadata', paneId, '--source', update.source, ...('title' in update ? ['--title', update.title] : ['--clear-title'])]); } openPluginPane(options: PluginPaneOptions): Pane { diff --git a/package/core/src/index.ts b/package/core/src/index.ts index 9f52df1..3e52fee 100644 --- a/package/core/src/index.ts +++ b/package/core/src/index.ts @@ -3,7 +3,7 @@ export { HerdrCommandError, type HerdrErrorOptions, PluginError } from '#herdr-p export { executePlugin, type PluginExecutionOptions } from '#herdr-plugin-core/execution'; export { type HerdrApiError, HerdrCliClient, HerdrCliTransport, HerdrConfigClient, HerdrPaneClient, HerdrResponseDecoder, HerdrTabClient, HerdrWorkspaceClient } from '#herdr-plugin-core/herdr-cli'; export { isJsonObject } from '#herdr-plugin-core/json'; -export type { Environment, JsonObject, Pane, PluginPaneOptions, Tab, Workspace } from '#herdr-plugin-core/models'; +export type { Environment, JsonObject, Pane, PaneTitleUpdate, PluginPaneOptions, Tab, Workspace } from '#herdr-plugin-core/models'; export { HerdrConfigPathResolver, PluginStateDirectoryResolver } from '#herdr-plugin-core/path-resolvers'; export { AtomicFileStore, JsonFileStore } from '#herdr-plugin-core/persistence'; export { PluginContext } from '#herdr-plugin-core/plugin-context'; diff --git a/package/core/src/models.ts b/package/core/src/models.ts index 057a400..49a03df 100644 --- a/package/core/src/models.ts +++ b/package/core/src/models.ts @@ -19,6 +19,18 @@ export interface Pane { readonly workspace_id: string; } +export type PaneTitleUpdate = + | { + readonly source: string; + readonly title: string; + readonly clearTitle?: never; + } + | { + readonly source: string; + readonly title?: never; + readonly clearTitle: true; + }; + export interface PluginPaneOptions { readonly pluginId: string; readonly entrypoint: string; diff --git a/package/core/src/ports.ts b/package/core/src/ports.ts index f593d05..dc06456 100644 --- a/package/core/src/ports.ts +++ b/package/core/src/ports.ts @@ -1,4 +1,4 @@ -import type { Pane, PluginPaneOptions, Tab, Workspace } from '#herdr-plugin-core/models'; +import type { Pane, PaneTitleUpdate, PluginPaneOptions, Tab, Workspace } from '#herdr-plugin-core/models'; export interface WorkspaceClient { listWorkspaces(): readonly Workspace[]; @@ -16,7 +16,9 @@ export interface ConfigClient { } export interface PaneClient { + listPanes(workspaceId?: string): readonly Pane[]; getPane(paneId: string): Pane | undefined; + reportPaneTitle(paneId: string, update: PaneTitleUpdate): void; openPluginPane(options: PluginPaneOptions): Pane; closePluginPane(paneId: string): boolean; } diff --git a/package/core/tests/herdr-cli-client.test.ts b/package/core/tests/herdr-cli-client.test.ts index 18d1b18..4589e94 100644 --- a/package/core/tests/herdr-cli-client.test.ts +++ b/package/core/tests/herdr-cli-client.test.ts @@ -74,6 +74,122 @@ describe('HerdrCliClient', () => { ]); }); + it('lists panes and owns source-scoped pane title metadata', () => { + const runner = new StubCommandRunner( + { + error: undefined, + status: 0, + stdout: JSON.stringify({ result: { panes: [{ pane_id: 'p1', tab_id: 't1', workspace_id: 'w1' }] } }), + stderr: '', + }, + { + error: undefined, + status: 0, + stdout: '', + stderr: '', + }, + { + error: undefined, + status: 0, + stdout: '', + stderr: '', + }, + ); + + const client = new HerdrCliClient('/mock/herdr', runner); + + expect( + client.listPanes('w1'), + ).toEqual([{ pane_id: 'p1', tab_id: 't1', workspace_id: 'w1' }]); + + client.reportPaneTitle('p1', { source: 'example.hints', title: 'Ctrl+B arrows' }); + client.reportPaneTitle('p1', { source: 'example.hints', clearTitle: true }); + + expect( + runner.calls.map(({ args }) => args), + ).toEqual([ + ['pane', 'list', '--workspace', 'w1'], + ['pane', 'report-metadata', 'p1', '--source', 'example.hints', '--title', 'Ctrl+B arrows'], + ['pane', 'report-metadata', 'p1', '--source', 'example.hints', '--clear-title'], + ]); + }); + + it('gets a pane through the shared transport', () => { + const runner = new StubCommandRunner({ + error: undefined, + status: 0, + stdout: JSON.stringify({ result: { pane: { pane_id: 'p1', tab_id: 't1', workspace_id: 'w1' } } }), + stderr: '', + }); + + const client = new HerdrCliClient('/mock/herdr', runner); + + expect( + client.getPane('p1'), + ).toEqual({ pane_id: 'p1', tab_id: 't1', workspace_id: 'w1' }); + expect(runner.calls[0]?.args).toEqual(['pane', 'get', 'p1']); + }); + + it('returns undefined when a pane is not found', () => { + const runner = new StubCommandRunner({ + error: undefined, + status: 1, + stdout: '', + stderr: JSON.stringify({ error: { code: 'pane_not_found', message: 'missing pane' } }), + }); + + const client = new HerdrCliClient('/mock/herdr', runner); + + expect( + client.getPane('missing'), + ).toBeUndefined(); + expect(runner.calls[0]?.args).toEqual(['pane', 'get', 'missing']); + }); + + it('preserves structured errors from pane lookups', () => { + const runner = new StubCommandRunner({ + error: undefined, + status: 1, + stdout: '', + stderr: JSON.stringify({ error: { code: 'server_unavailable', message: 'server unavailable' } }), + }); + + const client = new HerdrCliClient('/mock/herdr', runner); + + try { + client.getPane('p1'); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(HerdrCommandError); + expect((error as HerdrCommandError).code).toBe('server_unavailable'); + } + + expect(runner.calls[0]?.args).toEqual(['pane', 'get', 'p1']); + }); + + it('preserves command-runner errors from pane lookups', () => { + const processError = new Error('spawn failed'); + + const runner = new StubCommandRunner({ + error: processError, + status: null, + stdout: '', + stderr: '', + }); + + const client = new HerdrCliClient('/mock/herdr', runner); + + try { + client.getPane('p1'); + expect.unreachable(); + } catch (error) { + expect(error).toBeInstanceOf(HerdrCommandError); + expect((error as HerdrCommandError).cause).toBe(processError); + } + + expect(runner.calls[0]?.args).toEqual(['pane', 'get', 'p1']); + }); + it('preserves structured Herdr error codes', () => { const runner = new StubCommandRunner({ error: undefined, diff --git a/plans/001-preserve-pane-lookup-failures.md b/plans/001-preserve-pane-lookup-failures.md new file mode 100644 index 0000000..11f8310 --- /dev/null +++ b/plans/001-preserve-pane-lookup-failures.md @@ -0,0 +1,206 @@ +# Plan 001: Preserve real pane lookup failures + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving on. If a +> STOP condition occurs, stop and report; do not improvise. Commit the work in +> the assigned Ollin worktree. Skip every instruction to update +> `plans/README.md`; the parent reviewer owns Improve artifacts. +> +> **Drift check (run first)**: +> `git diff --stat 41f59cd..HEAD -- packages/plugin-core/src/herdr-cli/panes/index.ts packages/plugin-core/tests/herdr-cli-client.test.ts` +> If either file changed, compare the excerpts below with the live code. Stop +> on a material mismatch. + +## Status + +- **Priority**: P1 +- **Effort**: S +- **Risk**: LOW +- **Depends on**: none +- **Category**: bug +- **Planned at**: commit `41f59cd`, 2026-07-21 +- **Ollin delivery**: PR READY +- **Worktree**: `/Users/gocanto/.codex/worktrees/codex/herdr-plugin-plan-001-pane-lookup-errors` +- **Branch**: `chore/ollin-pane-lookup-errors` +- **Routing profile**: `gpt-5.6-sol@high=100%` +- **Effective routing**: `gpt-5.6-sol@high=100%` +- **Model**: `gpt-5.6-sol` +- **Level**: `high` +- **Harness**: authenticated Codex CLI (`codex`) +- **Approved commit**: `9eb32035267c834e1d257746907f343cabdf6743` +- **Review correction commit**: `5a1cf20c0c1a51fc857586acef9b181cedb74723` +- **Draft PR**: `https://github.com/oullin/herdr-plugins/pull/6` +- **PR head**: `5a1cf20c0c1a51fc857586acef9b181cedb74723` +- **PR state**: ready for review; CI passed; one review thread addressed and + resolved; no unresolved threads +- **Automated review note**: Gemini reviewed the original head and its only + finding was corrected on the current head; the integration's own notice says + the consumer review service ended on 2026-07-17, so no current-head rerun is + available or treated as a remaining gate +- **Parent review**: APPROVE; focused tests 8/8, full suite 92/92, + formatting, linting, typechecking, audit, diff hygiene, scope, and forbidden + additions checks passed + +## Why this matters + +`HerdrPaneClient.getPane()` currently maps every failed process invocation and +every non-zero Herdr response to `undefined`. Callers therefore cannot +distinguish a genuinely missing pane from a disconnected Herdr server, a +permission problem, or another API failure. Preserve the useful optional +lookup contract for `pane_not_found`, but let operational and decoding failures +remain observable. + +## Current state + +- `packages/plugin-core/src/herdr-cli/panes/index.ts:24-32` bypasses the normal + transport error decoder: + + ```ts + getPane(paneId: string): Pane | undefined { + const args = ['pane', 'get', paneId]; + const result = this.transport.execute(args); + + if (result.error || result.status !== 0) { + return undefined; + } + + return this.parse(this.transport.decodeResult(result.stdout, args)['pane']); + } + ``` + +- `HerdrCliTransport.call()` already converts structured error responses into + `HerdrCommandError` and preserves their `code` field. Use that existing seam; + do not duplicate response decoding. +- `packages/plugin-core/tests/herdr-cli-client.test.ts` uses + `StubCommandRunner` and direct argument assertions. Add tests there using the + same style and real structured Herdr response envelopes. +- The installed Herdr 0.7.4 CLI reports a missing pane with error code + `pane_not_found`. +- The transport boundary is known to throw `HerdrCommandError`. Inside the + narrow `getPane()` catch, use a local type assertion to that existing class + and inspect its `code`; do not add `instanceof`, `typeof`, reflection, a new + interface, an adapter, or a new DTO for a value that is not transported. + +## Commands you will need + +| Purpose | Command | Expected on success | +| ------------------------- | ------------------------------------------------------------- | ----------------------------------- | +| Install in fresh worktree | `vp install --frozen-lockfile` | exit 0 | +| Focused tests | `vp test packages/plugin-core/tests/herdr-cli-client.test.ts` | all focused tests pass | +| Format | `make format-all` | exit 0, no formatter or lint errors | +| Full verification | `vp run ready` | check and complete test suite pass | +| Dependency audit | `pnpm audit --audit-level high` | no high or critical vulnerability | +| Diff hygiene | `git diff --check` | no output, exit 0 | + +## Scope + +**In scope**: + +- `packages/plugin-core/src/herdr-cli/panes/index.ts` +- `packages/plugin-core/tests/herdr-cli-client.test.ts` + +**Out of scope**: + +- Public method signatures or package exports +- `closePluginPane()` and its boolean result +- Other Herdr clients or a repository-wide error-model rewrite +- New interfaces, adapters, DTOs, reflection, `typeof`, `instanceof`, or + `Record` types +- Improve plan and index files + +## Git workflow + +- Assigned branch: `chore/ollin-pane-lookup-errors` +- Base: `chore/pane-navigation-hints` +- Create one logical commit with subject `Preserve pane lookup failures`. +- Do not push, open a PR, merge, or edit Improve artifacts. + +## Steps + +### Step 1: Characterise the lookup contract + +Extend `packages/plugin-core/tests/herdr-cli-client.test.ts` with direct tests +that prove: + +1. A successful `pane get` response returns the parsed pane. +2. A structured `pane_not_found` error returns `undefined`. +3. A different structured Herdr error is rethrown with its code intact. +4. A command-runner process error is rethrown rather than hidden. + +Use `StubCommandRunner`; do not mock modules or call a live Herdr server. + +**Verify**: +`vp test packages/plugin-core/tests/herdr-cli-client.test.ts` must fail only on +the newly asserted error-semantics cases before implementation. + +### Step 2: Route lookup through the normal decoder + +Change `HerdrPaneClient.getPane()` to call the transport's decoded JSON path. +The transport contract already normalises failures to `HerdrCommandError`, so +inside the narrow catch use a local assertion to that existing class and +translate only `code === 'pane_not_found'` into `undefined`; rethrow the +original caught value for every other code. Pass the returned `pane` value +through the existing private `parse()` method so malformed success payloads +still fail loudly. + +Do not add a new error class, DTO, interface, adapter, `typeof`, `instanceof`, +reflection, `Record`, change `PaneClient`, or weaken parsing. + +**Verify**: +`vp test packages/plugin-core/tests/herdr-cli-client.test.ts` passes all focused +tests. + +### Step 3: Format and prove repository compatibility + +Run the formatter and all repository gates. Review the branch diff and ensure +only the two in-scope files changed. + +**Verify**: + +- `make format-all` exits 0. +- `vp run ready` exits 0. +- `pnpm audit --audit-level high` reports no high or critical vulnerability. +- `git diff --check` exits 0 with no output. +- `git diff --name-only chore/pane-navigation-hints...HEAD` lists only the two + in-scope files. +- `git diff -U0 chore/pane-navigation-hints...HEAD | rg '^\+.*\b(typeof|instanceof|Record|interface)\b|^\+.*\bReflect\.|^\+.*\bObject\.(keys|values|entries)\b'` + returns no matches. + +## Test plan + +- Keep the existing shared-client tests intact. +- Add one happy-path pane lookup and the three failure-path cases named above. +- Assert both behavior and the command arguments sent to Herdr where useful. +- Do not test private methods or duplicate transport decoder tests. + +## Done criteria + +- [ ] `getPane()` returns `undefined` only for `pane_not_found`. +- [ ] Other structured Herdr errors retain their code and are thrown. +- [ ] Process errors and malformed success payloads are observable failures. +- [ ] The public `Pane | undefined` signature and exports are unchanged. +- [ ] No new interface, adapter, DTO, `typeof`, `instanceof`, reflection, or + `Record` usage is introduced. +- [ ] Focused tests and `vp run ready` pass. +- [ ] `pnpm audit --audit-level high` and `git diff --check` pass. +- [ ] Only the two in-scope files are changed. +- [ ] The executor creates the required local commit. + +## STOP conditions + +Stop and report if: + +- The live method no longer matches the current-state excerpt. +- Herdr's structured missing-pane code is not `pane_not_found`. +- Correct behavior requires changing the public `PaneClient` interface. +- Correct behavior cannot be expressed through the existing concrete + `HerdrCommandError` contract without new reflection or an unsafe broad cast. +- A required fix touches any out-of-scope file. +- A verification failure remains after two bounded correction attempts. + +## Maintenance notes + +- Reviewers should confirm that the catch is code-specific and does not reduce + all `HerdrCommandError` instances to absence. +- Future optional lookup methods should follow the same pattern: translate only + the explicit not-found code and preserve operational failures. diff --git a/plans/002-restyle-and-harden-tmux-help-dialog.md b/plans/002-restyle-and-harden-tmux-help-dialog.md new file mode 100644 index 0000000..ad9386b --- /dev/null +++ b/plans/002-restyle-and-harden-tmux-help-dialog.md @@ -0,0 +1,309 @@ +# Plan 002: Restyle and harden the tmux help dialog + +> **Executor instructions**: Follow this plan step by step. Run every +> verification command and confirm the expected result before moving on. If a +> STOP condition occurs, stop and report; do not improvise. Commit the work in +> the assigned Ollin worktree. Skip every instruction to update +> `plans/README.md`; the parent reviewer owns Improve artifacts. +> +> **Required coding standard**: Before modifying source or tests, read +> `/Users/gocanto/Sites/skills/.agents/skills/typescript-coding-standards/SKILL.md` +> completely. Preserve this repository's class-based, concern-separated, +> dependency-free renderer design. The user constraints below override any +> generic recommendation to add interfaces, adapters, reflection, `typeof`, +> `instanceof`, or `Record` types. +> +> **Drift check (run first)**: +> `git diff --stat 41f59cd..HEAD -- plugins/tmux-keybindings/herdr-plugin.toml plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts plugins/tmux-keybindings/tests/support/terminal-output.ts` +> Stop on a material mismatch with the current-state excerpts or contracts. + +## Status + +- **Priority**: P1 +- **Effort**: M +- **Risk**: MED +- **Depends on**: none; Ollin stacks it after Plan 001 operationally +- **Category**: bug +- **Planned at**: commit `41f59cd`, 2026-07-21 +- **Ollin delivery**: PR READY +- **Worktree**: `/Users/gocanto/.codex/worktrees/codex/herdr-plugin-plan-002-tmux-help-dialog` +- **Branch**: `chore/ollin-tmux-help-dialog` +- **Routing profile**: `gpt-5.6-sol@high=100%` +- **Effective routing**: `gpt-5.6-sol@high=100%` +- **Model**: `gpt-5.6-sol` +- **Level**: `high` +- **Harness**: authenticated Codex CLI (`codex`) +- **Approved commit**: `65b98fca0610ed99460d291663ed7106275699e6` +- **Draft PR**: `https://github.com/oullin/herdr-plugins/pull/7` +- **PR head**: `65b98fca0610ed99460d291663ed7106275699e6` +- **PR state**: ready for review; current-head CI passed; no review threads, + top-level comments, or requested changes +- **Automated review note**: the discontinued Gemini consumer integration did + not run after the ready transition; parent review plus two independent + `gpt-5.6-sol` high-reasoning reviews approved the current head +- **Parent review**: APPROVE; focused tests 8/8, full suite 92/92, + formatting, linting, typechecking, audit, render geometry, strict input, + diff hygiene, exact scope, and architecture policy checks passed; two + independent read-only reviews approved architecture and behavior + +## Why this matters + +The tmux help popup is functional but visually disconnected from the supplied +Herdr help-dialog reference. Its two-column, unstyled output also becomes hard +to scan at the target size. Separately, its close detector accepts any byte +sequence containing Escape, so ordinary terminal arrow keys and unrelated Alt +keys close the dialog. Deliver the requested visual hierarchy while making the +documented close behavior precise. + +## Current state + +- `plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts:8-23` + builds a centred plain-text header and chooses a two-column layout at widths + of 72 or more. It has no ANSI palette or fixed footer. +- `plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts:5-12` + scans every byte and returns `true` when any byte equals Escape or Ctrl+C. + Arrow keys begin with Escape, so they are incorrectly accepted. +- `plugins/tmux-keybindings/herdr-plugin.toml:45-50` declares an `84` by `24` + popup. The single-column target needs 30 rows to show all existing groups. +- `PANEL_GROUPS` in + `plugins/tmux-keybindings/src/domain/keybinding-profile.ts` remains the + authoritative command catalogue. Do not edit or duplicate it. +- Existing renderer and shortcut tests under `tests/presentation/` are direct, + behavior-focused Vite+ tests. Extend them without snapshots or module mocks. + +## Visual contract + +Use this exact palette: + +| Role | Colour | +| ---------------------- | --------- | +| Background | `#1a1b26` | +| Primary text | `#c0caf5` | +| Muted text | `#737aa2` | +| Section heading | `#7dcfff` | +| Shortcut | `#bb9af7` | +| Close badge background | `#7aa2f7` | +| Close badge text | `#1a1b26` | + +At `84x30`, render this hierarchy without overflow: + +1. Lowercase `tmux keybindings` title at the left. +2. A right-aligned blue `esc close` badge on the title row. +3. Muted subtitle: `available commands and configured shortcuts`. +4. Lowercase `global`, `tabs`, and `panes` headings in cyan. +5. Every existing shortcut in magenta, aligned to a stable left column, with + its existing description in primary text. +6. A final muted footer that truthfully advertises + `toggle Option+Command+T` and `close Esc` only. + +The renderer must apply the background across each complete visible row, +including padding, and reset ANSI state at every row boundary. Compute visible +width, padding, and truncation from unstyled Unicode code points before adding +ANSI sequences. On insufficient height, reserve the footer and replace the last +available content row with a muted `... resize dialog to see all bindings` +message. Do not claim scrolling support. + +## Object design constraints + +- `BindingsPanelRenderer` remains the orchestration class. It owns dialog + composition but delegates palette data and terminal-line mechanics so it does + not become a god class. +- Add `DialogPalette` as an immutable class DTO with explicit colour properties, + a private constructor, and one named static factory for the tmux palette. Do + not use `Record` or dynamic keys. +- Add `StyledSegment` as an immutable class DTO/value object for text plus its + explicit foreground, background, and emphasis values. One exported class, + one file. +- Add `TerminalLine` as the focused value object responsible for code-point + width, truncation, padding, ANSI rendering, background fill, and reset. It + consumes concrete `StyledSegment` instances and contains no reflection. +- Add `TerminalOutput` under test support as an immutable class DTO/value object + that strips ANSI and exposes rows/visible widths for assertions. Do not add + free test helper functions. +- Use concrete classes directly. Do not create interfaces, ports, adapters, + service locators, dependency containers, or inheritance hierarchies. +- Keep one exported class per file. No newly added line may use `typeof`, + `instanceof`, `Record`, `Reflect`, `Object.keys`, `Object.values`, or + `Object.entries`. + +## Commands you will need + +| Purpose | Command | Expected on success | +| ------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------- | +| Install in fresh worktree | `vp install --frozen-lockfile` | exit 0 | +| Focused tests | `vp test plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts` | all focused tests pass | +| Render smoke | `node --experimental-strip-types --input-type=module -e "import { BindingsPanelRenderer } from './plugins/tmux-keybindings/index.ts'; process.stdout.write(new BindingsPanelRenderer().render(84, 30))"` | styled 84x30 dialog output | +| Format | `make format-all` | exit 0, no formatter or lint errors | +| Full verification | `vp run ready` | check and complete test suite pass | +| Dependency audit | `pnpm audit --audit-level high` | no high or critical vulnerability | +| Diff hygiene | `git diff --check` | no output, exit 0 | + +## Scope + +**In scope**: + +- `plugins/tmux-keybindings/herdr-plugin.toml` +- `plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts` +- `plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts` +- `plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts` (new) +- `plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts` (new) +- `plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts` (new) +- `plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts` +- `plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts` +- `plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts` +- `plugins/tmux-keybindings/tests/support/terminal-output.ts` (new) + +**Out of scope**: + +- `PANEL_GROUPS`, keybinding assignments, action IDs, and descriptions +- `BindingsPanelApplication` lifecycle behavior +- New scrolling, mouse, Enter-to-close, or navigation interactions +- New runtime or development dependencies +- New interfaces, adapters, free helper functions, reflection, `typeof`, + `instanceof`, or `Record` types +- Plugin versions, installation behavior, and other plugins +- Improve plan and index files + +## Git workflow + +- Assigned branch: `chore/ollin-tmux-help-dialog` +- Base: the Plan 001 branch after its PR reaches `PR READY` +- Create one logical commit with subject `Restyle tmux help dialog`. +- Do not push, open a PR, merge, or edit Improve artifacts. + +## Steps + +### Step 1: Lock the visual and input regressions in tests + +Add the focused `TerminalOutput` immutable test DTO and extend the renderer test +without free helper functions. Assert that: + +- the title, subtitle, badge, headings, every `PANEL_GROUPS` shortcut and every + description appear at `84x30`; +- the required true-colour ANSI sequences are present; +- stripping ANSI produces exactly 30 rows, each no wider than 84 code points; +- the badge is right-aligned on the first stripped row; +- a short height preserves the footer and displays the resize message; +- every styled row ends with a reset sequence. + +Extend the shortcut test to accept standalone Escape, standalone Ctrl+C, and +exact bytes `[0x1b, 0x74]`, while rejecting arrow sequences such as +`[0x1b, 0x5b, 0x41]` and unrelated Alt sequences such as `[0x1b, 0x78]`. + +Update the architecture contract's expected popup height from 24 to 30. + +**Verify**: the focused test command fails only on the new visual, height, and +input expectations before implementation. + +### Step 2: Implement the single-column styled renderer + +Keep the public `BindingsPanelRenderer` constructor and +`render(width, height): string` method unchanged. Split the new implementation +across the three concrete production classes named in Object design constraints. +Each class owns one concern and lives in its own file; no class may simply +forward another method or exist only to imitate a port/adapter convention. + +Build rows from immutable `StyledSegment` DTOs, let `TerminalLine` truncate and +pad visible text, then apply the exact `DialogPalette`. Retain a minimum safe +width of 20 and a minimum safe height of 4. Use `PANEL_GROUPS` directly and +render it in its existing order. Remove the wide two-column branch and +decorative divider. + +**Verify**: renderer tests pass, and the render-smoke command visibly matches +the specified hierarchy without leaking terminal style after the final row. + +### Step 3: Make close detection sequence-specific + +Replace the byte scan with exact accepted sequences: + +- one byte `0x1b` for Escape; +- one byte `0x03` for Ctrl+C; +- two bytes `[0x1b, 0x74]` for the existing legacy Alt-T representation. + +All other input returns `false`. Do not add timers, stateful sequence buffering, +or new close shortcuts. + +**Verify**: the focused shortcut tests pass, including arrow and unrelated Alt +regressions. + +### Step 4: Increase the popup height + +Change only the tmux `bindings` pane height in `herdr-plugin.toml` from 24 to +30 and update its contract assertion. Preserve width, placement, title, +version, command, and all other manifest fields. + +**Verify**: the focused architecture contract passes. + +### Step 5: Format and prove repository compatibility + +Run all required gates and inspect the full branch diff against its assigned +base. Confirm that ANSI codes do not participate in visible-width calculations +and every command remains sourced from `PANEL_GROUPS`. + +**Verify**: + +- `make format-all` exits 0. +- `vp run ready` exits 0. +- `pnpm audit --audit-level high` reports no high or critical vulnerability. +- `git diff --check` exits 0 with no output. +- `git diff --name-only ...HEAD` lists only the six in-scope + existing files and four declared new files. +- `git diff -U0 ...HEAD | rg '^\+.*\b(typeof|instanceof|Record|interface)\b|^\+.*\bReflect\.|^\+.*\bObject\.(keys|values|entries)\b'` + returns no matches. +- Every new production or test-support file contains exactly one exported + class and no exported free function. + +## Test plan + +- Prefer exact behavior assertions over a large snapshot. +- Strip ANSI only for geometry and textual assertions; separately assert the + required colour sequences through `TerminalOutput` methods so styling remains + covered. +- Derive catalogue assertions from `PANEL_GROUPS` to prevent future commands + from disappearing silently. +- Cover the accepted byte sequences and the two named regressions. +- Keep existing narrow and short viewport intent, updated for the new layout. + +## Done criteria + +- [ ] The `84x30` output matches the specified visual hierarchy and palette. +- [ ] Every existing tmux shortcut and description appears without overflow. +- [ ] Every stripped row is at most 84 code points and ANSI state resets safely. +- [ ] Short viewports show a truthful resize message and close footer. +- [ ] Escape, Ctrl+C, and exact `Esc+t` close the dialog. +- [ ] Arrow keys and unrelated Alt sequences do not close the dialog. +- [ ] Popup height is 30; all other manifest behavior is unchanged. +- [ ] New rendering data uses immutable class DTOs/value objects, one exported + class per file. +- [ ] No new interface, adapter, free helper function, `typeof`, `instanceof`, + reflection, or `Record` usage is introduced. +- [ ] Focused tests, render smoke, and `vp run ready` pass. +- [ ] `pnpm audit --audit-level high` and `git diff --check` pass. +- [ ] Only the ten in-scope files are changed. +- [ ] The executor creates the required local commit. + +## STOP conditions + +Stop and report if: + +- The live renderer, shortcut detector, manifest, or tests materially differ + from the current-state description. +- The target layout cannot show the full existing catalogue at `84x30` without + changing commands or descriptions. +- Correct visible-width handling requires adding a dependency. +- The design requires reflection, `typeof`, `instanceof`, `Record`, a new + interface/adapter, or more than one exported class in a file. +- The work requires changing `BindingsPanelApplication` or any out-of-scope + file. +- The TypeScript coding standards conflict irreconcilably with repository + conventions or this confirmed scope. +- A verification failure remains after two bounded correction attempts. + +## Maintenance notes + +- Future command additions must remain driven by `PANEL_GROUPS`; the renderer + tests should fail if the 84x30 contract no longer fits. +- Reviewers should inspect the raw ANSI output for full-row background fill, + right-aligned badge spacing, per-row resets, and truthful footer text. +- Keep close detection strict. Broad Escape-byte matching recreates the arrow + key regression. diff --git a/plans/README.md b/plans/README.md new file mode 100644 index 0000000..692e36e --- /dev/null +++ b/plans/README.md @@ -0,0 +1,64 @@ +# Implementation Plans + +Generated by the Improve skill on 2026-07-21 at commit `41f59cd`. Execute in +the order below. Ollin Check owns worktree, branch, delivery, and status +metadata; executors must not edit this index. + +## Execution order and status + +| Plan | Title | Priority | Effort | Depends on | Status | +| ---- | --------------------------------------- | -------- | ------ | ---------- | -------- | +| 001 | Preserve real pane lookup failures | P1 | S | - | PR READY | +| 002 | Restyle and harden the tmux help dialog | P1 | M | - | PR READY | + +Status values for Ollin delivery: `TODO`, `IN PROGRESS`, `READY FOR PR`, +`PR DRAFT`, `PR READY`, `DONE`, `BLOCKED`, or `REJECTED`. + +## Routing state + +- **Routing profile**: `gpt-5.6-sol@high=100%` +- **Effective routing**: `gpt-5.6-sol@high=100%` +- **Harness**: authenticated Codex CLI (`codex`) +- **Plan 001 assignment**: `gpt-5.6-sol`, level `high` +- **Plan 002 assignment**: `gpt-5.6-sol`, level `high` + +## Dependency notes + +- The plans have no source-level dependency, but Ollin must execute them in + order as one linear PR stack. +- Plan 001 starts from `chore/pane-navigation-hints`. +- Plan 002 starts only after Plan 001 reaches `PR READY`, and its draft PR uses + the Plan 001 branch as its base. + +## Findings considered and rejected + +- Rewriting the complete plugin core error model: disproportionate to the + confirmed `getPane` bug and inconsistent with this focused branch. +- Changing `closePluginPane` from its intentional boolean contract: unrelated + to pane lookup semantics. +- Adding scrolling, Enter-to-close, or new tmux commands: the supplied image is + a visual target, not authorization to add interactions or change the + command catalogue. +- Adding a terminal styling dependency: the required renderer is small enough + to keep dependency-free with local ANSI helpers. +- Adding interfaces, ports, adapters, `Record` types, reflection, `typeof`, or + `instanceof` to support these changes: none is required. New render data is + represented by immutable class DTOs/value objects, one exported class per + file, with concrete collaborators split by concern. +- Broad refactors in tab numbers, config editing, or pane navigation hints: + the existing verification baseline passes and no additional high-confidence, + high-leverage defect was confirmed. + +## Binding implementation constraints + +- These constraints apply to all newly added or modified logic. Existing + unrelated legacy declarations are not authorization for a repository-wide + rewrite. +- Use classes for behavior and immutable class DTOs/value objects for new data + structures. Keep one exported class per file. +- Do not add `typeof`, `instanceof`, `Record`, `Reflect`, metadata reflection, + dynamic object-key inspection, interfaces, or adapter layers. +- Split styling, terminal-line layout, and test-output inspection into focused + files. Do not grow `BindingsPanelRenderer` into a god class. +- Apply SOLID principles only where they reduce coupling or clarify ownership; + do not manufacture ports or indirection for convention's sake. diff --git a/plugins/pane-navigation-hints/LICENSE b/plugins/pane-navigation-hints/LICENSE new file mode 100644 index 0000000..1547418 --- /dev/null +++ b/plugins/pane-navigation-hints/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Gus + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/plugins/pane-navigation-hints/README.md b/plugins/pane-navigation-hints/README.md new file mode 100644 index 0000000..fe8ef8c --- /dev/null +++ b/plugins/pane-navigation-hints/README.md @@ -0,0 +1,61 @@ +# Pane Navigation Hints + +Show the effective pane navigation shortcuts in every Herdr pane border. The plugin reads the active `config.toml`, keeps the legend below Herdr's 80-character pane-title limit, and updates newly created panes automatically. + +For the tmux-style profile, the border resembles: + +```text +Ctrl+B then ←/↓/↑/→ · Last ; +``` + +Read the shared prefix first: press `Ctrl+B`, release it, then press the arrow pointing toward the adjacent pane you want to focus. `Last ;` uses the same prefix to return to the last pane. + +The plugin is display-only. It does not change keybindings and does not depend on the Tmux Keybindings plugin. + +## Install and refresh + +```sh +herdr plugin install oullin/herdr-plugins/plugins/pane-navigation-hints +herdr plugin action invoke oullin.pane-navigation-hints.refresh +``` + +The refresh action validates the current Herdr configuration, reads pane navigation values from `[keys]`, and applies the legend to all existing panes. Missing values use the Herdr 0.7.4 defaults, while empty strings remain unbound and are omitted from the legend. + +A `pane.created` hook applies the same legend to future panes. Invoke refresh again after changing and reloading `config.toml`. + +The config path follows `HERDR_CONFIG_PATH` when it is set. Otherwise the plugin reads `%APPDATA%\herdr\config.toml` on Windows and `~/.config/herdr/config.toml` (or `$XDG_CONFIG_HOME/herdr/config.toml`) on Linux and macOS. + +## Clear and uninstall + +Clear the plugin-owned title metadata to reveal the normal pane titles: + +```sh +herdr plugin action invoke oullin.pane-navigation-hints.clear +``` + +Clear active hints before disabling or uninstalling the plugin: + +```sh +herdr plugin action invoke oullin.pane-navigation-hints.clear +herdr plugin uninstall oullin.pane-navigation-hints +``` + +The clear action removes only metadata reported by this plugin. Manual pane names and titles from other sources remain untouched. + +## Development + +From the repository root: + +```sh +vp install --frozen-lockfile +vp run ready +herdr plugin link plugins/pane-navigation-hints +herdr plugin action invoke oullin.pane-navigation-hints.refresh +herdr plugin log list --plugin oullin.pane-navigation-hints +``` + +The plugin is strict TypeScript executed natively by Node.js 24 and uses the repository-local `@oullin/herdr-plugin-core` package from the same Herdr-managed checkout. + +## Licence + +[MIT](LICENSE) diff --git a/plugins/pane-navigation-hints/herdr-plugin.toml b/plugins/pane-navigation-hints/herdr-plugin.toml new file mode 100644 index 0000000..1b93c5e --- /dev/null +++ b/plugins/pane-navigation-hints/herdr-plugin.toml @@ -0,0 +1,36 @@ +id = "oullin.pane-navigation-hints" +name = "Pane Navigation Hints" +version = "0.1.0" +min_herdr_version = "0.7.4" +description = "Show live pane navigation shortcuts in every pane border" +platforms = ["linux", "macos", "windows"] + +[[build]] +command = [ + "npm", + "install", + "--omit=dev", + "--ignore-scripts", + "--no-audit", + "--no-fund", + "--no-package-lock", + "--workspaces=false", +] + +[[actions]] +id = "refresh" +title = "Refresh pane navigation hints" +description = "Show the current pane navigation shortcuts in every pane border" +contexts = ["global"] +command = ["node", "index.ts", "refresh"] + +[[actions]] +id = "clear" +title = "Clear pane navigation hints" +description = "Restore the normal title in every pane border" +contexts = ["global"] +command = ["node", "index.ts", "clear"] + +[[events]] +on = "pane.created" +command = ["node", "index.ts", "pane-created"] diff --git a/plugins/pane-navigation-hints/index.ts b/plugins/pane-navigation-hints/index.ts new file mode 100644 index 0000000..284f010 --- /dev/null +++ b/plugins/pane-navigation-hints/index.ts @@ -0,0 +1,32 @@ +import { pathToFileURL } from 'node:url'; + +import { PaneNavigationHintSynchroniser } from '#pane-navigation-hints/application/pane-navigation-hint-synchroniser'; +import { PaneNavigationHintsPlugin } from '#pane-navigation-hints/application/pane-navigation-hints-plugin'; +import { NavigationHintFormatter } from '#pane-navigation-hints/domain/navigation-hint-formatter'; +import { HerdrCliClient } from '#pane-navigation-hints/infrastructure/herdr-cli-client'; +import { KeybindingConfigParser } from '#pane-navigation-hints/infrastructure/keybinding-config-parser'; +import { KeybindingConfigurationReader } from '#pane-navigation-hints/infrastructure/keybinding-configuration-reader'; +import { PluginApplication } from '#pane-navigation-hints/presentation/plugin-application'; + +export { type HerdrClientPort } from '#pane-navigation-hints/application/ports/herdr-client-port'; +export { type KeybindingConfigurationPort } from '#pane-navigation-hints/application/ports/keybinding-configuration-port'; +export { PANE_TITLE_SOURCE, PaneNavigationHintSynchroniser } from '#pane-navigation-hints/application/pane-navigation-hint-synchroniser'; +export { type PaneNavigationHintsCommand, PaneNavigationHintsPlugin } from '#pane-navigation-hints/application/pane-navigation-hints-plugin'; +export { DEFAULT_PANE_NAVIGATION_BINDINGS, PANE_NAVIGATION_CONFIG_KEYS } from '#pane-navigation-hints/domain/default-keybindings'; +export type * from '#pane-navigation-hints/domain/models'; +export { NavigationHintFormatter } from '#pane-navigation-hints/domain/navigation-hint-formatter'; +export { PluginError } from '#pane-navigation-hints/errors/plugin-error'; +export { HerdrCliClient } from '#pane-navigation-hints/infrastructure/herdr-cli-client'; +export { KeybindingConfigParser } from '#pane-navigation-hints/infrastructure/keybinding-config-parser'; +export { KeybindingConfigurationReader } from '#pane-navigation-hints/infrastructure/keybinding-configuration-reader'; +export { PluginApplication } from '#pane-navigation-hints/presentation/plugin-application'; +export { HerdrConfigPathResolver, PluginContext } from '@oullin/herdr-plugin-core'; + +if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) { + const client = new HerdrCliClient(); + const configuration = new KeybindingConfigurationReader(new KeybindingConfigParser()); + const synchroniser = new PaneNavigationHintSynchroniser(client, configuration, new NavigationHintFormatter()); + const plugin = new PaneNavigationHintsPlugin(synchroniser); + + new PluginApplication(plugin).run(process.argv[2]); +} diff --git a/plugins/pane-navigation-hints/package.json b/plugins/pane-navigation-hints/package.json new file mode 100644 index 0000000..46eccc1 --- /dev/null +++ b/plugins/pane-navigation-hints/package.json @@ -0,0 +1,34 @@ +{ + "name": "@oullin/herdr-pane-navigation-hints", + "version": "0.1.0", + "description": "Show live pane navigation shortcuts in Herdr pane borders.", + "license": "MIT", + "repository": { + "type": "git", + "url": "git+https://github.com/oullin/herdr-plugins.git", + "directory": "plugins/pane-navigation-hints" + }, + "files": [ + "herdr-plugin.toml", + "index.ts", + "src/", + "README.md", + "LICENSE" + ], + "type": "module", + "imports": { + "#pane-navigation-hints/application/*": "./src/application/*.ts", + "#pane-navigation-hints/domain/*": "./src/domain/*.ts", + "#pane-navigation-hints/errors/*": "./src/errors/*.ts", + "#pane-navigation-hints/infrastructure/*": "./src/infrastructure/*.ts", + "#pane-navigation-hints/presentation/*": "./src/presentation/*.ts", + "#pane-navigation-hints/testing/*": "./tests/*.ts" + }, + "exports": "./index.ts", + "dependencies": { + "@oullin/herdr-plugin-core": "file:../../package/core" + }, + "engines": { + "node": ">=24.12.0 <25" + } +} diff --git a/plugins/pane-navigation-hints/src/application/pane-navigation-hint-synchroniser.ts b/plugins/pane-navigation-hints/src/application/pane-navigation-hint-synchroniser.ts new file mode 100644 index 0000000..7aad975 --- /dev/null +++ b/plugins/pane-navigation-hints/src/application/pane-navigation-hint-synchroniser.ts @@ -0,0 +1,57 @@ +import type { HerdrClientPort } from '#pane-navigation-hints/application/ports/herdr-client-port'; +import type { KeybindingConfigurationPort } from '#pane-navigation-hints/application/ports/keybinding-configuration-port'; +import type { Environment } from '#pane-navigation-hints/domain/models'; +import type { NavigationHintFormatter } from '#pane-navigation-hints/domain/navigation-hint-formatter'; + +export const PANE_TITLE_SOURCE = 'oullin.pane-navigation-hints'; + +export class PaneNavigationHintSynchroniser { + private readonly client: HerdrClientPort; + private readonly configuration: KeybindingConfigurationPort; + private readonly formatter: NavigationHintFormatter; + + constructor(client: HerdrClientPort, configuration: KeybindingConfigurationPort, formatter: NavigationHintFormatter) { + this.client = client; + this.configuration = configuration; + this.formatter = formatter; + } + + refreshAll(environment: Environment = process.env): number { + const title = this.title(environment); + const panes = this.client.listPanes(); + + for (const pane of panes) { + this.client.reportPaneTitle(pane.pane_id, { source: PANE_TITLE_SOURCE, title }); + } + + return panes.length; + } + + refreshPane(paneId: string, environment: Environment = process.env): number { + if (this.client.getPane(paneId) === undefined) { + return 0; + } + + this.client.reportPaneTitle(paneId, { source: PANE_TITLE_SOURCE, title: this.title(environment) }); + + return 1; + } + + clearAll(): number { + const panes = this.client.listPanes(); + + for (const pane of panes) { + this.client.reportPaneTitle(pane.pane_id, { source: PANE_TITLE_SOURCE, clearTitle: true }); + } + + return panes.length; + } + + private title(environment: Environment): string { + const configuration = this.configuration.read(environment); + + this.client.validateConfig(configuration.configPath); + + return this.formatter.format(configuration.bindings); + } +} diff --git a/plugins/pane-navigation-hints/src/application/pane-navigation-hints-plugin.ts b/plugins/pane-navigation-hints/src/application/pane-navigation-hints-plugin.ts new file mode 100644 index 0000000..45b896a --- /dev/null +++ b/plugins/pane-navigation-hints/src/application/pane-navigation-hints-plugin.ts @@ -0,0 +1,43 @@ +import type { PaneNavigationHintSynchroniser } from '#pane-navigation-hints/application/pane-navigation-hint-synchroniser'; +import type { Environment, PaneHintResult } from '#pane-navigation-hints/domain/models'; +import { PluginError } from '#pane-navigation-hints/errors/plugin-error'; +import { PluginContext } from '@oullin/herdr-plugin-core'; + +export type PaneNavigationHintsCommand = 'clear' | 'pane-created' | 'refresh'; + +export class PaneNavigationHintsPlugin { + private readonly synchroniser: PaneNavigationHintSynchroniser; + + constructor(synchroniser: PaneNavigationHintSynchroniser) { + this.synchroniser = synchroniser; + } + + run(command: string | undefined, environment: Environment = process.env): PaneHintResult { + switch (command) { + case 'refresh': + return { operation: 'refreshed', panes: this.synchroniser.refreshAll(environment) }; + + case 'clear': + return { operation: 'cleared', panes: this.synchroniser.clearAll() }; + + case 'pane-created': { + const context = new PluginContext(environment); + + if (context.event !== 'pane.created') { + throw new PluginError(`Expected pane.created event, received ${context.event ?? '(missing)'}`); + } + + const paneId = context.paneId(); + + if (!paneId) { + throw new PluginError('pane.created did not include a pane id'); + } + + return { operation: 'refreshed', panes: this.synchroniser.refreshPane(paneId, environment) }; + } + + default: + throw new PluginError(`Unknown pane-navigation-hints command: ${command ?? '(missing)'}`); + } + } +} diff --git a/plugins/pane-navigation-hints/src/application/ports/herdr-client-port.ts b/plugins/pane-navigation-hints/src/application/ports/herdr-client-port.ts new file mode 100644 index 0000000..552dc96 --- /dev/null +++ b/plugins/pane-navigation-hints/src/application/ports/herdr-client-port.ts @@ -0,0 +1,8 @@ +import type { Pane, PaneTitleUpdate } from '#pane-navigation-hints/domain/models'; + +export interface HerdrClientPort { + listPanes(workspaceId?: string): readonly Pane[]; + getPane(paneId: string): Pane | undefined; + reportPaneTitle(paneId: string, update: PaneTitleUpdate): void; + validateConfig(configPath: string): void; +} diff --git a/plugins/pane-navigation-hints/src/application/ports/keybinding-configuration-port.ts b/plugins/pane-navigation-hints/src/application/ports/keybinding-configuration-port.ts new file mode 100644 index 0000000..62ac468 --- /dev/null +++ b/plugins/pane-navigation-hints/src/application/ports/keybinding-configuration-port.ts @@ -0,0 +1,5 @@ +import type { Environment, KeybindingConfiguration } from '#pane-navigation-hints/domain/models'; + +export interface KeybindingConfigurationPort { + read(environment?: Environment): KeybindingConfiguration; +} diff --git a/plugins/pane-navigation-hints/src/domain/default-keybindings.ts b/plugins/pane-navigation-hints/src/domain/default-keybindings.ts new file mode 100644 index 0000000..9e7f068 --- /dev/null +++ b/plugins/pane-navigation-hints/src/domain/default-keybindings.ts @@ -0,0 +1,23 @@ +import type { PaneNavigationBindings } from '#pane-navigation-hints/domain/models'; + +export const DEFAULT_PANE_NAVIGATION_BINDINGS: PaneNavigationBindings = { + prefix: 'ctrl+b', + focusPaneLeft: 'prefix+h', + focusPaneDown: 'prefix+j', + focusPaneUp: 'prefix+k', + focusPaneRight: 'prefix+l', + cyclePaneNext: 'prefix+tab', + cyclePanePrevious: 'prefix+shift+tab', + lastPane: '', +}; + +export const PANE_NAVIGATION_CONFIG_KEYS = { + prefix: 'prefix', + focus_pane_left: 'focusPaneLeft', + focus_pane_down: 'focusPaneDown', + focus_pane_up: 'focusPaneUp', + focus_pane_right: 'focusPaneRight', + cycle_pane_next: 'cyclePaneNext', + cycle_pane_previous: 'cyclePanePrevious', + last_pane: 'lastPane', +} as const satisfies Readonly>; diff --git a/plugins/pane-navigation-hints/src/domain/models.ts b/plugins/pane-navigation-hints/src/domain/models.ts new file mode 100644 index 0000000..f8488c2 --- /dev/null +++ b/plugins/pane-navigation-hints/src/domain/models.ts @@ -0,0 +1,22 @@ +export type { Environment, Pane, PaneTitleUpdate } from '@oullin/herdr-plugin-core'; + +export interface PaneNavigationBindings { + readonly prefix: string; + readonly focusPaneLeft: string; + readonly focusPaneDown: string; + readonly focusPaneUp: string; + readonly focusPaneRight: string; + readonly cyclePaneNext: string; + readonly cyclePanePrevious: string; + readonly lastPane: string; +} + +export interface KeybindingConfiguration { + readonly configPath: string; + readonly bindings: PaneNavigationBindings; +} + +export interface PaneHintResult { + readonly operation: 'cleared' | 'refreshed'; + readonly panes: number; +} diff --git a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts new file mode 100644 index 0000000..6bc69b6 --- /dev/null +++ b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts @@ -0,0 +1,138 @@ +import type { PaneNavigationBindings } from '#pane-navigation-hints/domain/models'; + +const titleLimit = 80; +const separator = ' · '; + +const keyLabels: Readonly> = { + alt: 'Alt', + cmd: 'Cmd', + control: 'Ctrl', + ctrl: 'Ctrl', + down: '↓', + enter: 'Enter', + esc: 'Esc', + left: '←', + right: '→', + shift: 'Shift', + super: 'Super', + tab: 'Tab', + up: '↑', +}; + +export class NavigationHintFormatter { + format(bindings: PaneNavigationBindings): string { + const usesSharedPrefix = this.usesSharedPrefix(bindings); + const displayBindings = usesSharedPrefix ? this.withoutBindingPrefixes(bindings) : bindings; + + const segments = [this.focusSegment(displayBindings), displayBindings.lastPane === '' ? undefined : `Last ${this.renderBinding(displayBindings.lastPane, displayBindings.prefix)}`].filter( + (segment): segment is string => segment !== undefined, + ); + + const legend = segments.join(separator); + const title = segments.length === 0 ? 'Pane focus is unbound' : usesSharedPrefix ? `${this.renderPrefix(bindings.prefix)} then ${legend}` : legend; + + return this.truncate(title); + } + + private focusSegment(bindings: PaneNavigationBindings): string | undefined { + const directional = [ + { direction: '←', binding: bindings.focusPaneLeft }, + { direction: '↓', binding: bindings.focusPaneDown }, + { direction: '↑', binding: bindings.focusPaneUp }, + { direction: '→', binding: bindings.focusPaneRight }, + ].filter(({ binding }) => binding !== ''); + + if (directional.length === 0) { + return undefined; + } + + if (directional.length === 4) { + const directions = directional.map(({ direction }) => direction).join('/'); + + const collapsed = this.renderBindingList( + directional.map(({ binding }) => binding), + bindings.prefix, + ); + + return collapsed === directions ? directions : `${directions} ${collapsed}`; + } + + return directional.map(({ direction, binding }) => `${direction} ${this.renderBinding(binding, bindings.prefix)}`).join(' '); + } + + private renderBindingList(bindings: readonly string[], prefix: string): string { + return this.collapseBindings(bindings, prefix) ?? bindings.map((binding) => this.renderBinding(binding, prefix)).join('/'); + } + + private usesSharedPrefix(bindings: PaneNavigationBindings): boolean { + const actions = [bindings.focusPaneLeft, bindings.focusPaneDown, bindings.focusPaneUp, bindings.focusPaneRight, bindings.lastPane].filter((binding) => binding !== ''); + + return bindings.prefix !== '' && actions.length > 0 && actions.every((binding) => binding.startsWith('prefix+') && binding.length > 'prefix+'.length); + } + + private withoutBindingPrefixes(bindings: PaneNavigationBindings): PaneNavigationBindings { + const withoutPrefix = (binding: string): string => (binding === '' ? '' : binding.slice('prefix+'.length)); + + return { + prefix: '', + focusPaneLeft: withoutPrefix(bindings.focusPaneLeft), + focusPaneDown: withoutPrefix(bindings.focusPaneDown), + focusPaneUp: withoutPrefix(bindings.focusPaneUp), + focusPaneRight: withoutPrefix(bindings.focusPaneRight), + cyclePaneNext: withoutPrefix(bindings.cyclePaneNext), + cyclePanePrevious: withoutPrefix(bindings.cyclePanePrevious), + lastPane: withoutPrefix(bindings.lastPane), + }; + } + + private collapseBindings(bindings: readonly string[], prefix: string): string | undefined { + const parts = bindings.map((binding) => binding.split('+')); + const sharedHead = parts[0]?.[0]; + + if (!sharedHead || parts.some((bindingParts) => bindingParts.length < 2 || bindingParts[0] !== sharedHead)) { + return undefined; + } + + const renderedHead = sharedHead === 'prefix' ? this.renderPrefix(prefix) : this.renderKey(sharedHead); + + const tails = parts.map((bindingParts) => + bindingParts + .slice(1) + .map((part) => this.renderKey(part)) + .join('+'), + ); + + return [renderedHead, tails.join('/')].filter(Boolean).join('+'); + } + + private renderBinding(binding: string, prefix: string): string { + const parts = binding.split('+'); + + if (parts[0] === 'prefix') { + return [this.renderPrefix(prefix), ...parts.slice(1).map((part) => this.renderKey(part))].filter(Boolean).join('+'); + } + + return parts.map((part) => this.renderKey(part)).join('+'); + } + + private renderPrefix(prefix: string): string { + const parts = prefix.split('+').map((part) => this.renderKey(part)); + const final = parts.at(-1); + + if (final?.length === 1) { + parts[parts.length - 1] = final.toUpperCase(); + } + + return parts.join('+'); + } + + private renderKey(key: string): string { + return keyLabels[key.toLowerCase()] ?? key; + } + + private truncate(title: string): string { + const characters = Array.from(title); + + return characters.length <= titleLimit ? title : `${characters.slice(0, titleLimit - 1).join('')}…`; + } +} diff --git a/plugins/pane-navigation-hints/src/errors/plugin-error.ts b/plugins/pane-navigation-hints/src/errors/plugin-error.ts new file mode 100644 index 0000000..99c7fb1 --- /dev/null +++ b/plugins/pane-navigation-hints/src/errors/plugin-error.ts @@ -0,0 +1 @@ +export { PluginError } from '@oullin/herdr-plugin-core'; diff --git a/plugins/pane-navigation-hints/src/infrastructure/herdr-cli-client.ts b/plugins/pane-navigation-hints/src/infrastructure/herdr-cli-client.ts new file mode 100644 index 0000000..4e5d101 --- /dev/null +++ b/plugins/pane-navigation-hints/src/infrastructure/herdr-cli-client.ts @@ -0,0 +1 @@ +export { HerdrCliClient } from '@oullin/herdr-plugin-core'; diff --git a/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts b/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts new file mode 100644 index 0000000..a07d31f --- /dev/null +++ b/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts @@ -0,0 +1,87 @@ +import { DEFAULT_PANE_NAVIGATION_BINDINGS, PANE_NAVIGATION_CONFIG_KEYS } from '#pane-navigation-hints/domain/default-keybindings'; +import type { PaneNavigationBindings } from '#pane-navigation-hints/domain/models'; +import { PluginError } from '#pane-navigation-hints/errors/plugin-error'; + +const keysHeaderPattern = /^\s*\[keys\]\s*(?:#.*)?$/u; +const tableHeaderPattern = /^\s*\[.+\]\s*(?:#.*)?$/u; +const assignmentPattern = /^\s*([A-Za-z0-9_]+)\s*=\s*(.+?)\s*$/u; + +export class KeybindingConfigParser { + parse(source: string): PaneNavigationBindings { + const bindings = { ...DEFAULT_PANE_NAVIGATION_BINDINGS }; + const lines = source.split(/\r?\n/u); + const start = lines.findIndex((line) => keysHeaderPattern.test(line)); + + if (start < 0) { + return bindings; + } + + for (let index = start + 1; index < lines.length; index += 1) { + const line = lines[index] ?? ''; + + if (tableHeaderPattern.test(line)) { + break; + } + + const assignment = assignmentPattern.exec(this.withoutComment(line)); + + if (!assignment?.[1] || assignment[2] === undefined) { + continue; + } + + const property = PANE_NAVIGATION_CONFIG_KEYS[assignment[1] as keyof typeof PANE_NAVIGATION_CONFIG_KEYS]; + + if (property !== undefined) { + bindings[property] = this.stringValue(assignment[2], assignment[1]); + } + } + + return bindings; + } + + private stringValue(rawValue: string, key: string): string { + const value = rawValue.trim(); + + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { + try { + const parsed = JSON.parse(value) as unknown; + + if (typeof parsed === 'string') { + return parsed; + } + } catch (error) { + throw new PluginError(`Could not parse [keys].${key}`, { cause: error }); + } + } + + if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) { + return value.slice(1, -1); + } + + throw new PluginError(`[keys].${key} must be a quoted string`); + } + + private withoutComment(line: string): string { + let quote: '"' | "'" | undefined; + let escaped = false; + + for (let index = 0; index < line.length; index += 1) { + const character = line[index]; + + if (quote === '"' && character === '\\' && !escaped) { + escaped = true; + continue; + } + + if ((character === '"' || character === "'") && !escaped) { + quote = quote === character ? undefined : (quote ?? character); + } else if (character === '#' && quote === undefined) { + return line.slice(0, index); + } + + escaped = false; + } + + return line; + } +} diff --git a/plugins/pane-navigation-hints/src/infrastructure/keybinding-configuration-reader.ts b/plugins/pane-navigation-hints/src/infrastructure/keybinding-configuration-reader.ts new file mode 100644 index 0000000..2ae82a0 --- /dev/null +++ b/plugins/pane-navigation-hints/src/infrastructure/keybinding-configuration-reader.ts @@ -0,0 +1,23 @@ +import { existsSync, readFileSync } from 'node:fs'; + +import type { KeybindingConfigurationPort } from '#pane-navigation-hints/application/ports/keybinding-configuration-port'; +import type { Environment, KeybindingConfiguration } from '#pane-navigation-hints/domain/models'; +import type { KeybindingConfigParser } from '#pane-navigation-hints/infrastructure/keybinding-config-parser'; +import { HerdrConfigPathResolver } from '@oullin/herdr-plugin-core'; + +export class KeybindingConfigurationReader implements KeybindingConfigurationPort { + private readonly parser: KeybindingConfigParser; + private readonly pathResolver: HerdrConfigPathResolver; + + constructor(parser: KeybindingConfigParser, pathResolver: HerdrConfigPathResolver = new HerdrConfigPathResolver()) { + this.parser = parser; + this.pathResolver = pathResolver; + } + + read(environment: Environment = process.env): KeybindingConfiguration { + const configPath = this.pathResolver.resolve(environment); + const source = existsSync(configPath) ? readFileSync(configPath, 'utf8') : ''; + + return { configPath, bindings: this.parser.parse(source) }; + } +} diff --git a/plugins/pane-navigation-hints/src/presentation/plugin-application.ts b/plugins/pane-navigation-hints/src/presentation/plugin-application.ts new file mode 100644 index 0000000..44fa4d3 --- /dev/null +++ b/plugins/pane-navigation-hints/src/presentation/plugin-application.ts @@ -0,0 +1,21 @@ +import type { PaneNavigationHintsPlugin } from '#pane-navigation-hints/application/pane-navigation-hints-plugin'; +import type { Environment } from '#pane-navigation-hints/domain/models'; +import { executePlugin } from '@oullin/herdr-plugin-core'; + +export class PluginApplication { + private readonly plugin: PaneNavigationHintsPlugin; + + constructor(plugin: PaneNavigationHintsPlugin) { + this.plugin = plugin; + } + + run(command: string | undefined, environment: Environment = process.env): void { + executePlugin( + () => this.plugin.run(command, environment), + { + failurePrefix: 'Pane navigation hints failed', + successMessage: (result) => `Pane navigation hints ${result.operation} (${result.panes} panes)`, + }, + ); + } +} diff --git a/plugins/pane-navigation-hints/tests/application/pane-navigation-hint-synchroniser.test.ts b/plugins/pane-navigation-hints/tests/application/pane-navigation-hint-synchroniser.test.ts new file mode 100644 index 0000000..f34cfe7 --- /dev/null +++ b/plugins/pane-navigation-hints/tests/application/pane-navigation-hint-synchroniser.test.ts @@ -0,0 +1,66 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { PANE_TITLE_SOURCE, PaneNavigationHintSynchroniser } from '#pane-navigation-hints/application/pane-navigation-hint-synchroniser'; +import { NavigationHintFormatter } from '#pane-navigation-hints/domain/navigation-hint-formatter'; +import { FakeHerdrClient, FakeKeybindingConfiguration, panes } from '#pane-navigation-hints/testing/support/fakes'; + +describe('PaneNavigationHintSynchroniser', () => { + it('validates the active config and refreshes every existing pane', () => { + const client = new FakeHerdrClient(panes); + const configuration = new FakeKeybindingConfiguration(); + const synchroniser = new PaneNavigationHintSynchroniser(client, configuration, new NavigationHintFormatter()); + + expect( + synchroniser.refreshAll({ HERDR_CONFIG_PATH: '/config/herdr/config.toml' }), + ).toBe(2); + expect(client.validatedConfigPaths).toEqual(['/config/herdr/config.toml']); + expect(client.reports).toEqual([ + { + paneId: 'w1:p1', + update: { source: PANE_TITLE_SOURCE, title: 'Ctrl+B then ←/↓/↑/→ h/j/k/l' }, + }, + { + paneId: 'w1:p2', + update: { source: PANE_TITLE_SOURCE, title: 'Ctrl+B then ←/↓/↑/→ h/j/k/l' }, + }, + ]); + }); + + it('refreshes only the pane named by a pane-created hook', () => { + const client = new FakeHerdrClient(panes); + const synchroniser = new PaneNavigationHintSynchroniser(client, new FakeKeybindingConfiguration(), new NavigationHintFormatter()); + + expect( + synchroniser.refreshPane('w1:p2'), + ).toBe(1); + expect( + client.reports.map(({ paneId }) => paneId), + ).toEqual(['w1:p2']); + }); + + it('ignores a pane that closed before its hook ran', () => { + const client = new FakeHerdrClient(panes); + const configuration = new FakeKeybindingConfiguration(); + const synchroniser = new PaneNavigationHintSynchroniser(client, configuration, new NavigationHintFormatter()); + + expect( + synchroniser.refreshPane('w1:missing'), + ).toBe(0); + expect(configuration.environments).toEqual([]); + expect(client.reports).toEqual([]); + }); + + it('clears only this plugin source from every existing pane', () => { + const client = new FakeHerdrClient(panes); + const synchroniser = new PaneNavigationHintSynchroniser(client, new FakeKeybindingConfiguration(), new NavigationHintFormatter()); + + expect( + synchroniser.clearAll(), + ).toBe(2); + expect(client.validatedConfigPaths).toEqual([]); + expect(client.reports).toEqual([ + { paneId: 'w1:p1', update: { source: PANE_TITLE_SOURCE, clearTitle: true } }, + { paneId: 'w1:p2', update: { source: PANE_TITLE_SOURCE, clearTitle: true } }, + ]); + }); +}); diff --git a/plugins/pane-navigation-hints/tests/application/pane-navigation-hints-plugin.test.ts b/plugins/pane-navigation-hints/tests/application/pane-navigation-hints-plugin.test.ts new file mode 100644 index 0000000..7f2dd67 --- /dev/null +++ b/plugins/pane-navigation-hints/tests/application/pane-navigation-hints-plugin.test.ts @@ -0,0 +1,39 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { PaneNavigationHintSynchroniser } from '#pane-navigation-hints/application/pane-navigation-hint-synchroniser'; +import { PaneNavigationHintsPlugin } from '#pane-navigation-hints/application/pane-navigation-hints-plugin'; +import { NavigationHintFormatter } from '#pane-navigation-hints/domain/navigation-hint-formatter'; +import { FakeHerdrClient, FakeKeybindingConfiguration, panes } from '#pane-navigation-hints/testing/support/fakes'; + +describe('PaneNavigationHintsPlugin', () => { + function plugin(): PaneNavigationHintsPlugin { + return new PaneNavigationHintsPlugin(new PaneNavigationHintSynchroniser(new FakeHerdrClient(panes), new FakeKeybindingConfiguration(), new NavigationHintFormatter())); + } + + it('dispatches refresh and clear actions', () => { + expect( + plugin().run('refresh'), + ).toEqual({ operation: 'refreshed', panes: 2 }); + expect( + plugin().run('clear'), + ).toEqual({ operation: 'cleared', panes: 2 }); + }); + + it('reads the pane id from a pane-created event', () => { + expect( + plugin().run('pane-created', { + HERDR_PLUGIN_EVENT: 'pane.created', + HERDR_PANE_ID: 'w1:p2', + }), + ).toEqual({ operation: 'refreshed', panes: 1 }); + }); + + it('rejects missing or unexpected event context', () => { + expect(() => plugin().run('pane-created', { HERDR_PLUGIN_EVENT: 'pane.closed', HERDR_PANE_ID: 'w1:p2' })).toThrowError('Expected pane.created event'); + expect(() => plugin().run('pane-created', { HERDR_PLUGIN_EVENT: 'pane.created' })).toThrowError('pane.created did not include a pane id'); + }); + + it('rejects unknown commands', () => { + expect(() => plugin().run('unknown')).toThrowError('Unknown pane-navigation-hints command: unknown'); + }); +}); diff --git a/plugins/pane-navigation-hints/tests/architecture/plugin-contract.test.ts b/plugins/pane-navigation-hints/tests/architecture/plugin-contract.test.ts new file mode 100644 index 0000000..1b8d6b9 --- /dev/null +++ b/plugins/pane-navigation-hints/tests/architecture/plugin-contract.test.ts @@ -0,0 +1,93 @@ +import { readFileSync, readdirSync } from 'node:fs'; +import { extname, join, relative } from 'node:path'; + +import { describe, expect, it } from 'vite-plus/test'; + +const root = join( + process.cwd(), + 'plugins', + 'pane-navigation-hints', +); + +function sourceFiles(directory: string): readonly string[] { + return readdirSync( + directory, + { withFileTypes: true }, + ).flatMap((entry) => { + const path = join(directory, entry.name); + + if (entry.isDirectory()) { + return entry.name === 'node_modules' ? [] : sourceFiles(path); + } + + return ['.js', '.ts'].includes(extname(entry.name)) ? [path] : []; + }); +} + +describe('pane-navigation-hints plugin contract', () => { + it('declares an independently installable Herdr 0.7.4 plugin', () => { + const manifest = readFileSync( + join(root, 'herdr-plugin.toml'), + 'utf8', + ); + + expect(manifest).toContain('id = "oullin.pane-navigation-hints"'); + expect(manifest).toContain('version = "0.1.0"'); + expect(manifest).toContain('min_herdr_version = "0.7.4"'); + expect(manifest).toContain('id = "refresh"'); + expect(manifest).toContain('id = "clear"'); + expect(manifest).toContain('on = "pane.created"'); + }); + + it('keeps package metadata, aliases, and local core dependency aligned', () => { + const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { + readonly name?: string; + readonly version?: string; + readonly imports?: Record; + readonly dependencies?: Record; + }; + + expect(packageJson.name).toBe('@oullin/herdr-pane-navigation-hints'); + expect(packageJson.version).toBe('0.1.0'); + expect(packageJson.dependencies).toEqual({ '@oullin/herdr-plugin-core': 'file:../../package/core' }); + expect( + Object.keys(packageJson.imports ?? {}), + ).toEqual([ + '#pane-navigation-hints/application/*', + '#pane-navigation-hints/domain/*', + '#pane-navigation-hints/errors/*', + '#pane-navigation-hints/infrastructure/*', + '#pane-navigation-hints/presentation/*', + '#pane-navigation-hints/testing/*', + ]); + }); + + it('uses aliases instead of relative module specifiers', () => { + const relativeModulePattern = /(?:\bfrom\s+|\bimport\s*\(\s*|\bimport\s+)['"]\.{1,2}\//u; + + const violations = sourceFiles(root) + .filter((file) => relativeModulePattern.test(readFileSync(file, 'utf8'))) + .map((file) => relative(root, file)); + + expect(violations).toEqual([]); + }); + + it('documents catalogue installation and reversible cleanup', () => { + const catalogue = readFileSync( + join( + process.cwd(), + 'README.md', + ), + 'utf8', + ); + const readme = readFileSync( + join(root, 'README.md'), + 'utf8', + ); + + expect(catalogue).toContain('[Pane Navigation Hints](plugins/pane-navigation-hints)'); + expect(readme).toContain('oullin.pane-navigation-hints.refresh'); + expect(readme).toContain('oullin.pane-navigation-hints.clear'); + expect(readme).toContain('Clear active hints before disabling or uninstalling'); + }); +}); diff --git a/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts b/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts new file mode 100644 index 0000000..7ff84b9 --- /dev/null +++ b/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts @@ -0,0 +1,103 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { DEFAULT_PANE_NAVIGATION_BINDINGS } from '#pane-navigation-hints/domain/default-keybindings'; +import { NavigationHintFormatter } from '#pane-navigation-hints/domain/navigation-hint-formatter'; + +describe('NavigationHintFormatter', () => { + const formatter = new NavigationHintFormatter(); + + it('formats the Herdr defaults and omits the unbound last-pane action', () => { + expect( + formatter.format(DEFAULT_PANE_NAVIGATION_BINDINGS), + ).toBe('Ctrl+B then ←/↓/↑/→ h/j/k/l'); + }); + + it('collapses the tmux profile into one compact legend', () => { + expect( + formatter.format({ + prefix: 'ctrl+b', + focusPaneLeft: 'prefix+left', + focusPaneDown: 'prefix+down', + focusPaneUp: 'prefix+up', + focusPaneRight: 'prefix+right', + cyclePaneNext: 'prefix+o', + cyclePanePrevious: 'prefix+shift+tab', + lastPane: 'prefix+;', + }), + ).toBe('Ctrl+B then ←/↓/↑/→ · Last ;'); + }); + + it('renders partial directional bindings without showing cycle bindings', () => { + expect( + formatter.format({ + ...DEFAULT_PANE_NAVIGATION_BINDINGS, + focusPaneDown: '', + focusPaneUp: '', + cyclePaneNext: 'alt+n', + cyclePanePrevious: '', + }), + ).toBe('Ctrl+B then ← h → l'); + }); + + it('omits separators before collapsed bindings when the prefix is empty', () => { + expect( + formatter.format({ + prefix: '', + focusPaneLeft: 'prefix+left', + focusPaneDown: 'prefix+down', + focusPaneUp: 'prefix+up', + focusPaneRight: 'prefix+right', + cyclePaneNext: 'prefix+o', + cyclePanePrevious: 'prefix+shift+tab', + lastPane: 'prefix+;', + }), + ).toBe('←/↓/↑/→ · Last ;'); + }); + + it('omits separators before individual bindings when the prefix is empty', () => { + expect( + formatter.format({ + prefix: '', + focusPaneLeft: 'prefix+left', + focusPaneDown: '', + focusPaneUp: '', + focusPaneRight: '', + cyclePaneNext: 'prefix+tab', + cyclePanePrevious: '', + lastPane: '', + }), + ).toBe('← ←'); + }); + + it('reports when every pane navigation action is unbound', () => { + expect( + formatter.format({ + prefix: 'ctrl+b', + focusPaneLeft: '', + focusPaneDown: '', + focusPaneUp: '', + focusPaneRight: '', + cyclePaneNext: '', + cyclePanePrevious: '', + lastPane: '', + }), + ).toBe('Pane focus is unbound'); + }); + + it('never exceeds the Herdr pane title limit', () => { + const title = formatter.format({ + ...DEFAULT_PANE_NAVIGATION_BINDINGS, + focusPaneLeft: 'ctrl+this-is-an-intentionally-long-custom-navigation-binding-left', + focusPaneDown: 'alt+this-is-an-intentionally-long-custom-navigation-binding-down', + focusPaneUp: 'cmd+this-is-an-intentionally-long-custom-navigation-binding-up', + focusPaneRight: 'super+this-is-an-intentionally-long-custom-navigation-binding-right', + }); + + expect( + Array.from(title), + ).toHaveLength(80); + expect( + title.endsWith('…'), + ).toBe(true); + }); +}); diff --git a/plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts b/plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts new file mode 100644 index 0000000..0dae49b --- /dev/null +++ b/plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts @@ -0,0 +1,61 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { DEFAULT_PANE_NAVIGATION_BINDINGS } from '#pane-navigation-hints/domain/default-keybindings'; +import { KeybindingConfigParser } from '#pane-navigation-hints/infrastructure/keybinding-config-parser'; + +describe('KeybindingConfigParser', () => { + const parser = new KeybindingConfigParser(); + + it('returns Herdr 0.7.4 defaults when the keys section is absent', () => { + expect( + parser.parse('[ui]\npane_borders = true\n'), + ).toEqual(DEFAULT_PANE_NAVIGATION_BINDINGS); + }); + + it('merges quoted overrides, preserves empty bindings, and ignores inline comments', () => { + const bindings = parser.parse( + [ + '[keys]', + "prefix = 'ctrl+a' # literal string", + 'focus_pane_left = "prefix+left" # move left', + 'focus_pane_down = ""', + 'cycle_pane_next = "alt+n"', + 'last_pane = "prefix+semicolon"', + '[ui]', + 'focus_pane_right = "not-in-keys"', + ].join('\n'), + ); + + expect(bindings).toEqual({ + ...DEFAULT_PANE_NAVIGATION_BINDINGS, + prefix: 'ctrl+a', + focusPaneLeft: 'prefix+left', + focusPaneDown: '', + cyclePaneNext: 'alt+n', + lastPane: 'prefix+semicolon', + }); + }); + + it('parses escaped basic strings and hash characters inside quotes', () => { + const bindings = parser.parse(['[keys]', 'prefix = "ctrl+\\\\"', 'focus_pane_left = "ctrl+#" # comment'].join('\n')); + + expect(bindings.prefix).toBe('ctrl+\\'); + expect(bindings.focusPaneLeft).toBe('ctrl+#'); + }); + + it('parses Windows line endings without retaining carriage returns', () => { + const bindings = parser.parse(['[keys]', 'prefix = "ctrl+a"', 'focus_pane_left = "alt+h"', '[ui]', 'focus_pane_right = "not-in-keys"'].join('\r\n')); + + expect(bindings.prefix).toBe('ctrl+a'); + expect(bindings.focusPaneLeft).toBe('alt+h'); + expect(bindings.focusPaneRight).toBe(DEFAULT_PANE_NAVIGATION_BINDINGS.focusPaneRight); + }); + + it.each(['"', "'"])('rejects a lone %s quote as an unterminated value', (quote) => { + expect(() => parser.parse(`[keys]\nprefix = ${quote}\n`)).toThrowError('[keys].prefix must be a quoted string'); + }); + + it('rejects unquoted managed values', () => { + expect(() => parser.parse('[keys]\nprefix = ctrl+b\n')).toThrowError('[keys].prefix must be a quoted string'); + }); +}); diff --git a/plugins/pane-navigation-hints/tests/infrastructure/keybinding-configuration-reader.test.ts b/plugins/pane-navigation-hints/tests/infrastructure/keybinding-configuration-reader.test.ts new file mode 100644 index 0000000..d4e3d5c --- /dev/null +++ b/plugins/pane-navigation-hints/tests/infrastructure/keybinding-configuration-reader.test.ts @@ -0,0 +1,57 @@ +import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; + +import { afterEach, describe, expect, it } from 'vite-plus/test'; + +import { KeybindingConfigParser } from '#pane-navigation-hints/infrastructure/keybinding-config-parser'; +import { KeybindingConfigurationReader } from '#pane-navigation-hints/infrastructure/keybinding-configuration-reader'; + +describe('KeybindingConfigurationReader', () => { + const directories: string[] = []; + + afterEach(() => { + for (const directory of directories.splice(0)) { + rmSync( + directory, + { recursive: true, force: true }, + ); + } + }); + + it('follows HERDR_CONFIG_PATH and reads the current keys section', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'pane-navigation-hints-', + ), + ); + + const configPath = join(directory, 'config.toml'); + + directories.push(directory); + writeFileSync(configPath, '[keys]\nprefix = "ctrl+a"\n', 'utf8'); + + const configuration = new KeybindingConfigurationReader(new KeybindingConfigParser()).read({ HERDR_CONFIG_PATH: configPath }); + + expect(configuration.configPath).toBe(configPath); + expect(configuration.bindings.prefix).toBe('ctrl+a'); + }); + + it('uses defaults when the resolved config file does not exist', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'pane-navigation-hints-', + ), + ); + + const configPath = join(directory, 'missing.toml'); + + directories.push(directory); + + const configuration = new KeybindingConfigurationReader(new KeybindingConfigParser()).read({ HERDR_CONFIG_PATH: configPath }); + + expect(configuration.bindings.prefix).toBe('ctrl+b'); + }); +}); diff --git a/plugins/pane-navigation-hints/tests/support/fakes.ts b/plugins/pane-navigation-hints/tests/support/fakes.ts new file mode 100644 index 0000000..91c4fcd --- /dev/null +++ b/plugins/pane-navigation-hints/tests/support/fakes.ts @@ -0,0 +1,49 @@ +import type { HerdrClientPort } from '#pane-navigation-hints/application/ports/herdr-client-port'; +import type { KeybindingConfigurationPort } from '#pane-navigation-hints/application/ports/keybinding-configuration-port'; +import { DEFAULT_PANE_NAVIGATION_BINDINGS } from '#pane-navigation-hints/domain/default-keybindings'; +import type { Environment, KeybindingConfiguration, Pane, PaneTitleUpdate } from '#pane-navigation-hints/domain/models'; + +export class FakeHerdrClient implements HerdrClientPort { + readonly panes: Pane[]; + readonly reports: { readonly paneId: string; readonly update: PaneTitleUpdate }[] = []; + readonly validatedConfigPaths: string[] = []; + + constructor(panes: readonly Pane[] = []) { + this.panes = [...panes]; + } + + listPanes(workspaceId?: string): readonly Pane[] { + return workspaceId === undefined ? this.panes : this.panes.filter((pane) => pane.workspace_id === workspaceId); + } + + getPane(paneId: string): Pane | undefined { + return this.panes.find((pane) => pane.pane_id === paneId); + } + + reportPaneTitle(paneId: string, update: PaneTitleUpdate): void { + this.reports.push({ paneId, update }); + } + + validateConfig(configPath: string): void { + this.validatedConfigPaths.push(configPath); + } +} + +export class FakeKeybindingConfiguration implements KeybindingConfigurationPort { + readonly environments: (Environment | undefined)[] = []; + configuration: KeybindingConfiguration = { + configPath: '/config/herdr/config.toml', + bindings: DEFAULT_PANE_NAVIGATION_BINDINGS, + }; + + read(environment?: Environment): KeybindingConfiguration { + this.environments.push(environment); + + return this.configuration; + } +} + +export const panes: readonly Pane[] = [ + { pane_id: 'w1:p1', tab_id: 'w1:t1', workspace_id: 'w1' }, + { pane_id: 'w1:p2', tab_id: 'w1:t1', workspace_id: 'w1' }, +]; diff --git a/plugins/tmux-keybindings/README.md b/plugins/tmux-keybindings/README.md index 934654f..52270c5 100644 --- a/plugins/tmux-keybindings/README.md +++ b/plugins/tmux-keybindings/README.md @@ -1,27 +1,26 @@ # Tmux Keybindings -Apply a canonical tmux-style keymap to Herdr and keep a resize-aware binding reference beside the active terminal. Press `Ctrl+B /` to open the panel as a right-side split without moving focus; repeat `Ctrl+B /` to close it. Herdr's native `Ctrl+B ?` shortcut continues to open its `? keybinds` help. +Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Option+Command+T` to open it; repeat the chord inside the dialog or press `Esc` to close it. This is a direct Herdr binding and leaves the built-in `prefix+?` help binding untouched. The plugin owns only the bindings below. It preserves comments, unrelated settings, and unrelated custom commands in `config.toml`, records the original managed values under `HERDR_PLUGIN_STATE_DIR`, validates every change with `herdr config check`, and rolls back a rejected edit atomically. -| Prefix key | Action | -| ---------- | --------------------------- | -| `/` | Toggle the keybinding panel | -| `?` | Open Herdr keybinding help | -| `d` | Detach | -| `c` | New tab | -| `,` | Rename tab | -| `n` / `p` | Next / previous tab | -| `1..9` | Switch tab | -| `&` | Close tab | -| `%` / `"` | Split right / down | -| arrows | Focus the adjacent pane | -| `o` | Cycle panes | -| `;` | Focus the last pane | -| `x` | Close pane | -| `z` | Zoom pane | -| `[` | Copy mode | -| `w` | Workspace navigation | +| Prefix key | Action | +| ------------------ | ----------------------------- | +| `Option+Command+T` | Open/close the binding dialog | +| `d` | Detach | +| `c` | New tab | +| `,` | Rename tab | +| `n` / `p` | Next / previous tab | +| `1..9` | Switch tab | +| `&` | Close tab | +| `%` / `"` | Split right / down | +| arrows | Focus the adjacent pane | +| `o` | Cycle panes | +| `;` | Focus the last pane | +| `x` | Close pane | +| `z` | Zoom pane | +| `[` | Copy mode | +| `w` | Workspace navigation | ## Install and apply @@ -32,19 +31,19 @@ herdr plugin action invoke oullin.tmux-keybindings.apply The apply action follows `HERDR_CONFIG_PATH` when it is set. Otherwise it uses `%APPDATA%\herdr\config.toml` on Windows and `~/.config/herdr/config.toml` (or `$XDG_CONFIG_HOME/herdr/config.toml`) on Linux and macOS. A workspace-start hook idempotently reapplies the profile after installation; restoring the original bindings disables that automatic apply until the manual apply action is invoked again. -## Toggle the panel +## Open the dialog -Use `Ctrl+B /`, or invoke the action directly. Repeat `Ctrl+B /` to close the panel: +Use `Option+Command+T`, or invoke the action directly. The underlying Herdr binding is `alt+super+t`, so it does not depend on a terminal-specific bridge: ```sh herdr plugin action invoke oullin.tmux-keybindings.toggle ``` -Pane identity is tracked independently for each workspace and tab. If the panel was closed manually, the next toggle discards the stale record and opens a fresh panel. Herdr's normal session layout persistence keeps an open panel in the tab layout across detach and reattach. +The popup is session-modal and leaves the tiled tab layout unchanged. Its title keeps Esc visible, and the footer lists both supported closing shortcuts. ## Restore and uninstall -Restore the original managed values and any displaced `prefix+/` custom command. Installations upgraded from version 0.1.1 also retain custom commands saved when the panel used `prefix+?`: +Restore the values and any `alt+super+t` custom command that existed before the first apply: ```sh herdr plugin action invoke oullin.tmux-keybindings.restore diff --git a/plugins/tmux-keybindings/herdr-plugin.toml b/plugins/tmux-keybindings/herdr-plugin.toml index 54f84b5..511b23e 100644 --- a/plugins/tmux-keybindings/herdr-plugin.toml +++ b/plugins/tmux-keybindings/herdr-plugin.toml @@ -1,8 +1,8 @@ id = "oullin.tmux-keybindings" name = "Tmux Keybindings" -version = "0.1.2" -min_herdr_version = "0.7.0" -description = "Apply a canonical tmux keymap and toggle a persistent binding panel" +version = "0.2.0" +min_herdr_version = "0.7.4" +description = "Apply a canonical tmux keymap and open a modal binding reference" platforms = ["linux", "macos", "windows"] [[build]] @@ -26,8 +26,8 @@ command = ["node", "index.ts", "apply"] [[actions]] id = "toggle" -title = "Toggle tmux keybinding panel" -description = "Open or close the keybinding panel for the active tab" +title = "Open tmux keybinding dialog" +description = "Open the modal keybinding reference for the active pane" contexts = ["global"] command = ["node", "index.ts", "toggle"] @@ -45,5 +45,7 @@ command = ["node", "index.ts", "apply", "--automatic"] [[panes]] id = "bindings" title = "Tmux keybindings" -placement = "split" +placement = "popup" +width = 84 +height = 30 command = ["node", "index.ts", "bindings"] diff --git a/plugins/tmux-keybindings/index.ts b/plugins/tmux-keybindings/index.ts index 73c29cb..51b1134 100644 --- a/plugins/tmux-keybindings/index.ts +++ b/plugins/tmux-keybindings/index.ts @@ -26,6 +26,7 @@ export { KeybindingConfigEditor } from '#tmux-keybindings/infrastructure/keybind export { StateDirectoryResolver } from '#tmux-keybindings/infrastructure/state-directory-resolver'; export { BindingsPanelApplication } from '#tmux-keybindings/presentation/bindings-panel-application'; export { BindingsPanelRenderer } from '#tmux-keybindings/presentation/bindings-panel-renderer'; +export { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; export { PluginApplication } from '#tmux-keybindings/presentation/plugin-application'; export { AtomicFileStore, HerdrConfigPathResolver, JsonFileStore, PluginContext, PluginStateDirectoryResolver } from '@oullin/herdr-plugin-core'; @@ -39,7 +40,7 @@ if (process.argv[1] && import.meta.url === pathToFileURL(process.argv[1]).href) const state = new JsonStateRepository(stateDirectory); const client = new HerdrCliClient(); const configuration = new ConfigurationManager(client, new KeybindingConfigEditor(), new ConfigPathResolver(), state); - const toggle = new PanelToggle(client, state); + const toggle = new PanelToggle(client); new PluginApplication(configuration, toggle).run(action, process.env, process.argv.includes('--automatic')); } diff --git a/plugins/tmux-keybindings/package.json b/plugins/tmux-keybindings/package.json index 3c0be9c..033e69f 100644 --- a/plugins/tmux-keybindings/package.json +++ b/plugins/tmux-keybindings/package.json @@ -1,7 +1,7 @@ { "name": "@oullin/herdr-tmux-keybindings", - "version": "0.1.2", - "description": "Apply a canonical tmux keymap and toggle a persistent Herdr binding panel.", + "version": "0.2.0", + "description": "Apply a canonical tmux keymap and open a modal Herdr binding reference.", "license": "MIT", "repository": { "type": "git", diff --git a/plugins/tmux-keybindings/src/application/configuration-manager.ts b/plugins/tmux-keybindings/src/application/configuration-manager.ts index 2fd3d5b..1db68f9 100644 --- a/plugins/tmux-keybindings/src/application/configuration-manager.ts +++ b/plugins/tmux-keybindings/src/application/configuration-manager.ts @@ -29,12 +29,16 @@ export class ConfigurationManager { const configPath = this.pathResolver.resolve(environment); const originalExists = this.files.exists(configPath); const original = this.files.read(configPath) ?? ''; - const edit = this.editor.apply(original, configPath); const savedSnapshot = this.state.loadConfigurationSnapshot(configPath); + const requiresMigration = savedSnapshot !== undefined && savedSnapshot.version < 5; + const baseline = requiresMigration ? this.editor.restore(original, savedSnapshot) : original; + const edit = this.editor.apply(baseline, configPath); this.writeAndValidate(configPath, original, originalExists, edit.content); - this.state.saveConfigurationSnapshot(this.editor.mergeSnapshots(savedSnapshot, edit.snapshot)); + const snapshot = requiresMigration ? edit.snapshot : this.editor.mergeSnapshots(savedSnapshot, edit.snapshot); + + this.state.saveConfigurationSnapshot(snapshot); if (!automatic) { this.state.setAutomaticApplyDisabled(false); diff --git a/plugins/tmux-keybindings/src/application/panel-toggle.ts b/plugins/tmux-keybindings/src/application/panel-toggle.ts index 9aea7c4..9d0ccfa 100644 --- a/plugins/tmux-keybindings/src/application/panel-toggle.ts +++ b/plugins/tmux-keybindings/src/application/panel-toggle.ts @@ -1,51 +1,26 @@ import type { HerdrClientPort } from '#tmux-keybindings/application/ports/herdr-client-port'; -import type { StateRepositoryPort } from '#tmux-keybindings/application/ports/state-repository-port'; import type { Environment } from '#tmux-keybindings/domain/models'; import { PluginError } from '#tmux-keybindings/errors/plugin-error'; import { PluginContext } from '@oullin/herdr-plugin-core'; -export type ToggleResult = 'opened' | 'closed'; +export type ToggleResult = 'opened'; export class PanelToggle { private readonly client: HerdrClientPort; - private readonly state: StateRepositoryPort; - constructor(client: HerdrClientPort, state: StateRepositoryPort) { + constructor(client: HerdrClientPort) { this.client = client; - this.state = state; } toggle(environment: Environment = process.env): ToggleResult { const context = new PluginContext(environment); - const workspaceId = context.workspaceId(); - const tabId = context.tabId(); const activePaneId = context.paneId(); - if (!workspaceId || !tabId || !activePaneId) { - throw new PluginError('The keybinding panel requires an active workspace, tab, and pane'); + if (!activePaneId) { + throw new PluginError('The keybinding dialog requires an active pane'); } - const trackedPaneId = this.state.getTrackedPane(workspaceId, tabId); - - if (trackedPaneId) { - const pane = this.client.getPane(trackedPaneId); - - if (pane && pane.workspace_id === workspaceId && pane.tab_id === tabId && this.client.closePluginPane(trackedPaneId)) { - this.state.deleteTrackedPane(workspaceId, tabId); - - return 'closed'; - } - - this.state.deleteTrackedPane(workspaceId, tabId); - } - - const pane = this.client.openBindingsPane(activePaneId); - - if (pane.workspace_id !== workspaceId || pane.tab_id !== tabId) { - throw new PluginError('Herdr opened the keybinding panel outside the active tab'); - } - - this.state.setTrackedPane(workspaceId, tabId, pane.pane_id); + this.client.openBindingsPopup(); return 'opened'; } diff --git a/plugins/tmux-keybindings/src/application/ports/herdr-client-port.ts b/plugins/tmux-keybindings/src/application/ports/herdr-client-port.ts index 598f984..4900230 100644 --- a/plugins/tmux-keybindings/src/application/ports/herdr-client-port.ts +++ b/plugins/tmux-keybindings/src/application/ports/herdr-client-port.ts @@ -1,8 +1,5 @@ -import type { Pane } from '#tmux-keybindings/domain/models'; import type { ConfigClient } from '@oullin/herdr-plugin-core'; export interface HerdrClientPort extends ConfigClient { - getPane(paneId: string): Pane | undefined; - openBindingsPane(targetPaneId: string): Pane; - closePluginPane(paneId: string): boolean; + openBindingsPopup(): void; } diff --git a/plugins/tmux-keybindings/src/application/ports/state-repository-port.ts b/plugins/tmux-keybindings/src/application/ports/state-repository-port.ts index 135923b..5667cc0 100644 --- a/plugins/tmux-keybindings/src/application/ports/state-repository-port.ts +++ b/plugins/tmux-keybindings/src/application/ports/state-repository-port.ts @@ -6,7 +6,4 @@ export interface StateRepositoryPort { deleteConfigurationSnapshot(configPath: string): void; isAutomaticApplyDisabled(): boolean; setAutomaticApplyDisabled(disabled: boolean): void; - getTrackedPane(workspaceId: string, tabId: string): string | undefined; - setTrackedPane(workspaceId: string, tabId: string, paneId: string): void; - deleteTrackedPane(workspaceId: string, tabId: string): void; } diff --git a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts index 9e1e5f0..9eeb42e 100644 --- a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts +++ b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts @@ -19,9 +19,18 @@ export const PLUGIN_ID = 'oullin.tmux-keybindings'; export const PANE_ENTRYPOINT = 'bindings'; +export const DIALOG_SHORTCUT = 'alt+super+t'; + +export const GHOSTTY_BRIDGE_DIALOG_SHORTCUT = 'prefix+ctrl+g'; + +export const HYPER_DIALOG_SHORTCUT = 'prefix+ctrl+alt+shift+super+q'; + +export const SUPER_DIALOG_SHORTCUT = 'prefix+super+q'; + +export const LEGACY_DIALOG_SHORTCUT = 'prefix+?'; + export const KEYBINDING_PROFILE: readonly KeybindingAssignment[] = [ { key: 'prefix', value: 'ctrl+b' }, - { key: 'help', value: 'prefix+?' }, { key: 'detach', value: 'prefix+d' }, { key: 'new_tab', value: 'prefix+c' }, { key: 'rename_tab', value: 'prefix+comma' }, @@ -48,8 +57,7 @@ export const PANEL_GROUPS: readonly BindingGroup[] = [ title: 'Global', bindings: [ { chord: 'Ctrl+B', description: 'prefix' }, - { chord: 'Ctrl+B /', description: 'open / close this panel' }, - { chord: '?', description: 'keybinds' }, + { chord: 'Option+Command+T', description: 'open / close dialog' }, { chord: 'd', description: 'detach' }, { chord: 'w', description: 'workspace navigation' }, ], diff --git a/plugins/tmux-keybindings/src/domain/models.ts b/plugins/tmux-keybindings/src/domain/models.ts index f883617..2444634 100644 --- a/plugins/tmux-keybindings/src/domain/models.ts +++ b/plugins/tmux-keybindings/src/domain/models.ts @@ -1,7 +1,7 @@ -export type { Environment, Pane } from '@oullin/herdr-plugin-core'; +export type { Environment } from '@oullin/herdr-plugin-core'; export interface ConfigurationSnapshot { - readonly version: 1 | 2; + readonly version: 1 | 2 | 3 | 4 | 5; readonly configPath: string; readonly keysSectionExisted: boolean; readonly assignments: Readonly>; diff --git a/plugins/tmux-keybindings/src/infrastructure/herdr-cli-client.ts b/plugins/tmux-keybindings/src/infrastructure/herdr-cli-client.ts index ca6464b..727abf9 100644 --- a/plugins/tmux-keybindings/src/infrastructure/herdr-cli-client.ts +++ b/plugins/tmux-keybindings/src/infrastructure/herdr-cli-client.ts @@ -1,17 +1,9 @@ import type { HerdrClientPort } from '#tmux-keybindings/application/ports/herdr-client-port'; import { PANE_ENTRYPOINT, PLUGIN_ID } from '#tmux-keybindings/domain/keybinding-profile'; -import type { Pane } from '#tmux-keybindings/domain/models'; import { HerdrCliClient as CoreHerdrCliClient } from '@oullin/herdr-plugin-core'; export class HerdrCliClient extends CoreHerdrCliClient implements HerdrClientPort { - openBindingsPane(targetPaneId: string): Pane { - return this.openPluginPane({ - pluginId: PLUGIN_ID, - entrypoint: PANE_ENTRYPOINT, - targetPaneId, - placement: 'split', - direction: 'right', - focus: false, - }); + openBindingsPopup(): void { + this.run(['plugin', 'pane', 'open', '--plugin', PLUGIN_ID, '--entrypoint', PANE_ENTRYPOINT, '--placement', 'popup']); } } diff --git a/plugins/tmux-keybindings/src/infrastructure/json-state-repository.ts b/plugins/tmux-keybindings/src/infrastructure/json-state-repository.ts index 5c7fddf..c499bf0 100644 --- a/plugins/tmux-keybindings/src/infrastructure/json-state-repository.ts +++ b/plugins/tmux-keybindings/src/infrastructure/json-state-repository.ts @@ -5,10 +5,6 @@ import type { StateRepositoryPort } from '#tmux-keybindings/application/ports/st import type { ConfigurationSnapshot } from '#tmux-keybindings/domain/models'; import { JsonFileStore } from '@oullin/herdr-plugin-core'; -interface PaneState { - readonly panes: Readonly>; -} - export class JsonStateRepository implements StateRepositoryPort { private readonly stateDirectory: string; private readonly store: JsonFileStore; @@ -38,26 +34,6 @@ export class JsonStateRepository implements StateRepositoryPort { this.writeJson(join(this.stateDirectory, 'automatic-apply.json'), { disabled }); } - getTrackedPane(workspaceId: string, tabId: string): string | undefined { - return this.readPaneState().panes[this.paneKey(workspaceId, tabId)]; - } - - setTrackedPane(workspaceId: string, tabId: string, paneId: string): void { - const state = this.readPaneState(); - - this.writeJson(join(this.stateDirectory, 'panes.json'), { - panes: { ...state.panes, [this.paneKey(workspaceId, tabId)]: paneId }, - }); - } - - deleteTrackedPane(workspaceId: string, tabId: string): void { - const state = this.readPaneState(); - const panes = { ...state.panes }; - - delete panes[this.paneKey(workspaceId, tabId)]; - this.writeJson(join(this.stateDirectory, 'panes.json'), { panes }); - } - private snapshotPath(configPath: string): string { const digest = createHash('sha256') .update(configPath) @@ -67,14 +43,6 @@ export class JsonStateRepository implements StateRepositoryPort { return join(this.stateDirectory, 'configuration', `${digest}.json`); } - private paneKey(workspaceId: string, tabId: string): string { - return `${workspaceId}:${tabId}`; - } - - private readPaneState(): PaneState { - return this.readJson(join(this.stateDirectory, 'panes.json')) ?? { panes: {} }; - } - private readJson(path: string): T | undefined { return this.store.read(path); } diff --git a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts index 6cc53b6..8835eb7 100644 --- a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts +++ b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts @@ -1,6 +1,7 @@ -import { KEYBINDING_PROFILE, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; import type { ConfigurationEdit, ConfigurationSnapshot } from '#tmux-keybindings/domain/models'; +import { DIALOG_SHORTCUT, KEYBINDING_PROFILE, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; + interface CommandBlock { readonly start: number; readonly end: number; @@ -12,7 +13,7 @@ interface CommandBlock { const tableHeaderPattern = /^\s*\[.+\]\s*(?:#.*)?$/u; const keysHeaderPattern = /^\s*\[keys\]\s*(?:#.*)?$/u; const commandHeaderPattern = /^\s*\[\[keys\.command\]\]\s*(?:#.*)?$/u; -const toggleChord = 'prefix+/'; +const restorableAssignmentKeys = [...KEYBINDING_PROFILE.map((binding) => binding.key), 'help']; export class KeybindingConfigEditor { apply(source: string, configPath: string): ConfigurationEdit { @@ -25,9 +26,9 @@ export class KeybindingConfigEditor { } const commandBlocks = this.commandBlocks(sourceLines); - const managedCommandBlocks = commandBlocks.filter((block) => block.key === toggleChord || block.command === TOGGLE_ACTION_ID); + const managedCommandBlocks = commandBlocks.filter((block) => block.key === DIALOG_SHORTCUT || block.command === TOGGLE_ACTION_ID); - const displacedCommands = managedCommandBlocks.filter((block) => block.key === toggleChord && block.command !== TOGGLE_ACTION_ID).map((block) => ({ line: block.start, text: block.text })); + const displacedCommands = managedCommandBlocks.filter((block) => block.key === DIALOG_SHORTCUT && block.command !== TOGGLE_ACTION_ID).map((block) => ({ line: block.start, text: block.text })); let lines = this.removeCommandBlocks(sourceLines, managedCommandBlocks); @@ -37,7 +38,7 @@ export class KeybindingConfigEditor { return { content: lines.join('\n'), snapshot: { - version: 2, + version: 5, configPath, keysSectionExisted: keysSection !== undefined, assignments, @@ -52,7 +53,7 @@ export class KeybindingConfigEditor { } return { - version: 2, + version: 5, configPath: saved.configPath, keysSectionExisted: saved.keysSectionExisted, assignments: saved.assignments, @@ -128,8 +129,8 @@ export class KeybindingConfigEditor { let section = this.keysSection(lines); if (!section) { - const originals = KEYBINDING_PROFILE.flatMap((binding) => { - const assignment = snapshot.assignments[binding.key]; + const originals = restorableAssignmentKeys.flatMap((key) => { + const assignment = snapshot.assignments[key]; return assignment === null || assignment === undefined ? [] : [assignment]; }); @@ -148,9 +149,9 @@ export class KeybindingConfigEditor { const missing: string[] = []; - for (const binding of KEYBINDING_PROFILE) { - const index = this.assignmentIndex(lines, section, binding.key); - const original = snapshot.assignments[binding.key]; + for (const key of restorableAssignmentKeys) { + const original = snapshot.assignments[key]; + const index = this.assignmentIndex(lines, section, key); if (original === null || original === undefined) { if (index !== undefined) { @@ -197,10 +198,10 @@ export class KeybindingConfigEditor { private appendManagedCommand(sourceLines: readonly string[]): string[] { return this.appendBlock(sourceLines, [ '[[keys.command]]', - `key = "${toggleChord}"`, + `key = "${DIALOG_SHORTCUT}"`, 'type = "plugin_action"', `command = "${TOGGLE_ACTION_ID}"`, - 'description = "toggle tmux keybinding panel"', + 'description = "open tmux keybinding dialog"', ]); } diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-application.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-application.ts index b803dd2..0de5106 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-application.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-application.ts @@ -1,4 +1,5 @@ import type { BindingsPanelRenderer } from '#tmux-keybindings/presentation/bindings-panel-renderer'; +import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; export class BindingsPanelApplication { private readonly renderer: BindingsPanelRenderer; @@ -8,6 +9,10 @@ export class BindingsPanelApplication { } run(): void { + const closeShortcut = new PanelCloseShortcut(); + + let finished = false; + const render = (): void => { const width = process.stdout.columns ?? 48; const height = process.stdout.rows ?? 24; @@ -15,6 +20,12 @@ export class BindingsPanelApplication { process.stdout.write(`\u001B[2J\u001B[H\u001B[?25l${this.renderer.render(width, height)}`); }; const finish = (): void => { + if (finished) { + return; + } + + finished = true; + process.stdin.setRawMode?.(false); process.stdout.write('\u001B[?25h'); process.exit(0); }; @@ -24,6 +35,12 @@ export class BindingsPanelApplication { process.on('SIGWINCH', render); process.on('SIGINT', finish); process.on('SIGTERM', finish); + process.stdin.setRawMode?.(true); + process.stdin.on('data', (input: Buffer) => { + if (closeShortcut.accept(input)) { + finish(); + } + }); process.stdin.resume(); } } diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts index 99a5c7b..3333b4c 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -1,67 +1,81 @@ -import { PANEL_GROUPS, type BindingDescription, type BindingGroup } from '#tmux-keybindings/domain/keybinding-profile'; +import { PANEL_GROUPS } from '#tmux-keybindings/domain/keybinding-profile'; +import { DialogPalette } from '#tmux-keybindings/presentation/terminal/dialog-palette'; +import { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; +import { TerminalLine } from '#tmux-keybindings/presentation/terminal/terminal-line'; -const maximumChordWidth = 12; -const minimumChordWidth = 7; -const chordWidthRatio = 0.28; +const minimumWidth = 20; +const minimumHeight = 4; +const shortcutWidth = 20; export class BindingsPanelRenderer { render(width: number, height: number): string { - const safeWidth = Math.max(20, width); - const safeHeight = Math.max(4, height); - const contentWidth = safeWidth - 4; - const lines = safeWidth >= 72 ? this.wideLines(contentWidth) : this.narrowLines(contentWidth); - const header = this.centre('TMUX KEYBINDINGS', contentWidth); - const prefix = this.centre('Ctrl+B, then key', contentWidth); - const body = [header, prefix, '', ...lines]; - const visible = body.slice(0, safeHeight - 1); - - if (body.length > visible.length) { - visible[visible.length - 1] = this.truncate('… resize pane to see all bindings', contentWidth); - } - - return visible.map((line) => ` ${this.truncate(line, contentWidth)}`).join('\n'); - } - - private narrowLines(width: number): readonly string[] { - return PANEL_GROUPS.flatMap((group, index) => [...(index === 0 ? [] : ['']), group.title.toUpperCase(), ...group.bindings.map((binding) => this.bindingLine(binding, width))]); + const safeWidth = Math.max(minimumWidth, width); + const safeHeight = Math.max(minimumHeight, height); + const palette = DialogPalette.tmux(); + const contentRows = this.contentRows(safeWidth, palette); + const footer = this.footer(palette); + const visibleRows = this.visibleRows(contentRows, footer, safeHeight, palette); + + return visibleRows.map((segments) => TerminalLine.from(segments, safeWidth, palette.background).render()).join('\n'); } - private wideLines(width: number): readonly string[] { - const columnWidth = Math.floor((width - 3) / 2); - const columns: readonly BindingGroup[][] = [[PANEL_GROUPS[0] as BindingGroup, PANEL_GROUPS[1] as BindingGroup], [PANEL_GROUPS[2] as BindingGroup]]; - - const rendered = columns.map((groups) => - groups.flatMap((group, index) => [...(index === 0 ? [] : ['']), group.title.toUpperCase(), ...group.bindings.map((binding) => this.bindingLine(binding, columnWidth))]), - ); - - const rowCount = Math.max(...rendered.map((column) => column.length)); - const lines: string[] = []; - - for (let row = 0; row < rowCount; row += 1) { - const left = (rendered[0]?.[row] ?? '').padEnd(columnWidth); - const right = rendered[1]?.[row] ?? ''; - - lines.push(`${left} │ ${right}`.trimEnd()); + private contentRows(width: number, palette: DialogPalette): readonly (readonly StyledSegment[])[] { + const rows: (readonly StyledSegment[])[] = [ + this.title(width, palette), + this.text('available commands and configured shortcuts', palette.mutedText, palette), + this.text('', palette.primaryText, palette), + ]; + + for (const [groupIndex, group] of PANEL_GROUPS.entries()) { + if (groupIndex > 0) { + rows.push(this.text('', palette.primaryText, palette)); + } + + rows.push(this.text(group.title.toLowerCase(), palette.sectionHeading, palette, true)); + + for (const binding of group.bindings) { + rows.push([ + StyledSegment.from(binding.chord.padEnd(shortcutWidth), palette.shortcut, palette.background, false), + StyledSegment.from(binding.description, palette.primaryText, palette.background, false), + ]); + } } - return lines; + return rows; } - private bindingLine(binding: BindingDescription, width: number): string { - const chordWidth = Math.min(maximumChordWidth, Math.max(minimumChordWidth, Math.floor(width * chordWidthRatio))); + private title(width: number, palette: DialogPalette): readonly StyledSegment[] { + const title = 'tmux keybindings'; + const badge = 'esc close'; + const spacing = ' '.repeat(Math.max(1, width - Array.from(title).length - Array.from(badge).length)); - return `${binding.chord.padEnd(chordWidth)} ${binding.description}`; + return [ + StyledSegment.from(title, palette.primaryText, palette.background, true), + StyledSegment.from(spacing, palette.primaryText, palette.background, false), + StyledSegment.from(badge, palette.closeBadgeText, palette.closeBadgeBackground, true), + ]; } - private centre(value: string, width: number): string { - return value.padStart(Math.max(value.length, Math.floor((width + value.length) / 2))); + private footer(palette: DialogPalette): readonly StyledSegment[] { + return this.text('toggle Option+Command+T close Esc', palette.mutedText, palette); } - private truncate(value: string, width: number): string { - if (value.length <= width) { - return value; + private visibleRows(contentRows: readonly (readonly StyledSegment[])[], footer: readonly StyledSegment[], height: number, palette: DialogPalette): readonly (readonly StyledSegment[])[] { + const contentHeight = height - 1; + const visible = contentRows.slice(0, contentHeight); + + if (contentRows.length > contentHeight) { + visible[visible.length - 1] = this.text('... resize dialog to see all bindings', palette.mutedText, palette); + } + + while (visible.length < contentHeight) { + visible.push(this.text('', palette.primaryText, palette)); } - return `${value.slice(0, Math.max(0, width - 1))}…`; + return [...visible, footer]; + } + + private text(value: string, foreground: string, palette: DialogPalette, emphasized = false): readonly StyledSegment[] { + return [StyledSegment.from(value, foreground, palette.background, emphasized)]; } } diff --git a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts new file mode 100644 index 0000000..ffc184e --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts @@ -0,0 +1,13 @@ +const controlC = 0x03; +const escape = 0x1b; +const legacyAltT = 0x74; + +export class PanelCloseShortcut { + accept(input: Uint8Array): boolean { + if (input.length === 1) { + return input[0] === escape || input[0] === controlC; + } + + return input.length === 2 && input[0] === escape && input[1] === legacyAltT; + } +} diff --git a/plugins/tmux-keybindings/src/presentation/plugin-application.ts b/plugins/tmux-keybindings/src/presentation/plugin-application.ts index e96e554..2c19f51 100644 --- a/plugins/tmux-keybindings/src/presentation/plugin-application.ts +++ b/plugins/tmux-keybindings/src/presentation/plugin-application.ts @@ -26,7 +26,7 @@ export class PluginApplication { } case 'toggle': { - return `Tmux keybinding panel: ${this.panelToggle.toggle(environment)}`; + return `Tmux keybinding dialog: ${this.panelToggle.toggle(environment)}`; } default: diff --git a/plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts b/plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts new file mode 100644 index 0000000..33dc398 --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts @@ -0,0 +1,68 @@ +/** Immutable colours used to render the tmux keybindings dialog. */ +export class DialogPalette { + private readonly backgroundValue: string; + private readonly primaryTextValue: string; + private readonly mutedTextValue: string; + private readonly sectionHeadingValue: string; + private readonly shortcutValue: string; + private readonly closeBadgeBackgroundValue: string; + private readonly closeBadgeTextValue: string; + + private constructor( + backgroundValue: string, + primaryTextValue: string, + mutedTextValue: string, + sectionHeadingValue: string, + shortcutValue: string, + closeBadgeBackgroundValue: string, + closeBadgeTextValue: string, + ) { + this.backgroundValue = backgroundValue; + this.primaryTextValue = primaryTextValue; + this.mutedTextValue = mutedTextValue; + this.sectionHeadingValue = sectionHeadingValue; + this.shortcutValue = shortcutValue; + this.closeBadgeBackgroundValue = closeBadgeBackgroundValue; + this.closeBadgeTextValue = closeBadgeTextValue; + } + + /** Creates the tmux help-dialog palette. */ + static tmux(): DialogPalette { + return new DialogPalette('#1a1b26', '#c0caf5', '#737aa2', '#7dcfff', '#bb9af7', '#7aa2f7', '#1a1b26'); + } + + /** Returns the dialog background colour. */ + get background(): string { + return this.backgroundValue; + } + + /** Returns the primary text colour. */ + get primaryText(): string { + return this.primaryTextValue; + } + + /** Returns the muted text colour. */ + get mutedText(): string { + return this.mutedTextValue; + } + + /** Returns the section-heading colour. */ + get sectionHeading(): string { + return this.sectionHeadingValue; + } + + /** Returns the shortcut colour. */ + get shortcut(): string { + return this.shortcutValue; + } + + /** Returns the close-badge background colour. */ + get closeBadgeBackground(): string { + return this.closeBadgeBackgroundValue; + } + + /** Returns the close-badge text colour. */ + get closeBadgeText(): string { + return this.closeBadgeTextValue; + } +} diff --git a/plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts b/plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts new file mode 100644 index 0000000..df7222f --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts @@ -0,0 +1,39 @@ +/** Immutable terminal text with explicit foreground, background, and emphasis. */ +export class StyledSegment { + private readonly textValue: string; + private readonly foregroundValue: string; + private readonly backgroundValue: string; + private readonly emphasizedValue: boolean; + + private constructor(textValue: string, foregroundValue: string, backgroundValue: string, emphasizedValue: boolean) { + this.textValue = textValue; + this.foregroundValue = foregroundValue; + this.backgroundValue = backgroundValue; + this.emphasizedValue = emphasizedValue; + } + + /** Creates a styled terminal-text segment. */ + static from(text: string, foreground: string, background: string, emphasized: boolean): StyledSegment { + return new StyledSegment(text, foreground, background, emphasized); + } + + /** Returns the unstyled text. */ + get text(): string { + return this.textValue; + } + + /** Returns the foreground colour. */ + get foreground(): string { + return this.foregroundValue; + } + + /** Returns the background colour. */ + get background(): string { + return this.backgroundValue; + } + + /** Reports whether the segment is emphasized. */ + get emphasized(): boolean { + return this.emphasizedValue; + } +} diff --git a/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts new file mode 100644 index 0000000..5f17668 --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts @@ -0,0 +1,95 @@ +import type { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; + +const escape = String.fromCodePoint(0x1b); + +/** Renders immutable styled segments to one fixed-width terminal row. */ +export class TerminalLine { + private readonly segments: readonly StyledSegment[]; + private readonly width: number; + private readonly background: string; + + private constructor(segments: readonly StyledSegment[], width: number, background: string) { + this.segments = segments; + this.width = width; + this.background = background; + } + + /** Creates a fixed-width terminal row from concrete styled segments. */ + static from(segments: readonly StyledSegment[], width: number, background: string): TerminalLine { + return new TerminalLine(Object.freeze([...segments]), width, background); + } + + /** Renders ANSI styling after measuring, truncating, and padding unstyled code points. */ + render(): string { + const renderedSegments: string[] = []; + + let visibleWidth = 0; + + for (const segment of this.segments) { + const remainingWidth = this.width - visibleWidth; + + if (remainingWidth <= 0) { + break; + } + + const text = this.truncate(segment.text, remainingWidth); + + if (text.length === 0) { + continue; + } + + renderedSegments.push(this.renderSegment(segment, text)); + visibleWidth += this.visibleWidth(text); + } + + const padding = ' '.repeat(Math.max(0, this.width - visibleWidth)); + + return `${renderedSegments.join('')}${this.backgroundSequence(this.background)}${padding}${escape}[0m`; + } + + private renderSegment(segment: StyledSegment, text: string): string { + const emphasis = segment.emphasized ? `${escape}[1m` : `${escape}[22m`; + + return `${this.backgroundSequence(segment.background)}${emphasis}${this.foregroundSequence(segment.foreground)}${text}`; + } + + private visibleWidth(value: string): number { + return Array.from(value).length; + } + + private truncate(value: string, width: number): string { + const codePoints = Array.from(value); + + if (codePoints.length <= width) { + return value; + } + + if (width === 1) { + return '…'; + } + + return `${codePoints.slice(0, Math.max(0, width - 1)).join('')}…`; + } + + private foregroundSequence(colour: string): string { + const [red, green, blue] = this.parseColour(colour); + + return `${escape}[38;2;${red};${green};${blue}m`; + } + + private backgroundSequence(colour: string): string { + const [red, green, blue] = this.parseColour(colour); + + return `${escape}[48;2;${red};${green};${blue}m`; + } + + private parseColour(colour: string): readonly [number, number, number] { + const hexadecimal = colour.startsWith('#') ? colour.slice(1) : colour; + + if (!/^[0-9a-f]{6}$/iu.test(hexadecimal)) { + return [0, 0, 0]; + } + + return [Number.parseInt(hexadecimal.slice(0, 2), 16), Number.parseInt(hexadecimal.slice(2, 4), 16), Number.parseInt(hexadecimal.slice(4, 6), 16)]; + } +} diff --git a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts index 6c6a566..a2a55d7 100644 --- a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts +++ b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts @@ -5,6 +5,7 @@ import { join } from 'node:path'; import { describe, expect, it } from 'vite-plus/test'; import { ConfigurationManager } from '#tmux-keybindings/application/configuration-manager'; +import { KEYBINDING_PROFILE } from '#tmux-keybindings/domain/keybinding-profile'; import { ConfigPathResolver } from '#tmux-keybindings/infrastructure/config-path-resolver'; import { KeybindingConfigEditor } from '#tmux-keybindings/infrastructure/keybinding-config-editor'; import { FakeHerdrClient, FakeStateRepository } from '#tmux-keybindings/testing/support/fakes'; @@ -44,9 +45,10 @@ describe('ConfigurationManager', () => { const applied = readFileSync(configPath, 'utf8'); - expect(applied).toContain('help = "prefix+?"'); - expect(applied).toContain('key = "prefix+/"'); - expect(state.snapshots.get(configPath)?.version).toBe(2); + expect(applied).not.toContain('help ='); + expect(applied).toContain('key = "alt+super+t"'); + expect(state.snapshots.get(configPath)?.version).toBe(5); + expect( manager.apply(environment), ).toBe('unchanged'); @@ -62,83 +64,239 @@ describe('ConfigurationManager', () => { expect(state.automaticApplyDisabled).toBe(true); }); - it('migrates legacy state and preserves a newly displaced slash command', () => { + it('rolls back atomically when Herdr rejects the candidate', () => { const directory = mkdtempSync( join( tmpdir(), - 'herdr-tmux-migration-', + 'herdr-tmux-rollback-', ), ); const configPath = join(directory, 'config.toml'); - const originalAssignments = '[keys]\nprefix = "ctrl+a"\nhelp = "ctrl+h"\n'; - const legacyHelp = ['[[keys.command]]', 'key = "prefix+?"', 'type = "shell"', 'command = "old-help"', ''].join('\n'); - const slashCommand = ['[[keys.command]]', 'key = "prefix+/"', 'type = "shell"', 'command = "slash-help"', ''].join('\n'); - const editor = new KeybindingConfigEditor(); - const originalSnapshot = editor.apply(`${originalAssignments}\n${legacyHelp}`, configPath).snapshot; - const legacyApplied = editor.apply(originalAssignments, configPath).content.replace('help = "prefix+?"', 'help = ""').replace('key = "prefix+/"', 'key = "prefix+?"'); + const original = '[theme]\nname = "nord"\n'; + + writeFileSync(configPath, original); + + const { client, manager, state } = subject(); + + client.validationError = new Error('invalid config'); + + expect(() => manager.apply({ HERDR_CONFIG_PATH: configPath })).toThrowError('invalid config'); + expect( + readFileSync(configPath, 'utf8'), + ).toBe(original); + expect(state.snapshots.size).toBe(0); + expect(client.reloadCalls).toBe(0); + }); + + it('migrates the legacy help override to the dedicated dialog shortcut', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'herdr-tmux-migration-', + ), + ); - writeFileSync(configPath, `${legacyApplied}\n${slashCommand}`); + const configPath = join(directory, 'config.toml'); + const original = '[keys]\nprefix = "ctrl+a"\n'; + + const legacyApplied = [ + '[keys]', + 'prefix = "ctrl+b"', + 'help = ""', + '', + '[[keys.command]]', + 'key = "prefix+?"', + 'type = "plugin_action"', + 'command = "oullin.tmux-keybindings.toggle"', + 'description = "open tmux keybinding dialog"', + '', + ].join('\n'); + + writeFileSync(configPath, legacyApplied); const { manager, state } = subject(); state.snapshots.set(configPath, { - ...originalSnapshot, version: 1, - displacedCommands: [{ line: 4, text: legacyHelp }], + configPath, + keysSectionExisted: true, + assignments: { + ...Object.fromEntries(KEYBINDING_PROFILE.map(({ key }) => [key, null])), + prefix: 'prefix = "ctrl+a"', + help: null, + }, + displacedCommands: [], }); expect( manager.apply({ HERDR_CONFIG_PATH: configPath }), ).toBe('applied'); - const migrated = state.snapshots.get(configPath); + const migrated = readFileSync(configPath, 'utf8'); + + expect(migrated).not.toContain('help = ""'); + expect(migrated).not.toContain('key = "prefix+?"'); + expect(migrated).toContain('key = "alt+super+t"'); + expect(state.snapshots.get(configPath)?.version).toBe(5); + expect(state.snapshots.get(configPath)?.assignments).not.toHaveProperty('help'); - expect(migrated?.version).toBe(2); - expect(migrated?.assignments).toEqual(originalSnapshot.assignments); expect( - migrated?.displacedCommands.map((command) => command.text), - ).toEqual([legacyHelp, slashCommand]); + manager.restore({ HERDR_CONFIG_PATH: configPath }), + ).toBe('restored'); expect( - readFileSync(configPath, 'utf8'), - ).not.toContain('command = "slash-help"'); + readFileSync(configPath, 'utf8').trimEnd(), + ).toBe(original.trimEnd()); + }); + + it('migrates the super-only shortcut without losing the displaced command', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'herdr-tmux-super-migration-', + ), + ); + + const configPath = join(directory, 'config.toml'); + const displacedCommand = ['[[keys.command]]', 'key = "prefix+super+q"', 'type = "shell"', 'command = "old-super"', ''].join('\n'); + const original = ['[keys]', 'prefix = "ctrl+a"', '', displacedCommand].join('\n'); + + const superApplied = [ + '[keys]', + 'prefix = "ctrl+b"', + '', + '[[keys.command]]', + 'key = "prefix+super+q"', + 'type = "plugin_action"', + 'command = "oullin.tmux-keybindings.toggle"', + 'description = "open tmux keybinding dialog"', + '', + ].join('\n'); + + writeFileSync(configPath, superApplied); + + const { manager, state } = subject(); + + state.snapshots.set(configPath, { + version: 2, + configPath, + keysSectionExisted: true, + assignments: { + ...Object.fromEntries(KEYBINDING_PROFILE.map(({ key }) => [key, null])), + prefix: 'prefix = "ctrl+a"', + }, + displacedCommands: [{ line: 3, text: displacedCommand }], + }); + + expect( + manager.apply({ HERDR_CONFIG_PATH: configPath }), + ).toBe('applied'); + + const migrated = readFileSync(configPath, 'utf8'); + + expect(migrated).toContain('command = "old-super"'); + expect(migrated).toContain('key = "alt+super+t"'); + expect( + migrated.match(/oullin\.tmux-keybindings\.toggle/gu), + ).toHaveLength(1); + expect(state.snapshots.get(configPath)?.version).toBe(5); expect( manager.restore({ HERDR_CONFIG_PATH: configPath }), ).toBe('restored'); + expect( + readFileSync(configPath, 'utf8').trimEnd(), + ).toBe(original.trimEnd()); + }); + + it('migrates the Hyperkey modifier chord to the direct dialog shortcut', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'herdr-tmux-hyper-migration-', + ), + ); + + const configPath = join(directory, 'config.toml'); + + const hyperApplied = [ + '[keys]', + 'prefix = "ctrl+b"', + '', + '[[keys.command]]', + 'key = "prefix+ctrl+alt+shift+super+q"', + 'type = "plugin_action"', + 'command = "oullin.tmux-keybindings.toggle"', + 'description = "open tmux keybinding dialog"', + '', + ].join('\n'); + + writeFileSync(configPath, hyperApplied); + + const { manager, state } = subject(); + + state.snapshots.set(configPath, { + version: 3, + configPath, + keysSectionExisted: true, + assignments: Object.fromEntries(KEYBINDING_PROFILE.map(({ key }) => [key, null])), + displacedCommands: [], + }); - const restored = readFileSync(configPath, 'utf8'); + expect( + manager.apply({ HERDR_CONFIG_PATH: configPath }), + ).toBe('applied'); - expect(restored).toContain('prefix = "ctrl+a"'); - expect(restored).toContain('help = "ctrl+h"'); - expect(restored).toContain('command = "old-help"'); - expect(restored).toContain('command = "slash-help"'); - expect(restored).not.toContain('oullin.tmux-keybindings.toggle'); + const migrated = readFileSync(configPath, 'utf8'); + + expect(migrated).not.toContain('key = "prefix+ctrl+alt+shift+super+q"'); + expect(migrated).toContain('key = "alt+super+t"'); + expect(state.snapshots.get(configPath)?.version).toBe(5); }); - it('rolls back atomically when Herdr rejects the candidate', () => { + it('migrates the terminal bridge to the direct dialog shortcut', () => { const directory = mkdtempSync( join( tmpdir(), - 'herdr-tmux-rollback-', + 'herdr-tmux-bridge-migration-', ), ); const configPath = join(directory, 'config.toml'); - const original = '[theme]\nname = "nord"\n'; - writeFileSync(configPath, original); + const bridgeApplied = [ + '[keys]', + 'prefix = "ctrl+b"', + '', + '[[keys.command]]', + 'key = "prefix+ctrl+g"', + 'type = "plugin_action"', + 'command = "oullin.tmux-keybindings.toggle"', + 'description = "open tmux keybinding dialog"', + '', + ].join('\n'); - const { client, manager, state } = subject(); + writeFileSync(configPath, bridgeApplied); - client.validationError = new Error('invalid config'); + const { manager, state } = subject(); + + state.snapshots.set(configPath, { + version: 4, + configPath, + keysSectionExisted: true, + assignments: Object.fromEntries(KEYBINDING_PROFILE.map(({ key }) => [key, null])), + displacedCommands: [], + }); - expect(() => manager.apply({ HERDR_CONFIG_PATH: configPath })).toThrowError('invalid config'); expect( - readFileSync(configPath, 'utf8'), - ).toBe(original); - expect(state.snapshots.size).toBe(0); - expect(client.reloadCalls).toBe(0); + manager.apply({ HERDR_CONFIG_PATH: configPath }), + ).toBe('applied'); + + const migrated = readFileSync(configPath, 'utf8'); + + expect(migrated).not.toContain('key = "prefix+ctrl+g"'); + expect(migrated).toContain('key = "alt+super+t"'); + expect(state.snapshots.get(configPath)?.version).toBe(5); }); it('does not reapply automatically after a restore', () => { diff --git a/plugins/tmux-keybindings/tests/application/panel-toggle.test.ts b/plugins/tmux-keybindings/tests/application/panel-toggle.test.ts index 43eda5c..97f0443 100644 --- a/plugins/tmux-keybindings/tests/application/panel-toggle.test.ts +++ b/plugins/tmux-keybindings/tests/application/panel-toggle.test.ts @@ -1,85 +1,19 @@ import { describe, expect, it } from 'vite-plus/test'; import { PanelToggle } from '#tmux-keybindings/application/panel-toggle'; -import { FakeHerdrClient, FakeStateRepository } from '#tmux-keybindings/testing/support/fakes'; +import { FakeHerdrClient } from '#tmux-keybindings/testing/support/fakes'; describe('PanelToggle', () => { - const environment = { HERDR_WORKSPACE_ID: 'w1', HERDR_TAB_ID: 'w1:t1', HERDR_PANE_ID: 'w1:p1' }; - - it('opens a right-side non-focused plugin pane and tracks it per tab', () => { + it('opens a modal popup for the active pane', () => { const client = new FakeHerdrClient(); - const state = new FakeStateRepository(); - const toggle = new PanelToggle(client, state); expect( - toggle.toggle(environment), + new PanelToggle(client).toggle({ HERDR_PANE_ID: 'w1:p1' }), ).toBe('opened'); - expect(client.openedTargetPaneIds).toEqual(['w1:p1']); - expect( - state.getTrackedPane('w1', 'w1:t1'), - ).toBe('w1:p-panel'); - }); - - it('closes a tracked pane only in its workspace and tab', () => { - const client = new FakeHerdrClient(); - const state = new FakeStateRepository(); - - client.panes.set('w1:p-panel', client.openedPane); - state.setTrackedPane('w1', 'w1:t1', 'w1:p-panel'); - - expect( - new PanelToggle(client, state).toggle(environment), - ).toBe('closed'); - expect(client.closedPaneIds).toEqual(['w1:p-panel']); - expect( - state.getTrackedPane('w1', 'w1:t1'), - ).toBeUndefined(); + expect(client.popupOpenCalls).toBe(1); }); - it('recovers when a tracked pane was closed manually', () => { - const client = new FakeHerdrClient(); - const state = new FakeStateRepository(); - - state.setTrackedPane('w1', 'w1:t1', 'w1:p-stale'); - - expect( - new PanelToggle(client, state).toggle(environment), - ).toBe('opened'); - expect(client.closedPaneIds).toEqual([]); - expect( - state.getTrackedPane('w1', 'w1:t1'), - ).toBe('w1:p-panel'); - }); - - it('recovers when a stale id now belongs to a normal pane', () => { - const client = new FakeHerdrClient(); - const state = new FakeStateRepository(); - - client.panes.set('w1:p-stale', { pane_id: 'w1:p-stale', tab_id: 'w1:t1', workspace_id: 'w1' }); - client.closeResult = false; - state.setTrackedPane('w1', 'w1:t1', 'w1:p-stale'); - - expect( - new PanelToggle(client, state).toggle(environment), - ).toBe('opened'); - expect( - state.getTrackedPane('w1', 'w1:t1'), - ).toBe('w1:p-panel'); - }); - - it('keeps tracking independent between tabs', () => { - const client = new FakeHerdrClient(); - const state = new FakeStateRepository(); - - state.setTrackedPane('w1', 'w1:t2', 'w1:p-other-panel'); - - new PanelToggle(client, state).toggle(environment); - - expect( - state.getTrackedPane('w1', 'w1:t1'), - ).toBe('w1:p-panel'); - expect( - state.getTrackedPane('w1', 'w1:t2'), - ).toBe('w1:p-other-panel'); + it('requires an active pane', () => { + expect(() => new PanelToggle(new FakeHerdrClient()).toggle({})).toThrowError('The keybinding dialog requires an active pane'); }); }); diff --git a/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts b/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts index 8e2ec04..9084b28 100644 --- a/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts +++ b/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts @@ -17,8 +17,8 @@ describe('tmux-keybindings plugin contract', () => { ); expect(manifest).toContain('id = "oullin.tmux-keybindings"'); - expect(manifest).toContain('version = "0.1.2"'); - expect(manifest).toContain('min_herdr_version = "0.7.0"'); + expect(manifest).toContain('version = "0.2.0"'); + expect(manifest).toContain('min_herdr_version = "0.7.4"'); expect(manifest).toContain('[[build]]'); expect(manifest).toContain('"npm"'); expect(manifest).toContain('"--omit=dev"'); @@ -26,13 +26,16 @@ describe('tmux-keybindings plugin contract', () => { manifest.match(/^id = "(apply|toggle|restore|bindings)"$/gmu), ).toHaveLength(4); expect(manifest).toContain('on = "workspace.created"'); - expect(manifest).toContain('placement = "split"'); + expect(manifest).toContain('placement = "popup"'); + expect(manifest).toContain('width = 84'); + expect(manifest).toContain('height = 30'); }); it('uses concern aliases and is listed in the root catalogue', () => { const packageJson = JSON.parse(readFileSync(join(root, 'package.json'), 'utf8')) as { readonly dependencies?: Record; readonly imports?: Record; + readonly version?: string; }; const catalogue = readFileSync( @@ -54,6 +57,7 @@ describe('tmux-keybindings plugin contract', () => { '#tmux-keybindings/testing/*', ]); expect(catalogue).toContain('[Tmux Keybindings](plugins/tmux-keybindings)'); + expect(packageJson.version).toBe('0.2.0'); expect(packageJson.dependencies).toEqual({ '@oullin/herdr-plugin-core': 'file:../../package/core' }); }); @@ -67,7 +71,7 @@ describe('tmux-keybindings plugin contract', () => { 'utf8', ); - expect(packageJson.version).toBe('0.1.2'); + expect(packageJson.version).toBe('0.2.0'); expect(manifest).toContain(`version = "${packageJson.version}"`); }); }); diff --git a/plugins/tmux-keybindings/tests/domain/keybinding-profile.test.ts b/plugins/tmux-keybindings/tests/domain/keybinding-profile.test.ts index 0179be5..7afc6e0 100644 --- a/plugins/tmux-keybindings/tests/domain/keybinding-profile.test.ts +++ b/plugins/tmux-keybindings/tests/domain/keybinding-profile.test.ts @@ -8,7 +8,6 @@ describe('keybinding profile', () => { Object.fromEntries(KEYBINDING_PROFILE.map(({ key, value }) => [key, value])), ).toEqual({ prefix: 'ctrl+b', - help: 'prefix+?', detach: 'prefix+d', new_tab: 'prefix+c', rename_tab: 'prefix+comma', diff --git a/plugins/tmux-keybindings/tests/infrastructure/herdr-cli-client.test.ts b/plugins/tmux-keybindings/tests/infrastructure/herdr-cli-client.test.ts new file mode 100644 index 0000000..bb91f1c --- /dev/null +++ b/plugins/tmux-keybindings/tests/infrastructure/herdr-cli-client.test.ts @@ -0,0 +1,15 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { HerdrCliClient } from '#tmux-keybindings/infrastructure/herdr-cli-client'; +import { StubCommandRunner } from '@oullin/herdr-plugin-core/testing'; + +describe('HerdrCliClient', () => { + it('opens the bindings entrypoint as an active-pane popup', () => { + const runner = new StubCommandRunner({ error: undefined, status: 0, stdout: '', stderr: '' }); + const client = new HerdrCliClient('/mock/herdr', runner); + + client.openBindingsPopup(); + + expect(runner.calls[0]?.args).toEqual(['plugin', 'pane', 'open', '--plugin', 'oullin.tmux-keybindings', '--entrypoint', 'bindings', '--placement', 'popup']); + }); +}); diff --git a/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts b/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts index 8dcc354..bb04de0 100644 --- a/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts +++ b/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts @@ -14,7 +14,6 @@ describe('KeybindingConfigEditor', () => { const edit = editor.apply(source, '/config.toml'); expect(edit.content).toContain('prefix = "ctrl+b" # keep this explanation'); - expect(edit.content).toContain('help = "prefix+?"'); expect(edit.content).toContain('new_tab = "prefix+c"'); expect(edit.content).toContain('settings = "prefix+s"'); expect(edit.content).toContain('[theme]\nname = "nord"'); @@ -30,7 +29,7 @@ describe('KeybindingConfigEditor', () => { expect(edit.snapshot.assignments['prefix']).toBe("prefix = 'ctrl+#' # keep this explanation"); }); - it('keeps native help, displaces slash conflicts, and removes legacy plugin actions', () => { + it('preserves unrelated shortcuts, displaces direct conflicts, and removes legacy plugin actions', () => { const source = [ '[theme]', 'name = "nord"', @@ -41,9 +40,19 @@ describe('KeybindingConfigEditor', () => { 'command = "old-help"', '', '[[keys.command]]', - 'key = "prefix+/"', + 'key = "prefix+super+q"', 'type = "shell"', - 'command = "old-panel"', + 'command = "old-super"', + '', + '[[keys.command]]', + 'key = "prefix+ctrl+alt+shift+super+q"', + 'type = "shell"', + 'command = "old-hyper"', + '', + '[[keys.command]]', + 'key = "prefix+ctrl+g"', + 'type = "shell"', + 'command = "old-bridge"', '', '[[keys.command]]', 'key = "prefix+l"', @@ -51,41 +60,51 @@ describe('KeybindingConfigEditor', () => { 'command = "oullin.tmux-keybindings.toggle"', '', '[[keys.command]]', - 'key = "prefix+t"', + 'key = "alt+super+t"', 'type = "popup"', 'command = "lazygit"', '', + '[[keys.command]]', + 'key = "prefix+t"', + 'type = "popup"', + 'command = "gitui"', + '', ].join('\n'); const edit = editor.apply(source, '/config.toml'); + expect(edit.snapshot.version).toBe(5); expect(edit.content).toContain('[keys]\nprefix = "ctrl+b"'); - expect(edit.content).toContain('help = "prefix+?"'); - expect(edit.content).toContain('key = "prefix+?"\ntype = "shell"\ncommand = "old-help"'); - expect(edit.content).toContain('key = "prefix+/"\ntype = "plugin_action"'); - expect(edit.content).toContain('description = "toggle tmux keybinding panel"'); expect( edit.content.match(/oullin\.tmux-keybindings\.toggle/gu), ).toHaveLength(1); - expect(edit.content).toContain('command = "lazygit"'); - expect(edit.content).not.toContain('command = "old-panel"'); + expect(edit.content).toContain('command = "old-help"'); + expect(edit.content).toContain('command = "old-super"'); + expect(edit.content).toContain('command = "old-hyper"'); + expect(edit.content).toContain('command = "old-bridge"'); + expect(edit.content).toContain('command = "gitui"'); + expect(edit.content).not.toContain('command = "lazygit"'); + expect(edit.content).not.toContain('help = ""'); expect(edit.snapshot.displacedCommands).toEqual([ { - line: 8, - text: ['[[keys.command]]', 'key = "prefix+/"', 'type = "shell"', 'command = "old-panel"', ''].join('\n'), + line: 28, + text: ['[[keys.command]]', 'key = "alt+super+t"', 'type = "popup"', 'command = "lazygit"', ''].join('\n'), }, ]); const restored = editor.restore(edit.content, edit.snapshot); expect(restored).toContain('command = "old-help"'); - expect(restored).toContain('command = "old-panel"'); + expect(restored).toContain('command = "old-super"'); + expect(restored).toContain('command = "old-hyper"'); + expect(restored).toContain('command = "old-bridge"'); expect(restored).toContain('command = "lazygit"'); + expect(restored).toContain('command = "gitui"'); expect(restored).not.toContain('oullin.tmux-keybindings.toggle'); }); it('is idempotent and restores only plugin-owned values', () => { - const source = ['[keys]', 'prefix = "ctrl+a"', 'settings = "prefix+s"', '', '[[keys.command]]', 'key = "prefix+/"', 'type = "shell"', 'command = "old-panel"', ''].join('\n'); + const source = ['[keys]', 'prefix = "ctrl+a"', 'settings = "prefix+s"', '', '[[keys.command]]', 'key = "prefix+?"', 'type = "shell"', 'command = "old-help"', ''].join('\n'); const first = editor.apply(source, '/config.toml'); const second = editor.apply(first.content, '/config.toml'); @@ -105,9 +124,9 @@ describe('KeybindingConfigEditor', () => { 'settings = "prefix+s"', '', '[[keys.command]]', - 'key = "prefix+/"', + 'key = "alt+super+t"', 'type = "shell"', - 'command = "first-panel"', + 'command = "first-dialog"', '', '[[keys.command]]', 'key = "prefix+t"', @@ -115,9 +134,9 @@ describe('KeybindingConfigEditor', () => { 'command = "lazygit"', '', '[[keys.command]]', - 'key = "prefix+/"', + 'key = "alt+super+t"', 'type = "shell"', - 'command = "second-panel"', + 'command = "second-dialog"', '', ].join('\n'); @@ -130,7 +149,7 @@ describe('KeybindingConfigEditor', () => { expect(restored).toBe(source); }); - it('upgrades version-one snapshots without losing first-install state', () => { + it('merges current snapshots without losing first-install state', () => { const legacyHelp = ['[[keys.command]]', 'key = "prefix+?"', 'type = "shell"', 'command = "old-help"', ''].join('\n'); const legacyPluginAction = ['[[keys.command]]', 'key = "prefix+?"', 'type = "plugin_action"', 'command = "oullin.tmux-keybindings.toggle"', ''].join('\n'); @@ -148,15 +167,16 @@ describe('KeybindingConfigEditor', () => { ], }; - const discovered = editor.apply(['[[keys.command]]', 'key = "prefix+/"', 'type = "shell"', 'command = "slash-help"', ''].join('\n'), '/config.toml').snapshot; + const directConflict = ['[[keys.command]]', 'key = "alt+super+t"', 'type = "shell"', 'command = "dialog-help"', ''].join('\n'); + const discovered = editor.apply(directConflict, '/config.toml').snapshot; const merged = editor.mergeSnapshots(saved, discovered); expect(merged).toEqual({ ...saved, - version: 2, + version: 5, displacedCommands: [ { line: 4, text: legacyHelp }, - { line: 0, text: ['[[keys.command]]', 'key = "prefix+/"', 'type = "shell"', 'command = "slash-help"', ''].join('\n') }, + { line: 0, text: directConflict }, ], }); diff --git a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts index 37e2493..e7860f6 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -1,39 +1,107 @@ import { describe, expect, it } from 'vite-plus/test'; +import { PANEL_GROUPS } from '#tmux-keybindings/domain/keybinding-profile'; import { BindingsPanelRenderer } from '#tmux-keybindings/presentation/bindings-panel-renderer'; +import { TerminalOutput } from '#tmux-keybindings/testing/support/terminal-output'; describe('BindingsPanelRenderer', () => { const renderer = new BindingsPanelRenderer(); + const escape = String.fromCodePoint(0x1b); - it('renders every group in a narrow pane', () => { - const panel = renderer.render(42, 30); + it('renders the complete styled catalogue within the target dialog', () => { + const panel = renderer.render(84, 30); + const output = TerminalOutput.from(panel); + const primary = `${escape}[38;2;192;202;245m`; + const shortcut = `${escape}[38;2;187;154;247m`; - expect(panel).toContain('GLOBAL'); - expect(panel).toContain('TABS'); - expect(panel).toContain('PANES'); - expect(panel).toContain('% / "'); - expect(panel).toContain('Ctrl+B /'); - expect(panel).toContain('open / close this panel'); - expect(panel).toMatch(/\?\s+keybinds/u); + expect(output.rows).toHaveLength(30); expect( - panel.split('\n').every((line) => line.length <= 42), + output.visibleWidths.every((width) => width === 84), + ).toBe(true); + expect( + output.rows[0]?.startsWith('tmux keybindings'), + ).toBe(true); + expect( + output.rows[0]?.endsWith('esc close'), + ).toBe(true); + expect(output.rows).toContain('available commands and configured shortcuts'.padEnd(84)); + expect( + output.rows.at(-1)?.trim(), + ).toBe('toggle Option+Command+T close Esc'); + expect( + output.rows.some((row) => row.includes('Option+Command+T') && row.includes('open / close dialog')), + ).toBe(true); + + for (const group of PANEL_GROUPS) { + expect( + output.rows.some((row) => row.trim() === group.title.toLowerCase()), + ).toBe(true); + + for (const binding of group.bindings) { + expect(panel).toContain(binding.chord); + expect(panel).toContain(binding.description); + expect( + output.hasStyledText(shortcut, binding.chord), + ).toBe(true); + expect( + output.hasStyledText(primary, binding.description), + ).toBe(true); + } + } + + expect( + output.hasAnsiSequence(`${escape}[48;2;26;27;38m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;192;202;245m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;115;122;162m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;125;207;255m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;187;154;247m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[48;2;122;162;247m`), + ).toBe(true); + expect( + output.hasAnsiSequence(`${escape}[38;2;26;27;38m`), + ).toBe(true); + expect( + output.everyRowEndsWithReset(), ).toBe(true); }); - it('uses two columns in a wide pane', () => { - const panel = renderer.render(90, 20); + it('preserves the resize hint and footer at short heights', () => { + const output = TerminalOutput.from(renderer.render(84, 6)); - expect(panel).toContain('│'); - expect(panel).toContain('GLOBAL'); - expect(panel).toContain('PANES'); + expect(output.rows).toHaveLength(6); + expect( + output.rows.some((row) => row.includes('... resize dialog to see all bindings')), + ).toBe(true); + expect( + output.rows.at(-1)?.trim(), + ).toBe('toggle Option+Command+T close Esc'); + expect( + output.everyRowEndsWithReset(), + ).toBe(true); }); - it('adds an overflow hint at short heights', () => { - const panel = renderer.render(38, 6); + it('keeps narrow and minimum-height output within the safe viewport', () => { + const output = TerminalOutput.from(renderer.render(12, 2)); + expect(output.rows).toHaveLength(4); expect( - panel.split('\n'), - ).toHaveLength(5); - expect(panel).toContain('resize pane'); + output.visibleWidths.every((width) => width <= 20), + ).toBe(true); + expect( + output.rows.some((row) => row.includes('resize dialog')), + ).toBe(true); + expect( + output.everyRowEndsWithReset(), + ).toBe(true); }); }); diff --git a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts new file mode 100644 index 0000000..a212709 --- /dev/null +++ b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts @@ -0,0 +1,34 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; + +describe('PanelCloseShortcut', () => { + const shortcut = new PanelCloseShortcut(); + + it('closes for the exact supported input sequences', () => { + expect( + shortcut.accept(Uint8Array.from([0x1b])), + ).toBe(true); + expect( + shortcut.accept(Uint8Array.from([0x03])), + ).toBe(true); + expect( + shortcut.accept(Uint8Array.from([0x1b, 0x74])), + ).toBe(true); + }); + + it('rejects terminal arrow sequences', () => { + expect( + shortcut.accept(Uint8Array.from([0x1b, 0x5b, 0x41])), + ).toBe(false); + }); + + it('rejects unrelated Alt and plain input sequences', () => { + expect( + shortcut.accept(Uint8Array.from([0x1b, 0x78])), + ).toBe(false); + expect( + shortcut.accept(Uint8Array.from([0x74])), + ).toBe(false); + }); +}); diff --git a/plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts b/plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts new file mode 100644 index 0000000..6e6fd1d --- /dev/null +++ b/plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts @@ -0,0 +1,27 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; +import { TerminalLine } from '#tmux-keybindings/presentation/terminal/terminal-line'; + +describe('TerminalLine', () => { + const escape = String.fromCodePoint(0x1b); + + it('parses six-digit colours with or without a hash', () => { + const segment = StyledSegment.from('ok', 'c0caf5', '#1a1b26', false); + const output = TerminalLine.from([segment], 2, '#1a1b26').render(); + + expect(output).toContain(`${escape}[38;2;192;202;245mok`); + expect(output).toContain(`${escape}[48;2;26;27;38m`); + }); + + it('falls back to black for malformed colours', () => { + const segment = StyledSegment.from('ok', '#xyzxyz', '#abc', false); + const output = TerminalLine.from([segment], 3, 'invalid').render(); + + expect(output).not.toContain('NaN'); + expect(output).toContain(`${escape}[38;2;0;0;0mok`); + expect( + output.match(new RegExp(`${escape}\\[48;2;0;0;0m`, 'gu')), + ).toHaveLength(2); + }); +}); diff --git a/plugins/tmux-keybindings/tests/support/fakes.ts b/plugins/tmux-keybindings/tests/support/fakes.ts index 56bd9bb..adeb853 100644 --- a/plugins/tmux-keybindings/tests/support/fakes.ts +++ b/plugins/tmux-keybindings/tests/support/fakes.ts @@ -1,16 +1,12 @@ import type { HerdrClientPort } from '#tmux-keybindings/application/ports/herdr-client-port'; import type { StateRepositoryPort } from '#tmux-keybindings/application/ports/state-repository-port'; -import type { ConfigurationSnapshot, Pane } from '#tmux-keybindings/domain/models'; +import type { ConfigurationSnapshot } from '#tmux-keybindings/domain/models'; export class FakeHerdrClient implements HerdrClientPort { - readonly closedPaneIds: string[] = []; - readonly openedTargetPaneIds: string[] = []; readonly validatedPaths: string[] = []; + popupOpenCalls = 0; reloadCalls = 0; validationError: Error | undefined; - closeResult = true; - openedPane: Pane = { pane_id: 'w1:p-panel', tab_id: 'w1:t1', workspace_id: 'w1' }; - panes = new Map(); validateConfig(configPath: string): void { this.validatedPaths.push(configPath); @@ -24,27 +20,14 @@ export class FakeHerdrClient implements HerdrClientPort { this.reloadCalls += 1; } - getPane(paneId: string): Pane | undefined { - return this.panes.get(paneId); - } - - openBindingsPane(targetPaneId: string): Pane { - this.openedTargetPaneIds.push(targetPaneId); - - return this.openedPane; - } - - closePluginPane(paneId: string): boolean { - this.closedPaneIds.push(paneId); - - return this.closeResult; + openBindingsPopup(): void { + this.popupOpenCalls += 1; } } export class FakeStateRepository implements StateRepositoryPort { automaticApplyDisabled = false; readonly snapshots = new Map(); - readonly panes = new Map(); loadConfigurationSnapshot(configPath: string): ConfigurationSnapshot | undefined { return this.snapshots.get(configPath); @@ -65,16 +48,4 @@ export class FakeStateRepository implements StateRepositoryPort { setAutomaticApplyDisabled(disabled: boolean): void { this.automaticApplyDisabled = disabled; } - - getTrackedPane(workspaceId: string, tabId: string): string | undefined { - return this.panes.get(`${workspaceId}:${tabId}`); - } - - setTrackedPane(workspaceId: string, tabId: string, paneId: string): void { - this.panes.set(`${workspaceId}:${tabId}`, paneId); - } - - deleteTrackedPane(workspaceId: string, tabId: string): void { - this.panes.delete(`${workspaceId}:${tabId}`); - } } diff --git a/plugins/tmux-keybindings/tests/support/terminal-output.ts b/plugins/tmux-keybindings/tests/support/terminal-output.ts new file mode 100644 index 0000000..3506dc8 --- /dev/null +++ b/plugins/tmux-keybindings/tests/support/terminal-output.ts @@ -0,0 +1,48 @@ +const ansiReset = `${String.fromCodePoint(0x1b)}[0m`; +const ansiSequencePattern = new RegExp(`${String.fromCodePoint(0x1b)}\\[[0-9;]*m`, 'gu'); + +/** Provides immutable textual and terminal-style views of rendered output. */ +export class TerminalOutput { + private readonly rawValue: string; + private readonly rawRowsValue: readonly string[]; + private readonly rowsValue: readonly string[]; + + private constructor(rawValue: string, rawRowsValue: readonly string[], rowsValue: readonly string[]) { + this.rawValue = rawValue; + this.rawRowsValue = rawRowsValue; + this.rowsValue = rowsValue; + } + + /** Creates terminal output assertions from an ANSI-rendered value. */ + static from(value: string): TerminalOutput { + const rawRows = Object.freeze(value.split('\n')); + const rows = Object.freeze(rawRows.map((row) => row.replace(ansiSequencePattern, ''))); + + return new TerminalOutput(value, rawRows, rows); + } + + /** Returns rendered rows with ANSI sequences removed. */ + get rows(): readonly string[] { + return this.rowsValue; + } + + /** Returns the Unicode code-point width of every unstyled row. */ + get visibleWidths(): readonly number[] { + return Object.freeze(this.rowsValue.map((row) => Array.from(row).length)); + } + + /** Reports whether the raw output includes an ANSI sequence. */ + hasAnsiSequence(sequence: string): boolean { + return this.rawValue.includes(sequence); + } + + /** Reports whether text immediately follows a required ANSI style sequence. */ + hasStyledText(sequence: string, text: string): boolean { + return this.rawValue.includes(`${sequence}${text}`); + } + + /** Reports whether every raw row safely resets terminal state. */ + everyRowEndsWithReset(): boolean { + return this.rawRowsValue.every((row) => row.endsWith(ansiReset)); + } +} diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 874eecf..85745ac 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: package/core: {} + plugins/pane-navigation-hints: + dependencies: + '@oullin/herdr-plugin-core': + specifier: file:../../package/core + version: link:../../package/core + plugins/tab-numbers: dependencies: '@oullin/herdr-plugin-core':