From e07eb8eaf88757b8e7084ec0c37be5de3265ad05 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 10:06:29 +0800 Subject: [PATCH 01/14] Extend pane metadata support in plugin core --- packages/plugin-core/package.json | 2 +- .../plugin-core/src/herdr-cli/client/index.ts | 10 ++++- .../plugin-core/src/herdr-cli/panes/index.ts | 16 +++++++- packages/plugin-core/src/index.ts | 2 +- packages/plugin-core/src/models.ts | 12 ++++++ packages/plugin-core/src/ports.ts | 4 +- .../tests/herdr-cli-client.test.ts | 40 +++++++++++++++++++ .../tests/package-contract.test.ts | 2 +- 8 files changed, 82 insertions(+), 6 deletions(-) diff --git a/packages/plugin-core/package.json b/packages/plugin-core/package.json index 9c46656..c1ceae7 100644 --- a/packages/plugin-core/package.json +++ b/packages/plugin-core/package.json @@ -1,6 +1,6 @@ { "name": "@oullin/herdr-plugin-core", - "version": "0.1.0", + "version": "0.1.1", "private": true, "description": "Repository-local runtime primitives for Herdr plugins.", "license": "MIT", diff --git a/packages/plugin-core/src/herdr-cli/client/index.ts b/packages/plugin-core/src/herdr-cli/client/index.ts index e109b89..3f6ec75 100644 --- a/packages/plugin-core/src/herdr-cli/client/index.ts +++ b/packages/plugin-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/packages/plugin-core/src/herdr-cli/panes/index.ts b/packages/plugin-core/src/herdr-cli/panes/index.ts index 3c0b785..71cf9d4 100644 --- a/packages/plugin-core/src/herdr-cli/panes/index.ts +++ b/packages/plugin-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,6 +11,16 @@ 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); @@ -22,6 +32,10 @@ export class HerdrPaneClient implements PaneClient { 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 { const args = [ 'plugin', diff --git a/packages/plugin-core/src/index.ts b/packages/plugin-core/src/index.ts index 9f52df1..3e52fee 100644 --- a/packages/plugin-core/src/index.ts +++ b/packages/plugin-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/packages/plugin-core/src/models.ts b/packages/plugin-core/src/models.ts index 057a400..49a03df 100644 --- a/packages/plugin-core/src/models.ts +++ b/packages/plugin-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/packages/plugin-core/src/ports.ts b/packages/plugin-core/src/ports.ts index f593d05..dc06456 100644 --- a/packages/plugin-core/src/ports.ts +++ b/packages/plugin-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/packages/plugin-core/tests/herdr-cli-client.test.ts b/packages/plugin-core/tests/herdr-cli-client.test.ts index 18d1b18..9313acd 100644 --- a/packages/plugin-core/tests/herdr-cli-client.test.ts +++ b/packages/plugin-core/tests/herdr-cli-client.test.ts @@ -74,6 +74,46 @@ 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('preserves structured Herdr error codes', () => { const runner = new StubCommandRunner({ error: undefined, diff --git a/packages/plugin-core/tests/package-contract.test.ts b/packages/plugin-core/tests/package-contract.test.ts index 7b5ac5c..5571f70 100644 --- a/packages/plugin-core/tests/package-contract.test.ts +++ b/packages/plugin-core/tests/package-contract.test.ts @@ -20,7 +20,7 @@ describe('plugin core package contract', () => { expect(packageJson).toMatchObject({ name: '@oullin/herdr-plugin-core', private: true, - version: '0.1.0', + version: '0.1.1', exports: { '.': './src/index.ts', './herdr-cli': './src/herdr-cli/index.ts', From 1ecb7a82070826607a4edd390faa6142bc1f2cfe Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 10:06:34 +0800 Subject: [PATCH 02/14] Add pane navigation hints plugin --- README.md | 9 +- plugins/pane-navigation-hints/LICENSE | 21 +++ plugins/pane-navigation-hints/README.md | 59 ++++++++ .../pane-navigation-hints/herdr-plugin.toml | 36 +++++ plugins/pane-navigation-hints/index.ts | 32 +++++ plugins/pane-navigation-hints/package.json | 34 +++++ .../pane-navigation-hint-synchroniser.ts | 57 ++++++++ .../pane-navigation-hints-plugin.ts | 43 ++++++ .../application/ports/herdr-client-port.ts | 8 ++ .../ports/keybinding-configuration-port.ts | 5 + .../src/domain/default-keybindings.ts | 23 +++ .../src/domain/models.ts | 22 +++ .../src/domain/navigation-hint-formatter.ts | 132 ++++++++++++++++++ .../src/errors/plugin-error.ts | 1 + .../src/infrastructure/herdr-cli-client.ts | 1 + .../keybinding-config-parser.ts | 87 ++++++++++++ .../keybinding-configuration-reader.ts | 23 +++ .../src/presentation/plugin-application.ts | 21 +++ .../pane-navigation-hint-synchroniser.test.ts | 66 +++++++++ .../pane-navigation-hints-plugin.test.ts | 39 ++++++ .../architecture/plugin-contract.test.ts | 93 ++++++++++++ .../domain/navigation-hint-formatter.test.ts | 73 ++++++++++ .../keybinding-config-parser.test.ts | 49 +++++++ .../keybinding-configuration-reader.test.ts | 57 ++++++++ .../tests/support/fakes.ts | 49 +++++++ pnpm-lock.yaml | 6 + 26 files changed, 1042 insertions(+), 4 deletions(-) create mode 100644 plugins/pane-navigation-hints/LICENSE create mode 100644 plugins/pane-navigation-hints/README.md create mode 100644 plugins/pane-navigation-hints/herdr-plugin.toml create mode 100644 plugins/pane-navigation-hints/index.ts create mode 100644 plugins/pane-navigation-hints/package.json create mode 100644 plugins/pane-navigation-hints/src/application/pane-navigation-hint-synchroniser.ts create mode 100644 plugins/pane-navigation-hints/src/application/pane-navigation-hints-plugin.ts create mode 100644 plugins/pane-navigation-hints/src/application/ports/herdr-client-port.ts create mode 100644 plugins/pane-navigation-hints/src/application/ports/keybinding-configuration-port.ts create mode 100644 plugins/pane-navigation-hints/src/domain/default-keybindings.ts create mode 100644 plugins/pane-navigation-hints/src/domain/models.ts create mode 100644 plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts create mode 100644 plugins/pane-navigation-hints/src/errors/plugin-error.ts create mode 100644 plugins/pane-navigation-hints/src/infrastructure/herdr-cli-client.ts create mode 100644 plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts create mode 100644 plugins/pane-navigation-hints/src/infrastructure/keybinding-configuration-reader.ts create mode 100644 plugins/pane-navigation-hints/src/presentation/plugin-application.ts create mode 100644 plugins/pane-navigation-hints/tests/application/pane-navigation-hint-synchroniser.test.ts create mode 100644 plugins/pane-navigation-hints/tests/application/pane-navigation-hints-plugin.test.ts create mode 100644 plugins/pane-navigation-hints/tests/architecture/plugin-contract.test.ts create mode 100644 plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts create mode 100644 plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts create mode 100644 plugins/pane-navigation-hints/tests/infrastructure/keybinding-configuration-reader.test.ts create mode 100644 plugins/pane-navigation-hints/tests/support/fakes.ts diff --git a/README.md b/README.md index d172b4b..1ae31a4 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 toggleable reference panel. | `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/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..ae41ee8 --- /dev/null +++ b/plugins/pane-navigation-hints/README.md @@ -0,0 +1,59 @@ +# 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+←/↓/↑/→ · N/P Ctrl+B+o/Shift+Tab · Last Ctrl+B+; +``` + +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..63c80c0 --- /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:../../packages/plugin-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..3402e18 --- /dev/null +++ b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts @@ -0,0 +1,132 @@ +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 segments = [this.focusSegment(bindings), this.cycleSegment(bindings), bindings.lastPane === '' ? undefined : `Last ${this.renderBinding(bindings.lastPane, bindings.prefix)}`].filter( + (segment): segment is string => segment !== undefined, + ); + + const title = segments.length === 0 ? 'Pane navigation is unbound' : segments.join(separator); + + 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 collapsed = this.collapseBindings( + directional.map(({ binding }) => binding), + bindings.prefix, + ); + + if (collapsed !== undefined) { + return `←↓↑→ ${collapsed}`; + } + } + + return directional.map(({ direction, binding }) => `${direction} ${this.renderBinding(binding, bindings.prefix)}`).join(' '); + } + + private cycleSegment(bindings: PaneNavigationBindings): string | undefined { + const cycleBindings = [bindings.cyclePaneNext, bindings.cyclePanePrevious].filter((binding) => binding !== ''); + + if (cycleBindings.length === 0) { + return undefined; + } + + if (cycleBindings.length === 2) { + const collapsed = this.collapseBindings(cycleBindings, bindings.prefix); + + if (collapsed !== undefined) { + return `N/P ${collapsed}`; + } + } + + const labels = [ + bindings.cyclePaneNext === '' ? undefined : `N ${this.renderBinding(bindings.cyclePaneNext, bindings.prefix)}`, + bindings.cyclePanePrevious === '' ? undefined : `P ${this.renderBinding(bindings.cyclePanePrevious, bindings.prefix)}`, + ].filter((label): label is string => label !== undefined); + + return labels.join(' '); + } + + 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('/')}`; + } + + 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))].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..6827777 --- /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('\n'); + 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.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.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..9441d3e --- /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+h/j/k/l · N/P Ctrl+B+Tab/Shift+Tab' }, + }, + { + paneId: 'w1:p2', + update: { source: PANE_TITLE_SOURCE, title: '←↓↑→ Ctrl+B+h/j/k/l · N/P Ctrl+B+Tab/Shift+Tab' }, + }, + ]); + }); + + 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..ba468da --- /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:../../packages/plugin-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..357eb9b --- /dev/null +++ b/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts @@ -0,0 +1,73 @@ +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+h/j/k/l · N/P Ctrl+B+Tab/Shift+Tab'); + }); + + 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+←/↓/↑/→ · N/P Ctrl+B+o/Shift+Tab · Last Ctrl+B+;'); + }); + + it('renders partial and direct bindings without inventing missing actions', () => { + expect( + formatter.format({ + ...DEFAULT_PANE_NAVIGATION_BINDINGS, + focusPaneDown: '', + focusPaneUp: '', + cyclePaneNext: 'alt+n', + cyclePanePrevious: '', + }), + ).toBe('← Ctrl+B+h → Ctrl+B+l · N Alt+n'); + }); + + it('reports when every pane navigation action is unbound', () => { + expect( + formatter.format({ + prefix: 'ctrl+b', + focusPaneLeft: '', + focusPaneDown: '', + focusPaneUp: '', + focusPaneRight: '', + cyclePaneNext: '', + cyclePanePrevious: '', + lastPane: '', + }), + ).toBe('Pane navigation 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..e13612d --- /dev/null +++ b/plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts @@ -0,0 +1,49 @@ +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('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/pnpm-lock.yaml b/pnpm-lock.yaml index aad9d17..b5d7846 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -17,6 +17,12 @@ importers: packages/plugin-core: {} + plugins/pane-navigation-hints: + dependencies: + '@oullin/herdr-plugin-core': + specifier: file:../../packages/plugin-core + version: link:../../packages/plugin-core + plugins/tab-numbers: dependencies: '@oullin/herdr-plugin-core': From ba261c10c4bca6ec75d391abd2e526dd9317d3d1 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 14:53:02 +0800 Subject: [PATCH 03/14] Address pane hint review feedback --- .../src/domain/navigation-hint-formatter.ts | 4 +-- .../keybinding-config-parser.ts | 6 ++-- .../domain/navigation-hint-formatter.test.ts | 30 +++++++++++++++++++ .../keybinding-config-parser.test.ts | 12 ++++++++ 4 files changed, 47 insertions(+), 5 deletions(-) diff --git a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts index 3402e18..138ccbb 100644 --- a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts +++ b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts @@ -96,14 +96,14 @@ export class NavigationHintFormatter { .join('+'), ); - return `${renderedHead}+${tails.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))].join('+'); + return [this.renderPrefix(prefix), ...parts.slice(1).map((part) => this.renderKey(part))].filter(Boolean).join('+'); } return parts.map((part) => this.renderKey(part)).join('+'); diff --git a/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts b/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts index 6827777..a07d31f 100644 --- a/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts +++ b/plugins/pane-navigation-hints/src/infrastructure/keybinding-config-parser.ts @@ -9,7 +9,7 @@ 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('\n'); + const lines = source.split(/\r?\n/u); const start = lines.findIndex((line) => keysHeaderPattern.test(line)); if (start < 0) { @@ -42,7 +42,7 @@ export class KeybindingConfigParser { private stringValue(rawValue: string, key: string): string { const value = rawValue.trim(); - if (value.startsWith('"') && value.endsWith('"')) { + if (value.length >= 2 && value.startsWith('"') && value.endsWith('"')) { try { const parsed = JSON.parse(value) as unknown; @@ -54,7 +54,7 @@ export class KeybindingConfigParser { } } - if (value.startsWith("'") && value.endsWith("'")) { + if (value.length >= 2 && value.startsWith("'") && value.endsWith("'")) { return value.slice(1, -1); } 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 index 357eb9b..5bd4a33 100644 --- a/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts +++ b/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts @@ -39,6 +39,36 @@ describe('NavigationHintFormatter', () => { ).toBe('← Ctrl+B+h → Ctrl+B+l · N Alt+n'); }); + 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('←↓↑→ ←/↓/↑/→ · N/P o/Shift+Tab · 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('← ← · N Tab'); + }); + it('reports when every pane navigation action is unbound', () => { expect( formatter.format({ 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 index e13612d..0dae49b 100644 --- a/plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts +++ b/plugins/pane-navigation-hints/tests/infrastructure/keybinding-config-parser.test.ts @@ -43,6 +43,18 @@ describe('KeybindingConfigParser', () => { 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'); }); From dab51ad321eb78d93d49f021d2afd5677f9f9c7f Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 15:33:02 +0800 Subject: [PATCH 04/14] Clarify pane navigation hints --- plugins/pane-navigation-hints/README.md | 4 +- .../src/domain/navigation-hint-formatter.ts | 61 ++++++++++++++----- .../pane-navigation-hint-synchroniser.test.ts | 4 +- .../domain/navigation-hint-formatter.test.ts | 10 +-- 4 files changed, 55 insertions(+), 24 deletions(-) diff --git a/plugins/pane-navigation-hints/README.md b/plugins/pane-navigation-hints/README.md index ae41ee8..c3fb31f 100644 --- a/plugins/pane-navigation-hints/README.md +++ b/plugins/pane-navigation-hints/README.md @@ -5,9 +5,11 @@ Show the effective pane navigation shortcuts in every Herdr pane border. The plu For the tmux-style profile, the border resembles: ```text -←↓↑→ Ctrl+B+←/↓/↑/→ · N/P Ctrl+B+o/Shift+Tab · Last Ctrl+B+; +Ctrl+B then: Focus ←/↓/↑/→ · Cycle next/prev o/Shift+Tab · Last ; ``` +Read the shared prefix first: press `Ctrl+B`, release it, then press the arrow pointing toward the adjacent pane you want to focus. The remaining labels use the same prefix for cycling or returning 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 diff --git a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts index 138ccbb..60a11c3 100644 --- a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts +++ b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts @@ -21,11 +21,17 @@ const keyLabels: Readonly> = { export class NavigationHintFormatter { format(bindings: PaneNavigationBindings): string { - const segments = [this.focusSegment(bindings), this.cycleSegment(bindings), bindings.lastPane === '' ? undefined : `Last ${this.renderBinding(bindings.lastPane, bindings.prefix)}`].filter( - (segment): segment is string => segment !== undefined, - ); + const usesSharedPrefix = this.usesSharedPrefix(bindings); + const displayBindings = usesSharedPrefix ? this.withoutBindingPrefixes(bindings) : bindings; + + const segments = [ + this.focusSegment(displayBindings), + this.cycleSegment(displayBindings), + displayBindings.lastPane === '' ? undefined : `Last ${this.renderBinding(displayBindings.lastPane, displayBindings.prefix)}`, + ].filter((segment): segment is string => segment !== undefined); - const title = segments.length === 0 ? 'Pane navigation is unbound' : segments.join(separator); + const legend = segments.join(separator); + const title = segments.length === 0 ? 'Pane navigation is unbound' : usesSharedPrefix ? `${this.renderPrefix(bindings.prefix)} then: ${legend}` : legend; return this.truncate(title); } @@ -43,17 +49,17 @@ export class NavigationHintFormatter { } if (directional.length === 4) { - const collapsed = this.collapseBindings( + const directions = directional.map(({ direction }) => direction).join('/'); + + const collapsed = this.renderBindingList( directional.map(({ binding }) => binding), bindings.prefix, ); - if (collapsed !== undefined) { - return `←↓↑→ ${collapsed}`; - } + return collapsed === directions ? `Focus ${directions}` : `Focus ${directions} ${collapsed}`; } - return directional.map(({ direction, binding }) => `${direction} ${this.renderBinding(binding, bindings.prefix)}`).join(' '); + return `Focus ${directional.map(({ direction, binding }) => `${direction} ${this.renderBinding(binding, bindings.prefix)}`).join(' ')}`; } private cycleSegment(bindings: PaneNavigationBindings): string | undefined { @@ -64,21 +70,44 @@ export class NavigationHintFormatter { } if (cycleBindings.length === 2) { - const collapsed = this.collapseBindings(cycleBindings, bindings.prefix); - - if (collapsed !== undefined) { - return `N/P ${collapsed}`; - } + return `Cycle next/prev ${this.renderBindingList(cycleBindings, bindings.prefix)}`; } const labels = [ - bindings.cyclePaneNext === '' ? undefined : `N ${this.renderBinding(bindings.cyclePaneNext, bindings.prefix)}`, - bindings.cyclePanePrevious === '' ? undefined : `P ${this.renderBinding(bindings.cyclePanePrevious, bindings.prefix)}`, + bindings.cyclePaneNext === '' ? undefined : `Next ${this.renderBinding(bindings.cyclePaneNext, bindings.prefix)}`, + bindings.cyclePanePrevious === '' ? undefined : `Prev ${this.renderBinding(bindings.cyclePanePrevious, bindings.prefix)}`, ].filter((label): label is string => label !== undefined); return labels.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.cyclePaneNext, bindings.cyclePanePrevious, 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]; 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 index 9441d3e..2c546bc 100644 --- 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 @@ -17,11 +17,11 @@ describe('PaneNavigationHintSynchroniser', () => { expect(client.reports).toEqual([ { paneId: 'w1:p1', - update: { source: PANE_TITLE_SOURCE, title: '←↓↑→ Ctrl+B+h/j/k/l · N/P Ctrl+B+Tab/Shift+Tab' }, + update: { source: PANE_TITLE_SOURCE, title: 'Ctrl+B then: Focus ←/↓/↑/→ h/j/k/l · Cycle next/prev Tab/Shift+Tab' }, }, { paneId: 'w1:p2', - update: { source: PANE_TITLE_SOURCE, title: '←↓↑→ Ctrl+B+h/j/k/l · N/P Ctrl+B+Tab/Shift+Tab' }, + update: { source: PANE_TITLE_SOURCE, title: 'Ctrl+B then: Focus ←/↓/↑/→ h/j/k/l · Cycle next/prev Tab/Shift+Tab' }, }, ]); }); 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 index 5bd4a33..002e3de 100644 --- a/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts +++ b/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts @@ -9,7 +9,7 @@ describe('NavigationHintFormatter', () => { it('formats the Herdr defaults and omits the unbound last-pane action', () => { expect( formatter.format(DEFAULT_PANE_NAVIGATION_BINDINGS), - ).toBe('←↓↑→ Ctrl+B+h/j/k/l · N/P Ctrl+B+Tab/Shift+Tab'); + ).toBe('Ctrl+B then: Focus ←/↓/↑/→ h/j/k/l · Cycle next/prev Tab/Shift+Tab'); }); it('collapses the tmux profile into one compact legend', () => { @@ -24,7 +24,7 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: 'prefix+shift+tab', lastPane: 'prefix+;', }), - ).toBe('←↓↑→ Ctrl+B+←/↓/↑/→ · N/P Ctrl+B+o/Shift+Tab · Last Ctrl+B+;'); + ).toBe('Ctrl+B then: Focus ←/↓/↑/→ · Cycle next/prev o/Shift+Tab · Last ;'); }); it('renders partial and direct bindings without inventing missing actions', () => { @@ -36,7 +36,7 @@ describe('NavigationHintFormatter', () => { cyclePaneNext: 'alt+n', cyclePanePrevious: '', }), - ).toBe('← Ctrl+B+h → Ctrl+B+l · N Alt+n'); + ).toBe('Focus ← Ctrl+B+h → Ctrl+B+l · Next Alt+n'); }); it('omits separators before collapsed bindings when the prefix is empty', () => { @@ -51,7 +51,7 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: 'prefix+shift+tab', lastPane: 'prefix+;', }), - ).toBe('←↓↑→ ←/↓/↑/→ · N/P o/Shift+Tab · Last ;'); + ).toBe('Focus ←/↓/↑/→ · Cycle next/prev o/Shift+Tab · Last ;'); }); it('omits separators before individual bindings when the prefix is empty', () => { @@ -66,7 +66,7 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: '', lastPane: '', }), - ).toBe('← ← · N Tab'); + ).toBe('Focus ← ← · Next Tab'); }); it('reports when every pane navigation action is unbound', () => { From 6e6db7e70d4f54b8649651555b9ca180de9fce13 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 15:39:11 +0800 Subject: [PATCH 05/14] Shorten pane focus hints --- plugins/pane-navigation-hints/README.md | 4 +- .../src/domain/navigation-hint-formatter.ts | 37 ++++--------------- .../pane-navigation-hint-synchroniser.test.ts | 4 +- .../domain/navigation-hint-formatter.test.ts | 14 +++---- 4 files changed, 18 insertions(+), 41 deletions(-) diff --git a/plugins/pane-navigation-hints/README.md b/plugins/pane-navigation-hints/README.md index c3fb31f..fe8ef8c 100644 --- a/plugins/pane-navigation-hints/README.md +++ b/plugins/pane-navigation-hints/README.md @@ -5,10 +5,10 @@ Show the effective pane navigation shortcuts in every Herdr pane border. The plu For the tmux-style profile, the border resembles: ```text -Ctrl+B then: Focus ←/↓/↑/→ · Cycle next/prev o/Shift+Tab · Last ; +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. The remaining labels use the same prefix for cycling or returning to the last pane. +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. diff --git a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts index 60a11c3..6bc69b6 100644 --- a/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts +++ b/plugins/pane-navigation-hints/src/domain/navigation-hint-formatter.ts @@ -24,14 +24,12 @@ export class NavigationHintFormatter { const usesSharedPrefix = this.usesSharedPrefix(bindings); const displayBindings = usesSharedPrefix ? this.withoutBindingPrefixes(bindings) : bindings; - const segments = [ - this.focusSegment(displayBindings), - this.cycleSegment(displayBindings), - displayBindings.lastPane === '' ? undefined : `Last ${this.renderBinding(displayBindings.lastPane, displayBindings.prefix)}`, - ].filter((segment): segment is string => segment !== undefined); + 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 navigation is unbound' : usesSharedPrefix ? `${this.renderPrefix(bindings.prefix)} then: ${legend}` : legend; + const title = segments.length === 0 ? 'Pane focus is unbound' : usesSharedPrefix ? `${this.renderPrefix(bindings.prefix)} then ${legend}` : legend; return this.truncate(title); } @@ -56,29 +54,10 @@ export class NavigationHintFormatter { bindings.prefix, ); - return collapsed === directions ? `Focus ${directions}` : `Focus ${directions} ${collapsed}`; + return collapsed === directions ? directions : `${directions} ${collapsed}`; } - return `Focus ${directional.map(({ direction, binding }) => `${direction} ${this.renderBinding(binding, bindings.prefix)}`).join(' ')}`; - } - - private cycleSegment(bindings: PaneNavigationBindings): string | undefined { - const cycleBindings = [bindings.cyclePaneNext, bindings.cyclePanePrevious].filter((binding) => binding !== ''); - - if (cycleBindings.length === 0) { - return undefined; - } - - if (cycleBindings.length === 2) { - return `Cycle next/prev ${this.renderBindingList(cycleBindings, bindings.prefix)}`; - } - - const labels = [ - bindings.cyclePaneNext === '' ? undefined : `Next ${this.renderBinding(bindings.cyclePaneNext, bindings.prefix)}`, - bindings.cyclePanePrevious === '' ? undefined : `Prev ${this.renderBinding(bindings.cyclePanePrevious, bindings.prefix)}`, - ].filter((label): label is string => label !== undefined); - - return labels.join(' '); + return directional.map(({ direction, binding }) => `${direction} ${this.renderBinding(binding, bindings.prefix)}`).join(' '); } private renderBindingList(bindings: readonly string[], prefix: string): string { @@ -86,9 +65,7 @@ export class NavigationHintFormatter { } private usesSharedPrefix(bindings: PaneNavigationBindings): boolean { - const actions = [bindings.focusPaneLeft, bindings.focusPaneDown, bindings.focusPaneUp, bindings.focusPaneRight, bindings.cyclePaneNext, bindings.cyclePanePrevious, bindings.lastPane].filter( - (binding) => binding !== '', - ); + 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); } 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 index 2c546bc..f34cfe7 100644 --- 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 @@ -17,11 +17,11 @@ describe('PaneNavigationHintSynchroniser', () => { expect(client.reports).toEqual([ { paneId: 'w1:p1', - update: { source: PANE_TITLE_SOURCE, title: 'Ctrl+B then: Focus ←/↓/↑/→ h/j/k/l · Cycle next/prev Tab/Shift+Tab' }, + 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: Focus ←/↓/↑/→ h/j/k/l · Cycle next/prev Tab/Shift+Tab' }, + update: { source: PANE_TITLE_SOURCE, title: 'Ctrl+B then ←/↓/↑/→ h/j/k/l' }, }, ]); }); 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 index 002e3de..7ff84b9 100644 --- a/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts +++ b/plugins/pane-navigation-hints/tests/domain/navigation-hint-formatter.test.ts @@ -9,7 +9,7 @@ describe('NavigationHintFormatter', () => { it('formats the Herdr defaults and omits the unbound last-pane action', () => { expect( formatter.format(DEFAULT_PANE_NAVIGATION_BINDINGS), - ).toBe('Ctrl+B then: Focus ←/↓/↑/→ h/j/k/l · Cycle next/prev Tab/Shift+Tab'); + ).toBe('Ctrl+B then ←/↓/↑/→ h/j/k/l'); }); it('collapses the tmux profile into one compact legend', () => { @@ -24,10 +24,10 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: 'prefix+shift+tab', lastPane: 'prefix+;', }), - ).toBe('Ctrl+B then: Focus ←/↓/↑/→ · Cycle next/prev o/Shift+Tab · Last ;'); + ).toBe('Ctrl+B then ←/↓/↑/→ · Last ;'); }); - it('renders partial and direct bindings without inventing missing actions', () => { + it('renders partial directional bindings without showing cycle bindings', () => { expect( formatter.format({ ...DEFAULT_PANE_NAVIGATION_BINDINGS, @@ -36,7 +36,7 @@ describe('NavigationHintFormatter', () => { cyclePaneNext: 'alt+n', cyclePanePrevious: '', }), - ).toBe('Focus ← Ctrl+B+h → Ctrl+B+l · Next Alt+n'); + ).toBe('Ctrl+B then ← h → l'); }); it('omits separators before collapsed bindings when the prefix is empty', () => { @@ -51,7 +51,7 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: 'prefix+shift+tab', lastPane: 'prefix+;', }), - ).toBe('Focus ←/↓/↑/→ · Cycle next/prev o/Shift+Tab · Last ;'); + ).toBe('←/↓/↑/→ · Last ;'); }); it('omits separators before individual bindings when the prefix is empty', () => { @@ -66,7 +66,7 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: '', lastPane: '', }), - ).toBe('Focus ← ← · Next Tab'); + ).toBe('← ←'); }); it('reports when every pane navigation action is unbound', () => { @@ -81,7 +81,7 @@ describe('NavigationHintFormatter', () => { cyclePanePrevious: '', lastPane: '', }), - ).toBe('Pane navigation is unbound'); + ).toBe('Pane focus is unbound'); }); it('never exceeds the Herdr pane title limit', () => { From 184e48cb0ae4a44fee402ced3f42722644b19dfc Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 15:48:49 +0800 Subject: [PATCH 06/14] Replace keybinding panel with popup dialog --- README.md | 10 +-- plugins/tmux-keybindings/README.md | 40 +++++----- plugins/tmux-keybindings/herdr-plugin.toml | 14 ++-- plugins/tmux-keybindings/index.ts | 3 +- plugins/tmux-keybindings/package.json | 4 +- .../src/application/panel-toggle.ts | 35 ++------- .../application/ports/herdr-client-port.ts | 5 +- .../ports/state-repository-port.ts | 3 - .../src/domain/keybinding-profile.ts | 2 +- plugins/tmux-keybindings/src/domain/models.ts | 2 +- .../src/infrastructure/herdr-cli-client.ts | 12 +-- .../infrastructure/json-state-repository.ts | 32 -------- .../keybinding-config-editor.ts | 2 +- .../bindings-panel-application.ts | 17 ++++ .../presentation/bindings-panel-renderer.ts | 30 +++++-- .../src/presentation/panel-close-shortcut.ts | 30 +++++++ .../src/presentation/plugin-application.ts | 2 +- .../tests/application/panel-toggle.test.ts | 78 ++----------------- .../architecture/plugin-contract.test.ts | 10 ++- .../infrastructure/herdr-cli-client.test.ts | 15 ++++ .../bindings-panel-renderer.test.ts | 3 +- .../presentation/panel-close-shortcut.test.ts | 36 +++++++++ .../tmux-keybindings/tests/support/fakes.ts | 37 +-------- 23 files changed, 191 insertions(+), 231 deletions(-) create mode 100644 plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts create mode 100644 plugins/tmux-keybindings/tests/infrastructure/herdr-cli-client.test.ts create mode 100644 plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts diff --git a/README.md b/README.md index 1ae31a4..e74d5ad 100644 --- a/README.md +++ b/README.md @@ -4,11 +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` | -| [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` | +| 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/plugins/tmux-keybindings/README.md b/plugins/tmux-keybindings/README.md index 61c60c0..a73d161 100644 --- a/plugins/tmux-keybindings/README.md +++ b/plugins/tmux-keybindings/README.md @@ -1,26 +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; press the chord again to close it. +Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Ctrl+B`, then `?` to open it; repeat the chord inside the dialog or press `Esc` to close it. 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 | -| `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 | +| ---------- | ----------------------------- | +| `?` | 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 @@ -31,7 +31,7 @@ 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: @@ -39,7 +39,7 @@ Use `Ctrl+B ?`, or invoke the action directly: 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 header repeats both closing shortcuts so the exit path is always visible. ## Restore and uninstall diff --git a/plugins/tmux-keybindings/herdr-plugin.toml b/plugins/tmux-keybindings/herdr-plugin.toml index 6cbb5e5..4fe6714 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.1" -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 = 24 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 ef14d07..1f1ce2f 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.1", - "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/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 6bb7c4f..891fb35 100644 --- a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts +++ b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts @@ -48,7 +48,7 @@ export const PANEL_GROUPS: readonly BindingGroup[] = [ title: 'Global', bindings: [ { chord: 'Ctrl+B', description: 'prefix' }, - { chord: '?', description: 'toggle this panel' }, + { chord: '?', 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 d7aa604..e954cad 100644 --- a/plugins/tmux-keybindings/src/domain/models.ts +++ b/plugins/tmux-keybindings/src/domain/models.ts @@ -1,4 +1,4 @@ -export type { Environment, Pane } from '@oullin/herdr-plugin-core'; +export type { Environment } from '@oullin/herdr-plugin-core'; export interface ConfigurationSnapshot { readonly version: 1; 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 a34284a..91f703f 100644 --- a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts +++ b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts @@ -180,7 +180,7 @@ export class KeybindingConfigEditor { } private appendManagedCommand(sourceLines: readonly string[]): string[] { - return this.appendBlock(sourceLines, ['[[keys.command]]', 'key = "prefix+?"', 'type = "plugin_action"', `command = "${TOGGLE_ACTION_ID}"`, 'description = "toggle tmux keybinding panel"']); + return this.appendBlock(sourceLines, ['[[keys.command]]', 'key = "prefix+?"', 'type = "plugin_action"', `command = "${TOGGLE_ACTION_ID}"`, 'description = "open tmux keybinding dialog"']); } private appendBlock(sourceLines: readonly string[], block: readonly string[]): string[] { 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..dc55101 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -11,19 +11,20 @@ export class BindingsPanelRenderer { 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 shortcuts = this.shortcutLines(contentWidth); + const divider = this.centre('━'.repeat(Math.min(contentWidth, 42)), contentWidth); + const body = [header, ...shortcuts, divider, '', ...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); + visible[visible.length - 1] = this.truncate('… resize dialog 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))]); + return PANEL_GROUPS.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, width), ...group.bindings.map((binding) => this.bindingLine(binding, width))]); } private wideLines(width: number): readonly string[] { @@ -31,7 +32,7 @@ export class BindingsPanelRenderer { 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))]), + groups.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, columnWidth), ...group.bindings.map((binding) => this.bindingLine(binding, columnWidth))]), ); const rowCount = Math.max(...rendered.map((column) => column.length)); @@ -53,6 +54,25 @@ export class BindingsPanelRenderer { return `${binding.chord.padEnd(chordWidth)} ${binding.description}`; } + private shortcutLines(width: number): readonly string[] { + if (width >= 37) { + return [this.centre('Prefix Ctrl+B • Close Ctrl+B then ?', width)]; + } + + if (width >= 20) { + return [this.centre('Prefix Ctrl+B', width), this.centre('Close Ctrl+B then ?', width)]; + } + + return [this.centre('Close Ctrl+B ?', width)]; + } + + private sectionHeading(title: string, width: number): string { + const heading = title.toUpperCase(); + const rule = '─'.repeat(Math.max(0, width - heading.length - 1)); + + return `${heading} ${rule}`; + } + private centre(value: string, width: number): string { return value.padStart(Math.max(value.length, Math.floor((width + value.length) / 2))); } 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..32d5509 --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts @@ -0,0 +1,30 @@ +const controlB = 0x02; +const controlC = 0x03; +const escape = 0x1b; +const questionMark = 0x3f; + +export class PanelCloseShortcut { + private prefixPressed = false; + + accept(input: Uint8Array): boolean { + for (const byte of input) { + if (byte === escape || byte === controlC) { + return true; + } + + if (this.prefixPressed) { + this.prefixPressed = byte === controlB; + + if (byte === questionMark) { + return true; + } + + continue; + } + + this.prefixPressed = byte === controlB; + } + + return false; + } +} 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/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 b6822a4..f72aa1e 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.1"'); - 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 = 24'); }); 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:../../packages/plugin-core' }); }); }); 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/presentation/bindings-panel-renderer.test.ts b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts index 83ca0b3..4a6c27b 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -8,6 +8,7 @@ describe('BindingsPanelRenderer', () => { it('renders every group in a narrow pane', () => { const panel = renderer.render(42, 30); + expect(panel).toContain('Prefix Ctrl+B • Close Ctrl+B then ?'); expect(panel).toContain('GLOBAL'); expect(panel).toContain('TABS'); expect(panel).toContain('PANES'); @@ -31,6 +32,6 @@ describe('BindingsPanelRenderer', () => { expect( panel.split('\n'), ).toHaveLength(5); - expect(panel).toContain('resize pane'); + expect(panel).toContain('resize dialog'); }); }); 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..c989fee --- /dev/null +++ b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts @@ -0,0 +1,36 @@ +import { describe, expect, it } from 'vite-plus/test'; + +import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; + +describe('PanelCloseShortcut', () => { + it('closes when Ctrl+B and question mark arrive together', () => { + expect( + new PanelCloseShortcut().accept(Uint8Array.from([0x02, 0x3f])), + ).toBe(true); + }); + + it('remembers Ctrl+B across input chunks', () => { + const shortcut = new PanelCloseShortcut(); + + expect( + shortcut.accept(Uint8Array.from([0x02])), + ).toBe(false); + expect( + shortcut.accept(Uint8Array.from([0x3f])), + ).toBe(true); + }); + + it('ignores unrelated keys after the prefix', () => { + const shortcut = new PanelCloseShortcut(); + + expect( + shortcut.accept(Uint8Array.from([0x02, 0x78, 0x3f])), + ).toBe(false); + }); + + it('also closes a modal dialog with Escape', () => { + expect( + new PanelCloseShortcut().accept(Uint8Array.from([0x1b])), + ).toBe(true); + }); +}); 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}`); - } } From 3b6733f048df521a142ed8667df16cdce459823d Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 15:59:48 +0800 Subject: [PATCH 07/14] Preserve Herdr help keybinding --- plugins/tmux-keybindings/README.md | 8 +-- .../src/application/configuration-manager.ts | 5 +- .../src/domain/keybinding-profile.ts | 7 ++- plugins/tmux-keybindings/src/domain/models.ts | 2 +- .../keybinding-config-editor.ts | 25 ++++---- .../presentation/bindings-panel-renderer.ts | 10 +-- .../src/presentation/panel-close-shortcut.ts | 5 +- .../application/configuration-manager.test.ts | 61 +++++++++++++++++++ .../tests/domain/keybinding-profile.test.ts | 1 - .../keybinding-config-editor.test.ts | 22 ++++--- .../bindings-panel-renderer.test.ts | 3 +- .../presentation/panel-close-shortcut.test.ts | 8 +-- 12 files changed, 117 insertions(+), 40 deletions(-) diff --git a/plugins/tmux-keybindings/README.md b/plugins/tmux-keybindings/README.md index a73d161..215b769 100644 --- a/plugins/tmux-keybindings/README.md +++ b/plugins/tmux-keybindings/README.md @@ -1,12 +1,12 @@ # Tmux Keybindings -Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Ctrl+B`, then `?` to open it; repeat the chord inside the dialog or press `Esc` to close it. +Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Ctrl+B`, then `Super+Q` to open it; repeat the chord inside the dialog or press `Esc` to close it. Herdr's built-in `prefix+?` help binding remains 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 | | ---------- | ----------------------------- | -| `?` | Open/close the binding dialog | +| `Super+Q` | Open/close the binding dialog | | `d` | Detach | | `c` | New tab | | `,` | Rename tab | @@ -33,7 +33,7 @@ The apply action follows `HERDR_CONFIG_PATH` when it is set. Otherwise it uses ` ## Open the dialog -Use `Ctrl+B ?`, or invoke the action directly: +Use `Ctrl+B`, then `Super+Q`, or invoke the action directly: ```sh herdr plugin action invoke oullin.tmux-keybindings.toggle @@ -43,7 +43,7 @@ The popup is session-modal and leaves the tiled tab layout unchanged. Its header ## Restore and uninstall -Restore the values and any `prefix+?` custom command that existed before the first apply: +Restore the values and any `prefix+Super+Q` custom command that existed before the first apply: ```sh herdr plugin action invoke oullin.tmux-keybindings.restore diff --git a/plugins/tmux-keybindings/src/application/configuration-manager.ts b/plugins/tmux-keybindings/src/application/configuration-manager.ts index 9cb32f2..890ea67 100644 --- a/plugins/tmux-keybindings/src/application/configuration-manager.ts +++ b/plugins/tmux-keybindings/src/application/configuration-manager.ts @@ -29,12 +29,13 @@ 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 baseline = savedSnapshot?.version === 1 ? this.editor.restore(original, savedSnapshot) : original; + const edit = this.editor.apply(baseline, configPath); this.writeAndValidate(configPath, original, originalExists, edit.content); - if (!savedSnapshot) { + if (!savedSnapshot || savedSnapshot.version === 1) { this.state.saveConfigurationSnapshot(edit.snapshot); } diff --git a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts index 891fb35..258d661 100644 --- a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts +++ b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts @@ -19,9 +19,12 @@ export const PLUGIN_ID = 'oullin.tmux-keybindings'; export const PANE_ENTRYPOINT = 'bindings'; +export const 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: '' }, { key: 'detach', value: 'prefix+d' }, { key: 'new_tab', value: 'prefix+c' }, { key: 'rename_tab', value: 'prefix+comma' }, @@ -48,7 +51,7 @@ export const PANEL_GROUPS: readonly BindingGroup[] = [ title: 'Global', bindings: [ { chord: 'Ctrl+B', description: 'prefix' }, - { chord: '?', description: 'open / close dialog' }, + { chord: 'Super+Q', 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 e954cad..c681ed1 100644 --- a/plugins/tmux-keybindings/src/domain/models.ts +++ b/plugins/tmux-keybindings/src/domain/models.ts @@ -1,7 +1,7 @@ export type { Environment } from '@oullin/herdr-plugin-core'; export interface ConfigurationSnapshot { - readonly version: 1; + readonly version: 1 | 2; readonly configPath: string; readonly keysSectionExisted: boolean; readonly assignments: Readonly>; diff --git a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts index 91f703f..2c4100f 100644 --- a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts +++ b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts @@ -1,4 +1,4 @@ -import { KEYBINDING_PROFILE, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; +import { DIALOG_SHORTCUT, KEYBINDING_PROFILE, LEGACY_DIALOG_SHORTCUT, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; import type { ConfigurationEdit, ConfigurationSnapshot } from '#tmux-keybindings/domain/models'; interface CommandBlock { @@ -25,7 +25,7 @@ export class KeybindingConfigEditor { const commandBlocks = this.commandBlocks(sourceLines); - const managedCommandBlocks = commandBlocks.filter((block) => block.key === 'prefix+?' || block.command === TOGGLE_ACTION_ID); + const managedCommandBlocks = commandBlocks.filter((block) => block.key === DIALOG_SHORTCUT || block.command === TOGGLE_ACTION_ID); const displacedCommands = managedCommandBlocks.map((block) => ({ line: block.start, text: block.text })); let lines = this.removeCommandBlocks(sourceLines, managedCommandBlocks); @@ -36,7 +36,7 @@ export class KeybindingConfigEditor { return { content: lines.join('\n'), snapshot: { - version: 1, + version: 2, configPath, keysSectionExisted: keysSection !== undefined, assignments, @@ -52,7 +52,7 @@ export class KeybindingConfigEditor { lines = this.removeCommandBlocks( lines, - commandBlocks.filter((block) => block.key === 'prefix+?' || block.command === TOGGLE_ACTION_ID), + commandBlocks.filter((block) => block.key === (snapshot.version === 1 ? LEGACY_DIALOG_SHORTCUT : DIALOG_SHORTCUT) || block.command === TOGGLE_ACTION_ID), ); lines = this.restoreAssignments(lines, snapshot); @@ -113,9 +113,7 @@ export class KeybindingConfigEditor { let section = this.keysSection(lines); if (!section) { - const originals = KEYBINDING_PROFILE.flatMap((binding) => { - const assignment = snapshot.assignments[binding.key]; - + const originals = Object.values(snapshot.assignments).flatMap((assignment) => { return assignment === null || assignment === undefined ? [] : [assignment]; }); @@ -133,9 +131,8 @@ 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, original] of Object.entries(snapshot.assignments)) { + const index = this.assignmentIndex(lines, section, key); if (original === null || original === undefined) { if (index !== undefined) { @@ -180,7 +177,13 @@ export class KeybindingConfigEditor { } private appendManagedCommand(sourceLines: readonly string[]): string[] { - return this.appendBlock(sourceLines, ['[[keys.command]]', 'key = "prefix+?"', 'type = "plugin_action"', `command = "${TOGGLE_ACTION_ID}"`, 'description = "open tmux keybinding dialog"']); + return this.appendBlock(sourceLines, [ + '[[keys.command]]', + `key = "${DIALOG_SHORTCUT}"`, + 'type = "plugin_action"', + `command = "${TOGGLE_ACTION_ID}"`, + 'description = "open tmux keybinding dialog"', + ]); } private appendBlock(sourceLines: readonly string[], block: readonly string[]): string[] { diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts index dc55101..219845f 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -55,15 +55,15 @@ export class BindingsPanelRenderer { } private shortcutLines(width: number): readonly string[] { - if (width >= 37) { - return [this.centre('Prefix Ctrl+B • Close Ctrl+B then ?', width)]; + if (width >= 48) { + return [this.centre('Open/close Ctrl+B then Super+Q • Esc closes', width)]; } - if (width >= 20) { - return [this.centre('Prefix Ctrl+B', width), this.centre('Close Ctrl+B then ?', width)]; + if (width >= 25) { + return [this.centre('Ctrl+B then Super+Q', width), this.centre('Esc closes', width)]; } - return [this.centre('Close Ctrl+B ?', width)]; + return [this.centre('Esc closes', width)]; } private sectionHeading(title: string, width: number): string { diff --git a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts index 32d5509..e371023 100644 --- a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts +++ b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts @@ -1,7 +1,8 @@ const controlB = 0x02; const controlC = 0x03; const escape = 0x1b; -const questionMark = 0x3f; +const lowerQ = 0x71; +const upperQ = 0x51; export class PanelCloseShortcut { private prefixPressed = false; @@ -15,7 +16,7 @@ export class PanelCloseShortcut { if (this.prefixPressed) { this.prefixPressed = byte === controlB; - if (byte === questionMark) { + if (byte === lowerQ || byte === upperQ) { return true; } diff --git a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts index aa1c3cf..debf96a 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'; @@ -81,6 +82,66 @@ describe('ConfigurationManager', () => { expect(client.reloadCalls).toBe(0); }); + it('migrates the legacy help override to the dedicated dialog shortcut', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'herdr-tmux-migration-', + ), + ); + + 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, { + version: 1, + 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 = readFileSync(configPath, 'utf8'); + + expect(migrated).not.toContain('help = ""'); + expect(migrated).not.toContain('key = "prefix+?"'); + expect(migrated).toContain('key = "prefix+super+q"'); + expect(state.snapshots.get(configPath)?.version).toBe(2); + expect(state.snapshots.get(configPath)?.assignments).not.toHaveProperty('help'); + + expect( + manager.restore({ HERDR_CONFIG_PATH: configPath }), + ).toBe('restored'); + expect( + readFileSync(configPath, 'utf8').trimEnd(), + ).toBe(original.trimEnd()); + }); + it('does not reapply automatically after a restore', () => { const directory = mkdtempSync( join( diff --git a/plugins/tmux-keybindings/tests/domain/keybinding-profile.test.ts b/plugins/tmux-keybindings/tests/domain/keybinding-profile.test.ts index 7c75d73..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: '', detach: 'prefix+d', new_tab: 'prefix+c', rename_tab: 'prefix+comma', 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 2e07766..a915b83 100644 --- a/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts +++ b/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts @@ -44,29 +44,37 @@ describe('KeybindingConfigEditor', () => { 'command = "oullin.tmux-keybindings.toggle"', '', '[[keys.command]]', - 'key = "prefix+t"', + 'key = "prefix+super+q"', '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(2); expect(edit.content).toContain('[keys]\nprefix = "ctrl+b"'); expect( edit.content.match(/oullin\.tmux-keybindings\.toggle/gu), ).toHaveLength(1); - expect(edit.content).toContain('command = "lazygit"'); - expect(edit.content).not.toContain('command = "old-help"'); + expect(edit.content).toContain('command = "old-help"'); + 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: 3, - text: ['[[keys.command]]', 'key = "prefix+?"', 'type = "shell"', 'command = "old-help"', ''].join('\n'), - }, { line: 8, text: ['[[keys.command]]', 'key = "prefix+l"', 'type = "plugin_action"', 'command = "oullin.tmux-keybindings.toggle"', ''].join('\n'), }, + { + line: 13, + text: ['[[keys.command]]', 'key = "prefix+super+q"', 'type = "popup"', 'command = "lazygit"', ''].join('\n'), + }, ]); expect( editor.restore(edit.content, edit.snapshot), 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 4a6c27b..c1353e9 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -8,7 +8,8 @@ describe('BindingsPanelRenderer', () => { it('renders every group in a narrow pane', () => { const panel = renderer.render(42, 30); - expect(panel).toContain('Prefix Ctrl+B • Close Ctrl+B then ?'); + expect(panel).toContain('Ctrl+B then Super+Q'); + expect(panel).toContain('Esc closes'); expect(panel).toContain('GLOBAL'); expect(panel).toContain('TABS'); expect(panel).toContain('PANES'); diff --git a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts index c989fee..02f7ae5 100644 --- a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts @@ -3,9 +3,9 @@ import { describe, expect, it } from 'vite-plus/test'; import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; describe('PanelCloseShortcut', () => { - it('closes when Ctrl+B and question mark arrive together', () => { + it('closes when Ctrl+B and Q arrive together', () => { expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x02, 0x3f])), + new PanelCloseShortcut().accept(Uint8Array.from([0x02, 0x71])), ).toBe(true); }); @@ -16,7 +16,7 @@ describe('PanelCloseShortcut', () => { shortcut.accept(Uint8Array.from([0x02])), ).toBe(false); expect( - shortcut.accept(Uint8Array.from([0x3f])), + shortcut.accept(Uint8Array.from([0x51])), ).toBe(true); }); @@ -24,7 +24,7 @@ describe('PanelCloseShortcut', () => { const shortcut = new PanelCloseShortcut(); expect( - shortcut.accept(Uint8Array.from([0x02, 0x78, 0x3f])), + shortcut.accept(Uint8Array.from([0x02, 0x78, 0x71])), ).toBe(false); }); From 60da740daaf4b255a89aa50363486ef751d9cc50 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 16:09:49 +0800 Subject: [PATCH 08/14] Match Caps Lock hyper shortcut --- plugins/tmux-keybindings/README.md | 40 ++++++------ .../src/application/configuration-manager.ts | 5 +- .../src/domain/keybinding-profile.ts | 6 +- plugins/tmux-keybindings/src/domain/models.ts | 2 +- .../keybinding-config-editor.ts | 14 +++- .../presentation/bindings-panel-renderer.ts | 4 +- .../application/configuration-manager.test.ts | 64 ++++++++++++++++++- .../keybinding-config-editor.test.ts | 16 +++-- .../bindings-panel-renderer.test.ts | 2 +- 9 files changed, 115 insertions(+), 38 deletions(-) diff --git a/plugins/tmux-keybindings/README.md b/plugins/tmux-keybindings/README.md index 215b769..03ab6df 100644 --- a/plugins/tmux-keybindings/README.md +++ b/plugins/tmux-keybindings/README.md @@ -1,26 +1,26 @@ # Tmux Keybindings -Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Ctrl+B`, then `Super+Q` to open it; repeat the chord inside the dialog or press `Esc` to close it. Herdr's built-in `prefix+?` help binding remains untouched. +Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Ctrl+B`, then `Caps Lock+Q` to open it; repeat the chord inside the dialog or press `Esc` to close it. The Caps Lock shortcut targets Hyperkey's macOS mapping (`Ctrl+Alt+Shift+Super`), while Herdr's built-in `prefix+?` help binding remains 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 | -| ---------- | ----------------------------- | -| `Super+Q` | 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 | +| Prefix key | Action | +| ------------- | ----------------------------- | +| `Caps Lock+Q` | 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 @@ -33,7 +33,7 @@ The apply action follows `HERDR_CONFIG_PATH` when it is set. Otherwise it uses ` ## Open the dialog -Use `Ctrl+B`, then `Super+Q`, or invoke the action directly: +Use `Ctrl+B`, then `Caps Lock+Q`, or invoke the action directly. The underlying Herdr binding is `prefix+ctrl+alt+shift+super+q`: ```sh herdr plugin action invoke oullin.tmux-keybindings.toggle @@ -43,7 +43,7 @@ The popup is session-modal and leaves the tiled tab layout unchanged. Its header ## Restore and uninstall -Restore the values and any `prefix+Super+Q` custom command that existed before the first apply: +Restore the values and any `prefix+ctrl+alt+shift+super+q` custom command that existed before the first apply: ```sh herdr plugin action invoke oullin.tmux-keybindings.restore diff --git a/plugins/tmux-keybindings/src/application/configuration-manager.ts b/plugins/tmux-keybindings/src/application/configuration-manager.ts index 890ea67..2f48268 100644 --- a/plugins/tmux-keybindings/src/application/configuration-manager.ts +++ b/plugins/tmux-keybindings/src/application/configuration-manager.ts @@ -30,12 +30,13 @@ export class ConfigurationManager { const originalExists = this.files.exists(configPath); const original = this.files.read(configPath) ?? ''; const savedSnapshot = this.state.loadConfigurationSnapshot(configPath); - const baseline = savedSnapshot?.version === 1 ? this.editor.restore(original, savedSnapshot) : original; + const requiresMigration = savedSnapshot !== undefined && savedSnapshot.version < 3; + const baseline = requiresMigration ? this.editor.restore(original, savedSnapshot) : original; const edit = this.editor.apply(baseline, configPath); this.writeAndValidate(configPath, original, originalExists, edit.content); - if (!savedSnapshot || savedSnapshot.version === 1) { + if (!savedSnapshot || requiresMigration) { this.state.saveConfigurationSnapshot(edit.snapshot); } diff --git a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts index 258d661..0733a8f 100644 --- a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts +++ b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts @@ -19,7 +19,9 @@ export const PLUGIN_ID = 'oullin.tmux-keybindings'; export const PANE_ENTRYPOINT = 'bindings'; -export const DIALOG_SHORTCUT = 'prefix+super+q'; +export const DIALOG_SHORTCUT = 'prefix+ctrl+alt+shift+super+q'; + +export const SUPER_DIALOG_SHORTCUT = 'prefix+super+q'; export const LEGACY_DIALOG_SHORTCUT = 'prefix+?'; @@ -51,7 +53,7 @@ export const PANEL_GROUPS: readonly BindingGroup[] = [ title: 'Global', bindings: [ { chord: 'Ctrl+B', description: 'prefix' }, - { chord: 'Super+Q', description: 'open / close dialog' }, + { chord: 'CapsLock+Q', 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 c681ed1..73c37ee 100644 --- a/plugins/tmux-keybindings/src/domain/models.ts +++ b/plugins/tmux-keybindings/src/domain/models.ts @@ -1,7 +1,7 @@ export type { Environment } from '@oullin/herdr-plugin-core'; export interface ConfigurationSnapshot { - readonly version: 1 | 2; + readonly version: 1 | 2 | 3; readonly configPath: string; readonly keysSectionExisted: boolean; readonly assignments: Readonly>; diff --git a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts index 2c4100f..a70fd73 100644 --- a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts +++ b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts @@ -1,4 +1,4 @@ -import { DIALOG_SHORTCUT, KEYBINDING_PROFILE, LEGACY_DIALOG_SHORTCUT, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; +import { DIALOG_SHORTCUT, KEYBINDING_PROFILE, LEGACY_DIALOG_SHORTCUT, SUPER_DIALOG_SHORTCUT, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; import type { ConfigurationEdit, ConfigurationSnapshot } from '#tmux-keybindings/domain/models'; interface CommandBlock { @@ -36,7 +36,7 @@ export class KeybindingConfigEditor { return { content: lines.join('\n'), snapshot: { - version: 2, + version: 3, configPath, keysSectionExisted: keysSection !== undefined, assignments, @@ -52,7 +52,7 @@ export class KeybindingConfigEditor { lines = this.removeCommandBlocks( lines, - commandBlocks.filter((block) => block.key === (snapshot.version === 1 ? LEGACY_DIALOG_SHORTCUT : DIALOG_SHORTCUT) || block.command === TOGGLE_ACTION_ID), + commandBlocks.filter((block) => block.key === this.snapshotDialogShortcut(snapshot) || block.command === TOGGLE_ACTION_ID), ); lines = this.restoreAssignments(lines, snapshot); @@ -71,6 +71,14 @@ export class KeybindingConfigEditor { return lines.join('\n'); } + private snapshotDialogShortcut(snapshot: ConfigurationSnapshot): string { + if (snapshot.version === 1) { + return LEGACY_DIALOG_SHORTCUT; + } + + return snapshot.version === 2 ? SUPER_DIALOG_SHORTCUT : DIALOG_SHORTCUT; + } + private applyAssignments(sourceLines: readonly string[]): string[] { const lines = [...sourceLines]; diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts index 219845f..410666b 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -56,11 +56,11 @@ export class BindingsPanelRenderer { private shortcutLines(width: number): readonly string[] { if (width >= 48) { - return [this.centre('Open/close Ctrl+B then Super+Q • Esc closes', width)]; + return [this.centre('Open/close Ctrl+B then CapsLock+Q • Esc closes', width)]; } if (width >= 25) { - return [this.centre('Ctrl+B then Super+Q', width), this.centre('Esc closes', width)]; + return [this.centre('Ctrl+B then CapsLock+Q', width), this.centre('Esc closes', width)]; } return [this.centre('Esc closes', width)]; diff --git a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts index debf96a..a14692c 100644 --- a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts +++ b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts @@ -130,8 +130,8 @@ describe('ConfigurationManager', () => { expect(migrated).not.toContain('help = ""'); expect(migrated).not.toContain('key = "prefix+?"'); - expect(migrated).toContain('key = "prefix+super+q"'); - expect(state.snapshots.get(configPath)?.version).toBe(2); + expect(migrated).toContain('key = "prefix+ctrl+alt+shift+super+q"'); + expect(state.snapshots.get(configPath)?.version).toBe(3); expect(state.snapshots.get(configPath)?.assignments).not.toHaveProperty('help'); expect( @@ -142,6 +142,66 @@ describe('ConfigurationManager', () => { ).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 = "prefix+ctrl+alt+shift+super+q"'); + expect( + migrated.match(/oullin\.tmux-keybindings\.toggle/gu), + ).toHaveLength(1); + expect(state.snapshots.get(configPath)?.version).toBe(3); + + expect( + manager.restore({ HERDR_CONFIG_PATH: configPath }), + ).toBe('restored'); + expect( + readFileSync(configPath, 'utf8').trimEnd(), + ).toBe(original.trimEnd()); + }); + it('does not reapply automatically after a restore', () => { const directory = mkdtempSync( join( 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 a915b83..43abb67 100644 --- a/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts +++ b/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts @@ -39,12 +39,17 @@ describe('KeybindingConfigEditor', () => { 'command = "old-help"', '', '[[keys.command]]', + 'key = "prefix+super+q"', + 'type = "shell"', + 'command = "old-super"', + '', + '[[keys.command]]', 'key = "prefix+l"', 'type = "plugin_action"', 'command = "oullin.tmux-keybindings.toggle"', '', '[[keys.command]]', - 'key = "prefix+super+q"', + 'key = "prefix+ctrl+alt+shift+super+q"', 'type = "popup"', 'command = "lazygit"', '', @@ -57,23 +62,24 @@ describe('KeybindingConfigEditor', () => { const edit = editor.apply(source, '/config.toml'); - expect(edit.snapshot.version).toBe(2); + expect(edit.snapshot.version).toBe(3); expect(edit.content).toContain('[keys]\nprefix = "ctrl+b"'); expect( edit.content.match(/oullin\.tmux-keybindings\.toggle/gu), ).toHaveLength(1); expect(edit.content).toContain('command = "old-help"'); + expect(edit.content).toContain('command = "old-super"'); 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, + line: 13, text: ['[[keys.command]]', 'key = "prefix+l"', 'type = "plugin_action"', 'command = "oullin.tmux-keybindings.toggle"', ''].join('\n'), }, { - line: 13, - text: ['[[keys.command]]', 'key = "prefix+super+q"', 'type = "popup"', 'command = "lazygit"', ''].join('\n'), + line: 18, + text: ['[[keys.command]]', 'key = "prefix+ctrl+alt+shift+super+q"', 'type = "popup"', 'command = "lazygit"', ''].join('\n'), }, ]); expect( 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 c1353e9..93a964b 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -8,7 +8,7 @@ describe('BindingsPanelRenderer', () => { it('renders every group in a narrow pane', () => { const panel = renderer.render(42, 30); - expect(panel).toContain('Ctrl+B then Super+Q'); + expect(panel).toContain('Ctrl+B then CapsLock+Q'); expect(panel).toContain('Esc closes'); expect(panel).toContain('GLOBAL'); expect(panel).toContain('TABS'); From 41f59cdd51cd5b14590d9fe862a434dad87d3790 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 16:18:58 +0800 Subject: [PATCH 09/14] Use direct dialog shortcut --- plugins/tmux-keybindings/README.md | 40 ++++---- .../src/application/configuration-manager.ts | 2 +- .../src/domain/keybinding-profile.ts | 8 +- plugins/tmux-keybindings/src/domain/models.ts | 2 +- .../keybinding-config-editor.ts | 23 ++++- .../presentation/bindings-panel-renderer.ts | 4 +- .../src/presentation/panel-close-shortcut.ts | 17 ---- .../application/configuration-manager.test.ts | 98 ++++++++++++++++++- .../keybinding-config-editor.test.ts | 22 ++++- .../bindings-panel-renderer.test.ts | 2 +- .../presentation/panel-close-shortcut.test.ts | 21 +--- 11 files changed, 166 insertions(+), 73 deletions(-) diff --git a/plugins/tmux-keybindings/README.md b/plugins/tmux-keybindings/README.md index 03ab6df..f41cc5a 100644 --- a/plugins/tmux-keybindings/README.md +++ b/plugins/tmux-keybindings/README.md @@ -1,26 +1,26 @@ # Tmux Keybindings -Apply a canonical tmux-style keymap to Herdr and open a compact binding reference as a centered modal dialog. Press `Ctrl+B`, then `Caps Lock+Q` to open it; repeat the chord inside the dialog or press `Esc` to close it. The Caps Lock shortcut targets Hyperkey's macOS mapping (`Ctrl+Alt+Shift+Super`), while Herdr's built-in `prefix+?` help binding remains untouched. +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 | -| ------------- | ----------------------------- | -| `Caps Lock+Q` | 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 | +| 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 @@ -33,7 +33,7 @@ The apply action follows `HERDR_CONFIG_PATH` when it is set. Otherwise it uses ` ## Open the dialog -Use `Ctrl+B`, then `Caps Lock+Q`, or invoke the action directly. The underlying Herdr binding is `prefix+ctrl+alt+shift+super+q`: +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 @@ -43,7 +43,7 @@ The popup is session-modal and leaves the tiled tab layout unchanged. Its header ## Restore and uninstall -Restore the values and any `prefix+ctrl+alt+shift+super+q` custom command that existed before the first apply: +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/src/application/configuration-manager.ts b/plugins/tmux-keybindings/src/application/configuration-manager.ts index 2f48268..20c276b 100644 --- a/plugins/tmux-keybindings/src/application/configuration-manager.ts +++ b/plugins/tmux-keybindings/src/application/configuration-manager.ts @@ -30,7 +30,7 @@ export class ConfigurationManager { const originalExists = this.files.exists(configPath); const original = this.files.read(configPath) ?? ''; const savedSnapshot = this.state.loadConfigurationSnapshot(configPath); - const requiresMigration = savedSnapshot !== undefined && savedSnapshot.version < 3; + const requiresMigration = savedSnapshot !== undefined && savedSnapshot.version < 5; const baseline = requiresMigration ? this.editor.restore(original, savedSnapshot) : original; const edit = this.editor.apply(baseline, configPath); diff --git a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts index 0733a8f..9eeb42e 100644 --- a/plugins/tmux-keybindings/src/domain/keybinding-profile.ts +++ b/plugins/tmux-keybindings/src/domain/keybinding-profile.ts @@ -19,7 +19,11 @@ export const PLUGIN_ID = 'oullin.tmux-keybindings'; export const PANE_ENTRYPOINT = 'bindings'; -export const DIALOG_SHORTCUT = 'prefix+ctrl+alt+shift+super+q'; +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'; @@ -53,7 +57,7 @@ export const PANEL_GROUPS: readonly BindingGroup[] = [ title: 'Global', bindings: [ { chord: 'Ctrl+B', description: 'prefix' }, - { chord: 'CapsLock+Q', description: 'open / close dialog' }, + { 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 73c37ee..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 } from '@oullin/herdr-plugin-core'; export interface ConfigurationSnapshot { - readonly version: 1 | 2 | 3; + readonly version: 1 | 2 | 3 | 4 | 5; readonly configPath: string; readonly keysSectionExisted: boolean; readonly assignments: Readonly>; diff --git a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts index a70fd73..06134bf 100644 --- a/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts +++ b/plugins/tmux-keybindings/src/infrastructure/keybinding-config-editor.ts @@ -1,6 +1,15 @@ -import { DIALOG_SHORTCUT, KEYBINDING_PROFILE, LEGACY_DIALOG_SHORTCUT, SUPER_DIALOG_SHORTCUT, TOGGLE_ACTION_ID } from '#tmux-keybindings/domain/keybinding-profile'; import type { ConfigurationEdit, ConfigurationSnapshot } from '#tmux-keybindings/domain/models'; +import { + DIALOG_SHORTCUT, + GHOSTTY_BRIDGE_DIALOG_SHORTCUT, + HYPER_DIALOG_SHORTCUT, + KEYBINDING_PROFILE, + LEGACY_DIALOG_SHORTCUT, + SUPER_DIALOG_SHORTCUT, + TOGGLE_ACTION_ID, +} from '#tmux-keybindings/domain/keybinding-profile'; + interface CommandBlock { readonly start: number; readonly end: number; @@ -36,7 +45,7 @@ export class KeybindingConfigEditor { return { content: lines.join('\n'), snapshot: { - version: 3, + version: 5, configPath, keysSectionExisted: keysSection !== undefined, assignments, @@ -76,7 +85,15 @@ export class KeybindingConfigEditor { return LEGACY_DIALOG_SHORTCUT; } - return snapshot.version === 2 ? SUPER_DIALOG_SHORTCUT : DIALOG_SHORTCUT; + if (snapshot.version === 2) { + return SUPER_DIALOG_SHORTCUT; + } + + if (snapshot.version === 3) { + return HYPER_DIALOG_SHORTCUT; + } + + return snapshot.version === 4 ? GHOSTTY_BRIDGE_DIALOG_SHORTCUT : DIALOG_SHORTCUT; } private applyAssignments(sourceLines: readonly string[]): string[] { diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts index 410666b..8ddfb26 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -56,11 +56,11 @@ export class BindingsPanelRenderer { private shortcutLines(width: number): readonly string[] { if (width >= 48) { - return [this.centre('Open/close Ctrl+B then CapsLock+Q • Esc closes', width)]; + return [this.centre('Open/close Option+Command+T • Esc closes', width)]; } if (width >= 25) { - return [this.centre('Ctrl+B then CapsLock+Q', width), this.centre('Esc closes', width)]; + return [this.centre('Option+Command+T', width), this.centre('Esc closes', width)]; } return [this.centre('Esc closes', width)]; diff --git a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts index e371023..c55ca81 100644 --- a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts +++ b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts @@ -1,29 +1,12 @@ -const controlB = 0x02; const controlC = 0x03; const escape = 0x1b; -const lowerQ = 0x71; -const upperQ = 0x51; export class PanelCloseShortcut { - private prefixPressed = false; - accept(input: Uint8Array): boolean { for (const byte of input) { if (byte === escape || byte === controlC) { return true; } - - if (this.prefixPressed) { - this.prefixPressed = byte === controlB; - - if (byte === lowerQ || byte === upperQ) { - return true; - } - - continue; - } - - this.prefixPressed = byte === controlB; } return false; diff --git a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts index a14692c..910bf08 100644 --- a/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts +++ b/plugins/tmux-keybindings/tests/application/configuration-manager.test.ts @@ -130,8 +130,8 @@ describe('ConfigurationManager', () => { expect(migrated).not.toContain('help = ""'); expect(migrated).not.toContain('key = "prefix+?"'); - expect(migrated).toContain('key = "prefix+ctrl+alt+shift+super+q"'); - expect(state.snapshots.get(configPath)?.version).toBe(3); + 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( @@ -188,11 +188,11 @@ describe('ConfigurationManager', () => { const migrated = readFileSync(configPath, 'utf8'); expect(migrated).toContain('command = "old-super"'); - expect(migrated).toContain('key = "prefix+ctrl+alt+shift+super+q"'); + expect(migrated).toContain('key = "alt+super+t"'); expect( migrated.match(/oullin\.tmux-keybindings\.toggle/gu), ).toHaveLength(1); - expect(state.snapshots.get(configPath)?.version).toBe(3); + expect(state.snapshots.get(configPath)?.version).toBe(5); expect( manager.restore({ HERDR_CONFIG_PATH: configPath }), @@ -202,6 +202,96 @@ describe('ConfigurationManager', () => { ).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: [], + }); + + expect( + manager.apply({ HERDR_CONFIG_PATH: configPath }), + ).toBe('applied'); + + 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('migrates the terminal bridge to the direct dialog shortcut', () => { + const directory = mkdtempSync( + join( + tmpdir(), + 'herdr-tmux-bridge-migration-', + ), + ); + + const configPath = join(directory, 'config.toml'); + + 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'); + + writeFileSync(configPath, bridgeApplied); + + 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 }), + ).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', () => { const directory = mkdtempSync( join( 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 43abb67..3d3936b 100644 --- a/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts +++ b/plugins/tmux-keybindings/tests/infrastructure/keybinding-config-editor.test.ts @@ -44,12 +44,22 @@ describe('KeybindingConfigEditor', () => { '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"', 'type = "plugin_action"', 'command = "oullin.tmux-keybindings.toggle"', '', '[[keys.command]]', - 'key = "prefix+ctrl+alt+shift+super+q"', + 'key = "alt+super+t"', 'type = "popup"', 'command = "lazygit"', '', @@ -62,24 +72,26 @@ describe('KeybindingConfigEditor', () => { const edit = editor.apply(source, '/config.toml'); - expect(edit.snapshot.version).toBe(3); + expect(edit.snapshot.version).toBe(5); expect(edit.content).toContain('[keys]\nprefix = "ctrl+b"'); expect( edit.content.match(/oullin\.tmux-keybindings\.toggle/gu), ).toHaveLength(1); 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: 13, + line: 23, text: ['[[keys.command]]', 'key = "prefix+l"', 'type = "plugin_action"', 'command = "oullin.tmux-keybindings.toggle"', ''].join('\n'), }, { - line: 18, - text: ['[[keys.command]]', 'key = "prefix+ctrl+alt+shift+super+q"', 'type = "popup"', 'command = "lazygit"', ''].join('\n'), + line: 28, + text: ['[[keys.command]]', 'key = "alt+super+t"', 'type = "popup"', 'command = "lazygit"', ''].join('\n'), }, ]); expect( 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 93a964b..c47a544 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -8,7 +8,7 @@ describe('BindingsPanelRenderer', () => { it('renders every group in a narrow pane', () => { const panel = renderer.render(42, 30); - expect(panel).toContain('Ctrl+B then CapsLock+Q'); + expect(panel).toContain('Option+Command+T'); expect(panel).toContain('Esc closes'); expect(panel).toContain('GLOBAL'); expect(panel).toContain('TABS'); diff --git a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts index 02f7ae5..436e5d6 100644 --- a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts @@ -3,28 +3,15 @@ import { describe, expect, it } from 'vite-plus/test'; import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; describe('PanelCloseShortcut', () => { - it('closes when Ctrl+B and Q arrive together', () => { + it('closes when Option+Command+T arrives as a legacy alt sequence', () => { expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x02, 0x71])), + new PanelCloseShortcut().accept(Uint8Array.from([0x1b, 0x74])), ).toBe(true); }); - it('remembers Ctrl+B across input chunks', () => { - const shortcut = new PanelCloseShortcut(); - - expect( - shortcut.accept(Uint8Array.from([0x02])), - ).toBe(false); - expect( - shortcut.accept(Uint8Array.from([0x51])), - ).toBe(true); - }); - - it('ignores unrelated keys after the prefix', () => { - const shortcut = new PanelCloseShortcut(); - + it('ignores plain input', () => { expect( - shortcut.accept(Uint8Array.from([0x02, 0x78, 0x71])), + new PanelCloseShortcut().accept(Uint8Array.from([0x74])), ).toBe(false); }); From 9eb32035267c834e1d257746907f343cabdf6743 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 16:57:21 +0800 Subject: [PATCH 10/14] Preserve pane lookup failures --- .../plugin-core/src/herdr-cli/panes/index.ts | 15 ++-- .../tests/herdr-cli-client.test.ts | 76 +++++++++++++++++++ 2 files changed, 86 insertions(+), 5 deletions(-) diff --git a/packages/plugin-core/src/herdr-cli/panes/index.ts b/packages/plugin-core/src/herdr-cli/panes/index.ts index 71cf9d4..b23ef20 100644 --- a/packages/plugin-core/src/herdr-cli/panes/index.ts +++ b/packages/plugin-core/src/herdr-cli/panes/index.ts @@ -23,13 +23,18 @@ export class HerdrPaneClient implements PaneClient { 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; + } - return this.parse(this.transport.decodeResult(result.stdout, args)['pane']); + throw error; + } } reportPaneTitle(paneId: string, update: PaneTitleUpdate): void { diff --git a/packages/plugin-core/tests/herdr-cli-client.test.ts b/packages/plugin-core/tests/herdr-cli-client.test.ts index 9313acd..4589e94 100644 --- a/packages/plugin-core/tests/herdr-cli-client.test.ts +++ b/packages/plugin-core/tests/herdr-cli-client.test.ts @@ -114,6 +114,82 @@ describe('HerdrCliClient', () => { ]); }); + 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, From 5a1cf20c0c1a51fc857586acef9b181cedb74723 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 17:01:26 +0800 Subject: [PATCH 11/14] Harden pane lookup error guard --- packages/plugin-core/src/herdr-cli/panes/index.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/packages/plugin-core/src/herdr-cli/panes/index.ts b/packages/plugin-core/src/herdr-cli/panes/index.ts index b23ef20..96bd7fa 100644 --- a/packages/plugin-core/src/herdr-cli/panes/index.ts +++ b/packages/plugin-core/src/herdr-cli/panes/index.ts @@ -29,7 +29,7 @@ export class HerdrPaneClient implements PaneClient { } catch (error) { const commandError = error as HerdrCommandError; - if (commandError.code === 'pane_not_found') { + if (commandError?.code === 'pane_not_found') { return undefined; } From 65b98fca0610ed99460d291663ed7106275699e6 Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 17:17:50 +0800 Subject: [PATCH 12/14] Restyle tmux help dialog --- plugins/tmux-keybindings/herdr-plugin.toml | 2 +- .../presentation/bindings-panel-renderer.ts | 126 +++++++++--------- .../src/presentation/panel-close-shortcut.ts | 9 +- .../presentation/terminal/dialog-palette.ts | 68 ++++++++++ .../presentation/terminal/styled-segment.ts | 39 ++++++ .../presentation/terminal/terminal-line.ts | 93 +++++++++++++ .../architecture/plugin-contract.test.ts | 2 +- .../bindings-panel-renderer.test.ts | 104 ++++++++++++--- .../presentation/panel-close-shortcut.test.ts | 25 +++- .../tests/support/terminal-output.ts | 48 +++++++ 10 files changed, 417 insertions(+), 99 deletions(-) create mode 100644 plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts create mode 100644 plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts create mode 100644 plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts create mode 100644 plugins/tmux-keybindings/tests/support/terminal-output.ts diff --git a/plugins/tmux-keybindings/herdr-plugin.toml b/plugins/tmux-keybindings/herdr-plugin.toml index 4fe6714..511b23e 100644 --- a/plugins/tmux-keybindings/herdr-plugin.toml +++ b/plugins/tmux-keybindings/herdr-plugin.toml @@ -47,5 +47,5 @@ id = "bindings" title = "Tmux keybindings" placement = "popup" width = 84 -height = 24 +height = 30 command = ["node", "index.ts", "bindings"] diff --git a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts index 8ddfb26..3333b4c 100644 --- a/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts +++ b/plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts @@ -1,87 +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 shortcuts = this.shortcutLines(contentWidth); - const divider = this.centre('━'.repeat(Math.min(contentWidth, 42)), contentWidth); - const body = [header, ...shortcuts, divider, '', ...lines]; - const visible = body.slice(0, safeHeight - 1); - - if (body.length > visible.length) { - visible[visible.length - 1] = this.truncate('… resize dialog to see all bindings', contentWidth); - } - - return visible.map((line) => ` ${this.truncate(line, contentWidth)}`).join('\n'); + 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 narrowLines(width: number): readonly string[] { - return PANEL_GROUPS.flatMap((group, index) => [...(index === 0 ? [] : ['']), this.sectionHeading(group.title, width), ...group.bindings.map((binding) => this.bindingLine(binding, width))]); - } - - 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 ? [] : ['']), this.sectionHeading(group.title, columnWidth), ...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 shortcutLines(width: number): readonly string[] { - if (width >= 48) { - return [this.centre('Open/close Option+Command+T • Esc closes', width)]; - } - - if (width >= 25) { - return [this.centre('Option+Command+T', width), this.centre('Esc closes', width)]; - } - - return [this.centre('Esc closes', width)]; + private footer(palette: DialogPalette): readonly StyledSegment[] { + return this.text('toggle Option+Command+T close Esc', palette.mutedText, palette); } - private sectionHeading(title: string, width: number): string { - const heading = title.toUpperCase(); - const rule = '─'.repeat(Math.max(0, width - heading.length - 1)); - - return `${heading} ${rule}`; - } + 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); - private centre(value: string, width: number): string { - return value.padStart(Math.max(value.length, Math.floor((width + value.length) / 2))); - } + if (contentRows.length > contentHeight) { + visible[visible.length - 1] = this.text('... resize dialog to see all bindings', palette.mutedText, palette); + } - private truncate(value: string, width: number): string { - if (value.length <= width) { - return value; + 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 index c55ca81..ffc184e 100644 --- a/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts +++ b/plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts @@ -1,14 +1,13 @@ const controlC = 0x03; const escape = 0x1b; +const legacyAltT = 0x74; export class PanelCloseShortcut { accept(input: Uint8Array): boolean { - for (const byte of input) { - if (byte === escape || byte === controlC) { - return true; - } + if (input.length === 1) { + return input[0] === escape || input[0] === controlC; } - return false; + return input.length === 2 && input[0] === escape && input[1] === legacyAltT; } } 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..0c825a6 --- /dev/null +++ b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts @@ -0,0 +1,93 @@ +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 { + return `${escape}[38;2;${this.red(colour)};${this.green(colour)};${this.blue(colour)}m`; + } + + private backgroundSequence(colour: string): string { + return `${escape}[48;2;${this.red(colour)};${this.green(colour)};${this.blue(colour)}m`; + } + + private red(colour: string): number { + return Number.parseInt(colour.slice(1, 3), 16); + } + + private green(colour: string): number { + return Number.parseInt(colour.slice(3, 5), 16); + } + + private blue(colour: string): number { + return Number.parseInt(colour.slice(5, 7), 16); + } +} diff --git a/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts b/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts index f72aa1e..b0e6891 100644 --- a/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts +++ b/plugins/tmux-keybindings/tests/architecture/plugin-contract.test.ts @@ -28,7 +28,7 @@ describe('tmux-keybindings plugin contract', () => { expect(manifest).toContain('on = "workspace.created"'); expect(manifest).toContain('placement = "popup"'); expect(manifest).toContain('width = 84'); - expect(manifest).toContain('height = 24'); + expect(manifest).toContain('height = 30'); }); it('uses concern aliases and is listed in the root catalogue', () => { 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 c47a544..a4700be 100644 --- a/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/bindings-panel-renderer.test.ts @@ -1,38 +1,104 @@ 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('Option+Command+T'); - expect(panel).toContain('Esc closes'); - expect(panel).toContain('GLOBAL'); - expect(panel).toContain('TABS'); - expect(panel).toContain('PANES'); - expect(panel).toContain('% / "'); + 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'); + + 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( + output.visibleWidths.every((width) => width <= 20), + ).toBe(true); + expect( + output.rows.some((row) => row.includes('resize dialog')), + ).toBe(true); expect( - panel.split('\n'), - ).toHaveLength(5); - expect(panel).toContain('resize dialog'); + 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 index 436e5d6..a212709 100644 --- a/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts +++ b/plugins/tmux-keybindings/tests/presentation/panel-close-shortcut.test.ts @@ -3,21 +3,32 @@ import { describe, expect, it } from 'vite-plus/test'; import { PanelCloseShortcut } from '#tmux-keybindings/presentation/panel-close-shortcut'; describe('PanelCloseShortcut', () => { - it('closes when Option+Command+T arrives as a legacy alt sequence', () => { + 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( - new PanelCloseShortcut().accept(Uint8Array.from([0x1b, 0x74])), + shortcut.accept(Uint8Array.from([0x1b, 0x74])), ).toBe(true); }); - it('ignores plain input', () => { + it('rejects terminal arrow sequences', () => { expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x74])), + shortcut.accept(Uint8Array.from([0x1b, 0x5b, 0x41])), ).toBe(false); }); - it('also closes a modal dialog with Escape', () => { + it('rejects unrelated Alt and plain input sequences', () => { expect( - new PanelCloseShortcut().accept(Uint8Array.from([0x1b])), - ).toBe(true); + shortcut.accept(Uint8Array.from([0x1b, 0x78])), + ).toBe(false); + expect( + shortcut.accept(Uint8Array.from([0x74])), + ).toBe(false); }); }); 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)); + } +} From 2291a3b0cdcc5bc1f37bf8ae75d968ab314b57cd Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 17:22:17 +0800 Subject: [PATCH 13/14] Run formatting and commit changes by phase --- plans/001-preserve-pane-lookup-failures.md | 206 ++++++++++++ ...002-restyle-and-harden-tmux-help-dialog.md | 309 ++++++++++++++++++ plans/README.md | 64 ++++ 3 files changed, 579 insertions(+) create mode 100644 plans/001-preserve-pane-lookup-failures.md create mode 100644 plans/002-restyle-and-harden-tmux-help-dialog.md create mode 100644 plans/README.md diff --git a/plans/001-preserve-pane-lookup-failures.md b/plans/001-preserve-pane-lookup-failures.md new file mode 100644 index 0000000..cbf2765 --- /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..6a5c391 --- /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..383417a --- /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. From fd5083fc37ba33e66391468f1695002b3df3e04e Mon Sep 17 00:00:00 2001 From: Gustavo Ocanto Date: Tue, 21 Jul 2026 17:26:37 +0800 Subject: [PATCH 14/14] Harden terminal colour parsing --- .../presentation/terminal/terminal-line.ts | 22 ++++++++------- .../tests/presentation/terminal-line.test.ts | 27 +++++++++++++++++++ 2 files changed, 39 insertions(+), 10 deletions(-) create mode 100644 plugins/tmux-keybindings/tests/presentation/terminal-line.test.ts diff --git a/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts index 0c825a6..5f17668 100644 --- a/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts +++ b/plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts @@ -72,22 +72,24 @@ export class TerminalLine { } private foregroundSequence(colour: string): string { - return `${escape}[38;2;${this.red(colour)};${this.green(colour)};${this.blue(colour)}m`; + const [red, green, blue] = this.parseColour(colour); + + return `${escape}[38;2;${red};${green};${blue}m`; } private backgroundSequence(colour: string): string { - return `${escape}[48;2;${this.red(colour)};${this.green(colour)};${this.blue(colour)}m`; - } + const [red, green, blue] = this.parseColour(colour); - private red(colour: string): number { - return Number.parseInt(colour.slice(1, 3), 16); + return `${escape}[48;2;${red};${green};${blue}m`; } - private green(colour: string): number { - return Number.parseInt(colour.slice(3, 5), 16); - } + 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]; + } - private blue(colour: string): number { - return Number.parseInt(colour.slice(5, 7), 16); + 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/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); + }); +});