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.
This commit is contained in:
frizynn 2026-03-19 16:39:18 -03:00
parent eaf0538150
commit 9938fb3730
2 changed files with 119 additions and 202 deletions

View file

@ -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.
*/

View file

@ -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<string, { plain?: readonly string[]; shift?: readonly string[]; ctrl?: readonly string[] }> = {
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<string, KeyId> = {
"\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<string, KeyId> = (() => {
const map: Record<string, KeyId> = {};
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 ":<eventType>" 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[<codepoint>;<modifier>: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!);
}
}