Repository navigation
Restyle and harden the tmux help dialog #7
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
gocanto
merged 3 commits into
chore/pane-navigation-hints
from
chore/ollin-tmux-help-dialog
Jul 21, 2026
Merged
Changes from all commits
Commits
File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
126 changes: 60 additions & 66 deletions
126
plugins/tmux-keybindings/src/presentation/bindings-panel-renderer.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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)]; | ||
| } | ||
| } |
9 changes: 4 additions & 5 deletions
9
plugins/tmux-keybindings/src/presentation/panel-close-shortcut.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
68 changes: 68 additions & 0 deletions
68
plugins/tmux-keybindings/src/presentation/terminal/dialog-palette.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
39 changes: 39 additions & 0 deletions
39
plugins/tmux-keybindings/src/presentation/terminal/styled-segment.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -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; | ||
| } | ||
| } |
95 changes: 95 additions & 0 deletions
95
plugins/tmux-keybindings/src/presentation/terminal/terminal-line.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,95 @@ | ||
| import type { StyledSegment } from '#tmux-keybindings/presentation/terminal/styled-segment'; | ||
|
|
||
| const escape = String.fromCodePoint(0x1b); | ||
|
|
||
| /** Renders immutable styled segments to one fixed-width terminal row. */ | ||
| export class TerminalLine { | ||
| private readonly segments: readonly StyledSegment[]; | ||
| private readonly width: number; | ||
| private readonly background: string; | ||
|
|
||
| private constructor(segments: readonly StyledSegment[], width: number, background: string) { | ||
| this.segments = segments; | ||
| this.width = width; | ||
| this.background = background; | ||
| } | ||
|
|
||
| /** Creates a fixed-width terminal row from concrete styled segments. */ | ||
| static from(segments: readonly StyledSegment[], width: number, background: string): TerminalLine { | ||
| return new TerminalLine(Object.freeze([...segments]), width, background); | ||
| } | ||
|
|
||
| /** Renders ANSI styling after measuring, truncating, and padding unstyled code points. */ | ||
| render(): string { | ||
| const renderedSegments: string[] = []; | ||
|
|
||
| let visibleWidth = 0; | ||
|
|
||
| for (const segment of this.segments) { | ||
| const remainingWidth = this.width - visibleWidth; | ||
|
|
||
| if (remainingWidth <= 0) { | ||
| break; | ||
| } | ||
|
|
||
| const text = this.truncate(segment.text, remainingWidth); | ||
|
|
||
| if (text.length === 0) { | ||
| continue; | ||
| } | ||
|
|
||
| renderedSegments.push(this.renderSegment(segment, text)); | ||
| visibleWidth += this.visibleWidth(text); | ||
| } | ||
|
|
||
| const padding = ' '.repeat(Math.max(0, this.width - visibleWidth)); | ||
|
|
||
| return `${renderedSegments.join('')}${this.backgroundSequence(this.background)}${padding}${escape}[0m`; | ||
| } | ||
|
|
||
| private renderSegment(segment: StyledSegment, text: string): string { | ||
| const emphasis = segment.emphasized ? `${escape}[1m` : `${escape}[22m`; | ||
|
|
||
| return `${this.backgroundSequence(segment.background)}${emphasis}${this.foregroundSequence(segment.foreground)}${text}`; | ||
| } | ||
|
|
||
| private visibleWidth(value: string): number { | ||
| return Array.from(value).length; | ||
| } | ||
|
|
||
| private truncate(value: string, width: number): string { | ||
| const codePoints = Array.from(value); | ||
|
|
||
| if (codePoints.length <= width) { | ||
| return value; | ||
| } | ||
|
|
||
| if (width === 1) { | ||
| return '…'; | ||
| } | ||
|
|
||
| return `${codePoints.slice(0, Math.max(0, width - 1)).join('')}…`; | ||
| } | ||
|
|
||
| private foregroundSequence(colour: string): string { | ||
| const [red, green, blue] = this.parseColour(colour); | ||
|
|
||
| return `${escape}[38;2;${red};${green};${blue}m`; | ||
| } | ||
|
|
||
| private backgroundSequence(colour: string): string { | ||
| const [red, green, blue] = this.parseColour(colour); | ||
|
|
||
| return `${escape}[48;2;${red};${green};${blue}m`; | ||
| } | ||
|
|
||
| private parseColour(colour: string): readonly [number, number, number] { | ||
| const hexadecimal = colour.startsWith('#') ? colour.slice(1) : colour; | ||
|
|
||
| if (!/^[0-9a-f]{6}$/iu.test(hexadecimal)) { | ||
| return [0, 0, 0]; | ||
| } | ||
|
|
||
| return [Number.parseInt(hexadecimal.slice(0, 2), 16), Number.parseInt(hexadecimal.slice(2, 4), 16), Number.parseInt(hexadecimal.slice(4, 6), 16)]; | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.