From 9938fb37302164eaa3daec63f91ec14c238c9e00 Mon Sep 17 00:00:00 2001 From: frizynn Date: Thu, 19 Mar 2026 16:39:18 -0300 Subject: [PATCH] refactor: deduplicate rendering patterns in markdown and keys markdown.ts: Extract renderCodeBlock() helper to eliminate duplicated code block rendering between renderToken and renderListItem. keys.ts: Consolidate LEGACY_KEY_SEQUENCES, LEGACY_SHIFT_SEQUENCES, and LEGACY_CTRL_SEQUENCES into a single LEGACY_SEQUENCES structure with plain/shift/ctrl variants per key. Auto-generate LEGACY_SEQUENCE_KEY_IDS from this structure instead of hand-maintaining a 50+ entry reverse lookup. Extract hasKittyEventType() to deduplicate isKeyRelease and isKeyRepeat. Net reduction of ~80 lines. --- packages/pi-tui/src/components/markdown.ts | 54 ++--- packages/pi-tui/src/keys.ts | 267 ++++++++------------- 2 files changed, 119 insertions(+), 202 deletions(-) diff --git a/packages/pi-tui/src/components/markdown.ts b/packages/pi-tui/src/components/markdown.ts index b24999767..629f7f9fb 100644 --- a/packages/pi-tui/src/components/markdown.ts +++ b/packages/pi-tui/src/components/markdown.ts @@ -308,21 +308,7 @@ export class Markdown implements Component { } case "code": { - const indent = this.theme.codeBlockIndent ?? " "; - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); - for (const hlLine of highlightedLines) { - lines.push(`${indent}${hlLine}`); - } - } else { - // Split code by newlines and style each line - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); - } - } - lines.push(this.theme.codeBlockBorder("```")); + lines.push(...this.renderCodeBlock(token.text, token.lang)); if (nextTokenType !== "space") { lines.push(""); // Add spacing after code blocks (unless space token follows) } @@ -589,20 +575,7 @@ export class Markdown implements Component { lines.push(text); } else if (token.type === "code") { // Code block in list item - const indent = this.theme.codeBlockIndent ?? " "; - lines.push(this.theme.codeBlockBorder(`\`\`\`${token.lang || ""}`)); - if (this.theme.highlightCode) { - const highlightedLines = this.theme.highlightCode(token.text, token.lang); - for (const hlLine of highlightedLines) { - lines.push(`${indent}${hlLine}`); - } - } else { - const codeLines = token.text.split("\n"); - for (const codeLine of codeLines) { - lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); - } - } - lines.push(this.theme.codeBlockBorder("```")); + lines.push(...this.renderCodeBlock(token.text, token.lang)); } else { // Other token types - try to render as inline const text = this.renderInlineTokens([token], styleContext); @@ -615,6 +588,29 @@ export class Markdown implements Component { return lines; } + /** + * Render a fenced code block with syntax highlighting support. + * Used by both renderToken (top-level code blocks) and renderListItem (code blocks inside lists). + */ + private renderCodeBlock(code: string, lang?: string): string[] { + const lines: string[] = []; + const indent = this.theme.codeBlockIndent ?? " "; + lines.push(this.theme.codeBlockBorder(`\`\`\`${lang || ""}`)); + if (this.theme.highlightCode) { + const highlightedLines = this.theme.highlightCode(code, lang); + for (const hlLine of highlightedLines) { + lines.push(`${indent}${hlLine}`); + } + } else { + const codeLines = code.split("\n"); + for (const codeLine of codeLines) { + lines.push(`${indent}${this.theme.codeBlock(codeLine)}`); + } + } + lines.push(this.theme.codeBlockBorder("```")); + return lines; + } + /** * Get the visible width of the longest word in a string. */ diff --git a/packages/pi-tui/src/keys.ts b/packages/pi-tui/src/keys.ts index 57366f6f5..eff21579c 100644 --- a/packages/pi-tui/src/keys.ts +++ b/packages/pi-tui/src/keys.ts @@ -325,131 +325,74 @@ const FUNCTIONAL_CODEPOINTS = { end: -15, } as const; -const LEGACY_KEY_SEQUENCES = { - up: ["\x1b[A", "\x1bOA"], - down: ["\x1b[B", "\x1bOB"], - right: ["\x1b[C", "\x1bOC"], - left: ["\x1b[D", "\x1bOD"], - home: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], - end: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], - insert: ["\x1b[2~"], - delete: ["\x1b[3~"], - pageUp: ["\x1b[5~", "\x1b[[5~"], - pageDown: ["\x1b[6~", "\x1b[[6~"], - clear: ["\x1b[E", "\x1bOE"], - f1: ["\x1bOP", "\x1b[11~", "\x1b[[A"], - f2: ["\x1bOQ", "\x1b[12~", "\x1b[[B"], - f3: ["\x1bOR", "\x1b[13~", "\x1b[[C"], - f4: ["\x1bOS", "\x1b[14~", "\x1b[[D"], - f5: ["\x1b[15~", "\x1b[[E"], - f6: ["\x1b[17~"], - f7: ["\x1b[18~"], - f8: ["\x1b[19~"], - f9: ["\x1b[20~"], - f10: ["\x1b[21~"], - f11: ["\x1b[23~"], - f12: ["\x1b[24~"], +/** + * Consolidated legacy terminal key sequences. + * Each key maps to its sequences for unmodified, shift-modified, and ctrl-modified variants. + * This single structure replaces three separate maps (LEGACY_KEY_SEQUENCES, + * LEGACY_SHIFT_SEQUENCES, LEGACY_CTRL_SEQUENCES) that shared the same key sets. + */ +const LEGACY_SEQUENCES: Record = { + up: { plain: ["\x1b[A", "\x1bOA"], shift: ["\x1b[a"], ctrl: ["\x1bOa"] }, + down: { plain: ["\x1b[B", "\x1bOB"], shift: ["\x1b[b"], ctrl: ["\x1bOb"] }, + right: { plain: ["\x1b[C", "\x1bOC"], shift: ["\x1b[c"], ctrl: ["\x1bOc"] }, + left: { plain: ["\x1b[D", "\x1bOD"], shift: ["\x1b[d"], ctrl: ["\x1bOd"] }, + home: { plain: ["\x1b[H", "\x1bOH", "\x1b[1~", "\x1b[7~"], shift: ["\x1b[7$"], ctrl: ["\x1b[7^"] }, + end: { plain: ["\x1b[F", "\x1bOF", "\x1b[4~", "\x1b[8~"], shift: ["\x1b[8$"], ctrl: ["\x1b[8^"] }, + insert: { plain: ["\x1b[2~"], shift: ["\x1b[2$"], ctrl: ["\x1b[2^"] }, + delete: { plain: ["\x1b[3~"], shift: ["\x1b[3$"], ctrl: ["\x1b[3^"] }, + pageUp: { plain: ["\x1b[5~", "\x1b[[5~"], shift: ["\x1b[5$"], ctrl: ["\x1b[5^"] }, + pageDown: { plain: ["\x1b[6~", "\x1b[[6~"], shift: ["\x1b[6$"], ctrl: ["\x1b[6^"] }, + clear: { plain: ["\x1b[E", "\x1bOE"], shift: ["\x1b[e"], ctrl: ["\x1bOe"] }, + f1: { plain: ["\x1bOP", "\x1b[11~", "\x1b[[A"] }, + f2: { plain: ["\x1bOQ", "\x1b[12~", "\x1b[[B"] }, + f3: { plain: ["\x1bOR", "\x1b[13~", "\x1b[[C"] }, + f4: { plain: ["\x1bOS", "\x1b[14~", "\x1b[[D"] }, + f5: { plain: ["\x1b[15~", "\x1b[[E"] }, + f6: { plain: ["\x1b[17~"] }, + f7: { plain: ["\x1b[18~"] }, + f8: { plain: ["\x1b[19~"] }, + f9: { plain: ["\x1b[20~"] }, + f10: { plain: ["\x1b[21~"] }, + f11: { plain: ["\x1b[23~"] }, + f12: { plain: ["\x1b[24~"] }, } as const; -const LEGACY_SHIFT_SEQUENCES = { - up: ["\x1b[a"], - down: ["\x1b[b"], - right: ["\x1b[c"], - left: ["\x1b[d"], - clear: ["\x1b[e"], - insert: ["\x1b[2$"], - delete: ["\x1b[3$"], - pageUp: ["\x1b[5$"], - pageDown: ["\x1b[6$"], - home: ["\x1b[7$"], - end: ["\x1b[8$"], -} as const; - -const LEGACY_CTRL_SEQUENCES = { - up: ["\x1bOa"], - down: ["\x1bOb"], - right: ["\x1bOc"], - left: ["\x1bOd"], - clear: ["\x1bOe"], - insert: ["\x1b[2^"], - delete: ["\x1b[3^"], - pageUp: ["\x1b[5^"], - pageDown: ["\x1b[6^"], - home: ["\x1b[7^"], - end: ["\x1b[8^"], -} as const; - -const LEGACY_SEQUENCE_KEY_IDS: Record = { - "\x1bOA": "up", - "\x1bOB": "down", - "\x1bOC": "right", - "\x1bOD": "left", - "\x1bOH": "home", - "\x1bOF": "end", - "\x1b[E": "clear", - "\x1bOE": "clear", - "\x1bOe": "ctrl+clear", - "\x1b[e": "shift+clear", - "\x1b[2~": "insert", - "\x1b[2$": "shift+insert", - "\x1b[2^": "ctrl+insert", - "\x1b[3$": "shift+delete", - "\x1b[3^": "ctrl+delete", - "\x1b[[5~": "pageUp", - "\x1b[[6~": "pageDown", - "\x1b[a": "shift+up", - "\x1b[b": "shift+down", - "\x1b[c": "shift+right", - "\x1b[d": "shift+left", - "\x1bOa": "ctrl+up", - "\x1bOb": "ctrl+down", - "\x1bOc": "ctrl+right", - "\x1bOd": "ctrl+left", - "\x1b[5$": "shift+pageUp", - "\x1b[6$": "shift+pageDown", - "\x1b[7$": "shift+home", - "\x1b[8$": "shift+end", - "\x1b[5^": "ctrl+pageUp", - "\x1b[6^": "ctrl+pageDown", - "\x1b[7^": "ctrl+home", - "\x1b[8^": "ctrl+end", - "\x1bOP": "f1", - "\x1bOQ": "f2", - "\x1bOR": "f3", - "\x1bOS": "f4", - "\x1b[11~": "f1", - "\x1b[12~": "f2", - "\x1b[13~": "f3", - "\x1b[14~": "f4", - "\x1b[[A": "f1", - "\x1b[[B": "f2", - "\x1b[[C": "f3", - "\x1b[[D": "f4", - "\x1b[[E": "f5", - "\x1b[15~": "f5", - "\x1b[17~": "f6", - "\x1b[18~": "f7", - "\x1b[19~": "f8", - "\x1b[20~": "f9", - "\x1b[21~": "f10", - "\x1b[23~": "f11", - "\x1b[24~": "f12", - "\x1bb": "alt+left", - "\x1bf": "alt+right", - "\x1bp": "alt+up", - "\x1bn": "alt+down", -} as const; - -type LegacyModifierKey = keyof typeof LEGACY_SHIFT_SEQUENCES; +/** + * Reverse lookup from escape sequence to key identifier, auto-generated from LEGACY_SEQUENCES. + * Additional non-standard sequences (alt+arrow aliases) are appended after generation. + */ +const LEGACY_SEQUENCE_KEY_IDS: Record = (() => { + const map: Record = {}; + for (const [key, entry] of Object.entries(LEGACY_SEQUENCES)) { + const keyId = key as KeyId; + if (entry.plain) { + for (const seq of entry.plain) map[seq] = keyId; + } + if (entry.shift) { + for (const seq of entry.shift) map[seq] = `shift+${keyId}` as KeyId; + } + if (entry.ctrl) { + for (const seq of entry.ctrl) map[seq] = `ctrl+${keyId}` as KeyId; + } + } + // Non-standard alt+arrow aliases not derivable from the table + map["\x1bb"] = "alt+left"; + map["\x1bf"] = "alt+right"; + map["\x1bp"] = "alt+up"; + map["\x1bn"] = "alt+down"; + return map; +})(); const matchesLegacySequence = (data: string, sequences: readonly string[]): boolean => sequences.includes(data); -const matchesLegacyModifierSequence = (data: string, key: LegacyModifierKey, modifier: number): boolean => { - if (modifier === MODIFIERS.shift) { - return matchesLegacySequence(data, LEGACY_SHIFT_SEQUENCES[key]); +const matchesLegacyModifierSequence = (data: string, key: string, modifier: number): boolean => { + const entry = LEGACY_SEQUENCES[key]; + if (!entry) return false; + if (modifier === MODIFIERS.shift && entry.shift) { + return matchesLegacySequence(data, entry.shift); } - if (modifier === MODIFIERS.ctrl) { - return matchesLegacySequence(data, LEGACY_CTRL_SEQUENCES[key]); + if (modifier === MODIFIERS.ctrl && entry.ctrl) { + return matchesLegacySequence(data, entry.ctrl); } return false; }; @@ -481,33 +424,29 @@ interface ParsedModifyOtherKeysSequence { let _lastEventType: KeyEventType = "press"; /** - * Check if the last parsed key event was a key release. - * Only meaningful when Kitty keyboard protocol with flag 2 is active. + * Check if input data contains a Kitty event type marker. + * Event type markers appear as ":" followed by a sequence terminator (u, ~, A-D, H, F). + * Ignores bracketed paste content which may contain similar patterns. */ -export function isKeyRelease(data: string): boolean { - // Don't treat bracketed paste content as key release, even if it contains - // patterns like ":3F" (e.g., bluetooth MAC addresses like "90:62:3F:A5"). - // Terminal.ts re-wraps paste content with bracketed paste markers before - // passing to TUI, so pasted data will always contain \x1b[200~. +function hasKittyEventType(data: string, eventType: number): boolean { if (data.includes("\x1b[200~")) { return false; } + const marker = `:${eventType}`; + return ( + data.includes(`${marker}u`) || + data.includes(`${marker}~`) || + data.includes(`${marker}A`) || + data.includes(`${marker}B`) || + data.includes(`${marker}C`) || + data.includes(`${marker}D`) || + data.includes(`${marker}H`) || + data.includes(`${marker}F`) + ); +} - // Quick check: release events with flag 2 contain ":3" - // Format: \x1b[;:3u - if ( - data.includes(":3u") || - data.includes(":3~") || - data.includes(":3A") || - data.includes(":3B") || - data.includes(":3C") || - data.includes(":3D") || - data.includes(":3H") || - data.includes(":3F") - ) { - return true; - } - return false; +export function isKeyRelease(data: string): boolean { + return hasKittyEventType(data, 3); } /** @@ -515,25 +454,7 @@ export function isKeyRelease(data: string): boolean { * Only meaningful when Kitty keyboard protocol with flag 2 is active. */ export function isKeyRepeat(data: string): boolean { - // Don't treat bracketed paste content as key repeat, even if it contains - // patterns like ":2F". See isKeyRelease() for details. - if (data.includes("\x1b[200~")) { - return false; - } - - if ( - data.includes(":2u") || - data.includes(":2~") || - data.includes(":2A") || - data.includes(":2B") || - data.includes(":2C") || - data.includes(":2D") || - data.includes(":2H") || - data.includes(":2F") - ) { - return true; - } - return false; + return hasKittyEventType(data, 2); } function parseEventType(eventTypeStr: string | undefined): KeyEventType { @@ -847,7 +768,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "insert": if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.insert) || + matchesLegacySequence(data, LEGACY_SEQUENCES.insert.plain!) || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.insert, 0) ); } @@ -859,7 +780,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "delete": if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.delete) || + matchesLegacySequence(data, LEGACY_SEQUENCES.delete.plain!) || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.delete, 0) ); } @@ -870,14 +791,14 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "clear": if (modifier === 0) { - return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.clear); + return matchesLegacySequence(data, LEGACY_SEQUENCES.clear.plain!); } return matchesLegacyModifierSequence(data, "clear", modifier); case "home": if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.home) || + matchesLegacySequence(data, LEGACY_SEQUENCES.home.plain!) || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.home, 0) ); } @@ -889,7 +810,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "end": if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.end) || + matchesLegacySequence(data, LEGACY_SEQUENCES.end.plain!) || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.end, 0) ); } @@ -901,7 +822,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "pageup": if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageUp) || + matchesLegacySequence(data, LEGACY_SEQUENCES.pageUp.plain!) || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageUp, 0) ); } @@ -913,7 +834,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { case "pagedown": if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.pageDown) || + matchesLegacySequence(data, LEGACY_SEQUENCES.pageDown.plain!) || matchesKittySequence(data, FUNCTIONAL_CODEPOINTS.pageDown, 0) ); } @@ -928,7 +849,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.up) || + matchesLegacySequence(data, LEGACY_SEQUENCES.up.plain!) || matchesKittySequence(data, ARROW_CODEPOINTS.up, 0) ); } @@ -943,7 +864,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.down) || + matchesLegacySequence(data, LEGACY_SEQUENCES.down.plain!) || matchesKittySequence(data, ARROW_CODEPOINTS.down, 0) ); } @@ -970,7 +891,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.left) || + matchesLegacySequence(data, LEGACY_SEQUENCES.left.plain!) || matchesKittySequence(data, ARROW_CODEPOINTS.left, 0) ); } @@ -997,7 +918,7 @@ export function matchesKey(data: string, keyId: KeyId): boolean { } if (modifier === 0) { return ( - matchesLegacySequence(data, LEGACY_KEY_SEQUENCES.right) || + matchesLegacySequence(data, LEGACY_SEQUENCES.right.plain!) || matchesKittySequence(data, ARROW_CODEPOINTS.right, 0) ); } @@ -1021,8 +942,8 @@ export function matchesKey(data: string, keyId: KeyId): boolean { if (modifier !== 0) { return false; } - const functionKey = key as keyof typeof LEGACY_KEY_SEQUENCES; - return matchesLegacySequence(data, LEGACY_KEY_SEQUENCES[functionKey]); + const functionKey = key as keyof typeof LEGACY_SEQUENCES; + return matchesLegacySequence(data, LEGACY_SEQUENCES[functionKey]!.plain!); } }