This repository has no description
0

Configure Feed

Select the types of activity you want to include in your feed.

Add fuzzy filter, light themes, terminal theme, Ctrl+G theme cycling, persist theme

Interactive list:
- Fuzzy matching for session filter (fzf-style, characters in order)
- Name matches scored 10000+ above cwd/command matches
- ▸ selection indicator on the active item
- Ctrl+G cycles through all themes, name shown in footer
- Theme preference saved to ~/.local/state/pty/theme

Themes:
- Light variants of all 5 themes (coolBlueLight, warmAmberLight, etc.)
- New "terminal" theme: null colors, uses terminal's own fg/bg
- Terminal theme is the default — works on any color scheme
- Theme type widened to allow null (renderer already handled it)

Framework fix:
- Text nodes inside panels now preserve the panel's background color
instead of clearing to null. Previously invisible on dark themes
but caused white bleed-through on light terminals.

Nathan Herald (Mar 28, 2026, 11:31 AM +0100) de8b3d50 d5a54122

+417 -77
+1
.gitignore
··· 1 1 node_modules/ 2 2 dist/ 3 3 result 4 + .swp
+1 -1
package.json
··· 1 1 { 2 2 "name": "@myobie/pty", 3 - "version": "0.2.0", 3 + "version": "0.2.1", 4 4 "description": "Persistent terminal sessions with detach/attach, plus a Playwright-style testing library for TUI apps", 5 5 "type": "module", 6 6 "license": "MIT",
+1 -1
src/tui/app.ts
··· 128 128 for (let i = 0; i < label.length; i++) { 129 129 if (col + i >= 0 && col + i < buf.cols) { 130 130 buf.cells[0][col + i] = { 131 - char: label[i], fg: [...theme.bg1], bg: [...theme.fgAc], 131 + char: label[i], fg: theme.bg1 ? [...theme.bg1] : null, bg: theme.fgAc ? [...theme.fgAc] : null, 132 132 bold: false, dim: false, italic: false, underline: false, 133 133 }; 134 134 }
+25 -21
src/tui/builders.ts
··· 27 27 * This makes CLI tools (ls, git, etc.) inside embedded PTYs use colors that 28 28 * are coherent with the surrounding UI. 29 29 */ 30 + function hexOrDefault(c: [number, number, number] | null, fallback: string): string { 31 + return c ? rgbToHex(c) : fallback; 32 + } 33 + 30 34 export function themeToXterm(theme: Theme): Record<string, string> { 31 35 return { 32 - foreground: rgbToHex(theme.fg1), 33 - background: rgbToHex(theme.bg1), 34 - cursor: rgbToHex(theme.fgAc), 35 - cursorAccent: rgbToHex(theme.bg1), 36 - selection: rgbToHex(theme.bgHi), 36 + foreground: hexOrDefault(theme.fg1, "#d2dae8"), 37 + background: hexOrDefault(theme.bg1, "#0f111a"), 38 + cursor: hexOrDefault(theme.fgAc, "#64a0ff"), 39 + cursorAccent: hexOrDefault(theme.bg1, "#0f111a"), 40 + selection: hexOrDefault(theme.bgHi, "#1e2841"), 37 41 // Standard 8 colors 38 - black: rgbToHex(theme.bg1), 39 - red: rgbToHex(theme.err), 40 - green: rgbToHex(theme.ok), 41 - yellow: rgbToHex(theme.warn), 42 - blue: rgbToHex(theme.info), 43 - magenta: rgbToHex(theme.fgAc), 44 - cyan: rgbToHex(theme.fg2), 45 - white: rgbToHex(theme.fg1), 42 + black: hexOrDefault(theme.bg1, "#0f111a"), 43 + red: hexOrDefault(theme.err, "#f05050"), 44 + green: hexOrDefault(theme.ok, "#50c878"), 45 + yellow: hexOrDefault(theme.warn, "#f0b432"), 46 + blue: hexOrDefault(theme.info, "#50aaf0"), 47 + magenta: hexOrDefault(theme.fgAc, "#64a0ff"), 48 + cyan: hexOrDefault(theme.fg2, "#8c9bb9"), 49 + white: hexOrDefault(theme.fg1, "#d2dae8"), 46 50 // Bright variants 47 - brightBlack: rgbToHex(theme.fgMu), 48 - brightRed: rgbToHex(brighten(theme.err)), 49 - brightGreen: rgbToHex(brighten(theme.ok)), 50 - brightYellow: rgbToHex(brighten(theme.warn)), 51 - brightBlue: rgbToHex(brighten(theme.info)), 52 - brightMagenta: rgbToHex(brighten(theme.fgAc)), 53 - brightCyan: rgbToHex(brighten(theme.fg2)), 54 - brightWhite: rgbToHex(brighten(theme.fg1)), 51 + brightBlack: hexOrDefault(theme.fgMu, "#465069"), 52 + brightRed: hexOrDefault(theme.err ? brighten(theme.err) : null, "#ff6666"), 53 + brightGreen: hexOrDefault(theme.ok ? brighten(theme.ok) : null, "#78e0a0"), 54 + brightYellow: hexOrDefault(theme.warn ? brighten(theme.warn) : null, "#ffcc5a"), 55 + brightBlue: hexOrDefault(theme.info ? brighten(theme.info) : null, "#78c8ff"), 56 + brightMagenta: hexOrDefault(theme.fgAc ? brighten(theme.fgAc) : null, "#8cc0ff"), 57 + brightCyan: hexOrDefault(theme.fg2 ? brighten(theme.fg2) : null, "#b4c3e1"), 58 + brightWhite: hexOrDefault(theme.fg1 ? brighten(theme.fg1) : null, "#ffffff"), 55 59 }; 56 60 } 57 61
+60 -18
src/tui/colors.ts
··· 268 268 } 269 269 270 270 // --- Theme --- 271 + type ThemeColor = [number, number, number] | null; 272 + 271 273 export interface Theme { 272 - bg1: [number, number, number]; 273 - bg2: [number, number, number]; 274 - bgHi: [number, number, number]; 275 - bgAc: [number, number, number]; 276 - fg1: [number, number, number]; 277 - fg2: [number, number, number]; 278 - fgAc: [number, number, number]; 279 - fgMu: [number, number, number]; 280 - ok: [number, number, number]; 281 - warn: [number, number, number]; 282 - err: [number, number, number]; 283 - info: [number, number, number]; 284 - border: [number, number, number]; 274 + bg1: ThemeColor; 275 + bg2: ThemeColor; 276 + bgHi: ThemeColor; 277 + bgAc: ThemeColor; 278 + fg1: ThemeColor; 279 + fg2: ThemeColor; 280 + fgAc: ThemeColor; 281 + fgMu: ThemeColor; 282 + ok: ThemeColor; 283 + warn: ThemeColor; 284 + err: ThemeColor; 285 + info: ThemeColor; 286 + border: ThemeColor; 285 287 } 286 288 287 289 export const themes: Record<string, Theme> = { ··· 315 317 ok: [80, 230, 120], warn: [230, 200, 80], err: [230, 90, 80], info: [80, 190, 220], 316 318 border: [45, 65, 48], 317 319 }, 320 + // --- Light variants --- 321 + coolBlueLight: { 322 + bg1: [240, 244, 250], bg2: [230, 236, 245], bgHi: [210, 220, 238], bgAc: [70, 120, 200], 323 + fg1: [30, 35, 50], fg2: [80, 90, 115], fgAc: [40, 100, 220], fgMu: [140, 150, 175], 324 + ok: [30, 140, 70], warn: [180, 120, 0], err: [200, 40, 40], info: [30, 120, 200], 325 + border: [180, 190, 210], 326 + }, 327 + warmAmberLight: { 328 + bg1: [252, 245, 235], bg2: [242, 232, 218], bgHi: [230, 215, 195], bgAc: [180, 130, 50], 329 + fg1: [50, 40, 30], fg2: [100, 80, 55], fgAc: [190, 120, 10], fgMu: [150, 135, 110], 330 + ok: [40, 150, 40], warn: [200, 140, 0], err: [190, 50, 30], info: [50, 130, 180], 331 + border: [200, 185, 160], 332 + }, 333 + monoLight: { 334 + bg1: [245, 245, 245], bg2: [235, 235, 235], bgHi: [215, 215, 215], bgAc: [180, 180, 180], 335 + fg1: [30, 30, 30], fg2: [80, 80, 80], fgAc: [0, 0, 0], fgMu: [150, 150, 150], 336 + ok: [40, 140, 40], warn: [170, 140, 20], err: [180, 50, 50], info: [40, 120, 180], 337 + border: [190, 190, 190], 338 + }, 339 + draculaLight: { 340 + bg1: [248, 248, 242], bg2: [238, 236, 230], bgHi: [220, 218, 210], bgAc: [150, 140, 200], 341 + fg1: [40, 42, 54], fg2: [100, 70, 180], fgAc: [50, 160, 180], fgMu: [140, 150, 170], 342 + ok: [30, 170, 70], warn: [180, 170, 40], err: [210, 50, 50], info: [50, 160, 180], 343 + border: [200, 198, 190], 344 + }, 345 + forestLight: { 346 + bg1: [242, 250, 244], bg2: [230, 242, 232], bgHi: [210, 230, 215], bgAc: [70, 140, 85], 347 + fg1: [25, 45, 30], fg2: [60, 100, 70], fgAc: [40, 160, 70], fgMu: [130, 155, 135], 348 + ok: [30, 160, 60], warn: [170, 140, 20], err: [190, 50, 40], info: [40, 130, 170], 349 + border: [175, 200, 180], 350 + }, 351 + terminal: { 352 + bg1: null, bg2: null, bgHi: null, bgAc: null, 353 + fg1: null, fg2: null, fgAc: null, fgMu: null, 354 + ok: null, warn: null, err: null, info: null, 355 + border: null, 356 + }, 318 357 }; 319 358 320 359 // Quick access to theme color codes 360 + function fgOr(c: ThemeColor): string { return c ? fg(c[0], c[1], c[2]) : ""; } 361 + function bgOr(c: ThemeColor): string { return c ? bg(c[0], c[1], c[2]) : ""; } 362 + 321 363 export function c(theme: Theme) { 322 364 return { 323 - bg1: bg(...theme.bg1), bg2: bg(...theme.bg2), bgHi: bg(...theme.bgHi), bgAc: bg(...theme.bgAc), 324 - fg1: fg(...theme.fg1), fg2: fg(...theme.fg2), fgAc: fg(...theme.fgAc), fgMu: fg(...theme.fgMu), 325 - ok: fg(...theme.ok), warn: fg(...theme.warn), err: fg(...theme.err), info: fg(...theme.info), 326 - border: fg(...theme.border), 327 - bgOk: bg(...theme.ok), bgWarn: bg(...theme.warn), bgErr: bg(...theme.err), bgInfo: bg(...theme.info), 365 + bg1: bgOr(theme.bg1), bg2: bgOr(theme.bg2), bgHi: bgOr(theme.bgHi), bgAc: bgOr(theme.bgAc), 366 + fg1: fgOr(theme.fg1), fg2: fgOr(theme.fg2), fgAc: fgOr(theme.fgAc), fgMu: fgOr(theme.fgMu), 367 + ok: fgOr(theme.ok), warn: fgOr(theme.warn), err: fgOr(theme.err), info: fgOr(theme.info), 368 + border: fgOr(theme.border), 369 + bgOk: bgOr(theme.ok), bgWarn: bgOr(theme.warn), bgErr: bgOr(theme.err), bgInfo: bgOr(theme.info), 328 370 }; 329 371 } 330 372
+135
src/tui/fuzzy.ts
··· 1 + // Fuzzy matching — fzf-style character-by-character matching with scoring 2 + 3 + export interface FuzzyResult { 4 + match: boolean; 5 + score: number; 6 + } 7 + 8 + /** 9 + * Fuzzy match a query against a target string. 10 + * Characters in the query must appear in the target in order, but not adjacently. 11 + * Returns whether it matched and a score (higher = better match). 12 + * 13 + * Scoring: 14 + * - Consecutive character matches get a bonus 15 + * - Matches at word boundaries (after -, _, /, space, or start of string) get a bonus 16 + * - Shorter targets score higher for the same query 17 + * - Prefix matches score higher than mid-string matches 18 + */ 19 + export function fuzzyMatch(query: string, target: string): FuzzyResult { 20 + if (query.length === 0) return { match: true, score: 1 }; 21 + 22 + const q = query.toLowerCase(); 23 + const t = target.toLowerCase(); 24 + 25 + if (q.length > t.length) return { match: false, score: 0 }; 26 + 27 + // Check if it matches at all 28 + let qi = 0; 29 + for (let ti = 0; ti < t.length && qi < q.length; ti++) { 30 + if (t[ti] === q[qi]) qi++; 31 + } 32 + if (qi < q.length) return { match: false, score: 0 }; 33 + 34 + // It matches — now find the best scoring alignment 35 + // Use a greedy approach: try to match at word boundaries first, 36 + // then fall back to earliest match 37 + const matchPositions = findBestMatch(q, t); 38 + 39 + let score = 0; 40 + 41 + // Consecutive bonus: reward adjacent matches 42 + let consecutive = 0; 43 + for (let i = 0; i < matchPositions.length; i++) { 44 + if (i > 0 && matchPositions[i] === matchPositions[i - 1] + 1) { 45 + consecutive++; 46 + score += consecutive * 2; // escalating bonus for longer runs 47 + } else { 48 + consecutive = 0; 49 + } 50 + } 51 + 52 + // Word boundary bonus 53 + for (const pos of matchPositions) { 54 + if (pos === 0 || isBoundary(t, pos)) { 55 + score += 3; 56 + } 57 + } 58 + 59 + // Prefix bonus: first match at position 0 60 + if (matchPositions[0] === 0) { 61 + score += 5; 62 + } 63 + 64 + // Length penalty: prefer shorter targets 65 + score += Math.max(0, 10 - (t.length - q.length)); 66 + 67 + return { match: true, score }; 68 + } 69 + 70 + function isBoundary(str: string, pos: number): boolean { 71 + if (pos === 0) return true; 72 + const prev = str[pos - 1]; 73 + return prev === "-" || prev === "_" || prev === "/" || prev === " " || prev === "."; 74 + } 75 + 76 + /** 77 + * Find the best match positions — prefer word boundaries and consecutive runs. 78 + */ 79 + function findBestMatch(query: string, target: string): number[] { 80 + // First try: match at word boundaries where possible 81 + const boundaryMatch = matchPreferBoundaries(query, target); 82 + if (boundaryMatch) return boundaryMatch; 83 + 84 + // Fallback: greedy left-to-right 85 + const positions: number[] = []; 86 + let qi = 0; 87 + for (let ti = 0; ti < target.length && qi < query.length; ti++) { 88 + if (target[ti] === query[qi]) { 89 + positions.push(ti); 90 + qi++; 91 + } 92 + } 93 + return positions; 94 + } 95 + 96 + function matchPreferBoundaries(query: string, target: string): number[] | null { 97 + const positions: number[] = []; 98 + let qi = 0; 99 + let ti = 0; 100 + 101 + while (qi < query.length && ti < target.length) { 102 + // Look ahead for a boundary match 103 + let foundBoundary = false; 104 + for (let ahead = ti; ahead < target.length; ahead++) { 105 + if (target[ahead] === query[qi] && isBoundary(target, ahead)) { 106 + // Check that remaining query can still match remaining target 107 + if (canMatch(query, qi + 1, target, ahead + 1)) { 108 + positions.push(ahead); 109 + qi++; 110 + ti = ahead + 1; 111 + foundBoundary = true; 112 + break; 113 + } 114 + } 115 + } 116 + if (!foundBoundary) { 117 + // Take the next available match 118 + while (ti < target.length && target[ti] !== query[qi]) ti++; 119 + if (ti >= target.length) return null; 120 + positions.push(ti); 121 + qi++; 122 + ti++; 123 + } 124 + } 125 + 126 + return qi === query.length ? positions : null; 127 + } 128 + 129 + function canMatch(query: string, qi: number, target: string, ti: number): boolean { 130 + while (qi < query.length && ti < target.length) { 131 + if (target[ti] === query[qi]) qi++; 132 + ti++; 133 + } 134 + return qi >= query.length; 135 + }
+60 -21
src/tui/interactive.ts
··· 1 1 // Interactive session list — built with the declarative TUI framework + app() 2 + import * as fs from "node:fs"; 2 3 import * as path from "node:path"; 3 4 import { attach } from "../client.ts"; 4 5 import { 5 6 listSessions, validateName, acquireLock, releaseLock, 6 - cleanupAll, getSession, type SessionInfo, 7 + cleanupAll, getSession, getSessionDir, type SessionInfo, 7 8 } from "../sessions.ts"; 8 9 import { spawnDaemon, resolveCommand } from "../spawn.ts"; 9 10 import { 10 11 app, screen, signal, computed, batch, 11 12 text, row, spacer, panel, selectable, footer, canvas, 12 - updateScrollRegion, type KeyEvent, type ScreenContext, type UINode, 13 + updateScrollRegion, themes, 14 + type KeyEvent, type ScreenContext, type UINode, 13 15 } from "./index.ts"; 14 16 // Reuse utility functions from the existing screen modules 15 17 import { sortSessions, shortPath, timeAgo } from "./screen-list.ts"; 16 18 import { dedupName, listDirs } from "./screen-create.ts"; 19 + import { fuzzyMatch } from "./fuzzy.ts"; 17 20 18 21 /** Generate a session name from dir and command. */ 19 22 function autoName(dir: string, cmd: string, cmdArgs: string[]): string { ··· 40 43 const selectedIndex = signal(0); 41 44 const currentScreen = signal<"list" | "create">("list"); 42 45 46 + // Theme — persisted to ~/.local/state/pty/theme 47 + const themeNames = Object.keys(themes); 48 + const terminalIdx = themeNames.indexOf("terminal"); 49 + 50 + function loadSavedThemeIndex(): number { 51 + try { 52 + const name = fs.readFileSync(path.join(getSessionDir(), "theme"), "utf-8").trim(); 53 + const idx = themeNames.indexOf(name); 54 + if (idx >= 0) return idx; 55 + } catch {} 56 + return terminalIdx >= 0 ? terminalIdx : 0; 57 + } 58 + 59 + function saveTheme(name: string): void { 60 + try { 61 + fs.writeFileSync(path.join(getSessionDir(), "theme"), name + "\n"); 62 + } catch {} 63 + } 64 + 65 + const themeIndex = signal(loadSavedThemeIndex()); 66 + function cycleTheme(): void { 67 + const next = (themeIndex.peek() + 1) % themeNames.length; 68 + themeIndex.set(next); 69 + saveTheme(themeNames[next]); 70 + } 71 + function currentTheme() { 72 + return themes[themeNames[themeIndex.get()]]; 73 + } 74 + 43 75 // Create wizard state 44 76 const createStep = signal<"dir-initial" | "dir-browse" | "name-command">("dir-initial"); 45 77 const cwdPath = signal(process.cwd()); ··· 64 96 const sortedSessions = computed<SessionInfo[]>(() => sortSessions(sessions.get())); 65 97 66 98 const filteredItems = computed<ListItem[]>(() => { 67 - const filter = filterText.get().toLowerCase(); 68 - const matches: { item: ListItem; rank: number }[] = []; 99 + const filter = filterText.get(); 100 + const matches: { item: ListItem; score: number }[] = []; 69 101 for (const s of sortedSessions.get()) { 70 102 if (!filter) { 71 - matches.push({ item: { type: "session", session: s }, rank: 0 }); 103 + matches.push({ item: { type: "session", session: s }, score: 0 }); 72 104 continue; 73 105 } 74 106 const cmd = s.metadata 75 107 ? [s.metadata.displayCommand, ...s.metadata.args].join(" ") 76 108 : ""; 77 109 const cwd = s.metadata?.cwd ?? ""; 78 - const nameMatch = s.name.toLowerCase().includes(filter); 79 - const cwdMatch = cwd.toLowerCase().includes(filter); 80 - const cmdMatch = cmd.toLowerCase().includes(filter); 81 - if (!nameMatch && !cwdMatch && !cmdMatch) continue; 82 - // Name matches rank highest (0), then cwd (1), then command (2) 83 - const rank = nameMatch ? 0 : cwdMatch ? 1 : 2; 84 - matches.push({ item: { type: "session", session: s }, rank }); 110 + // Fuzzy match against name (weighted highest), cwd, and command 111 + const nameResult = fuzzyMatch(filter, s.name); 112 + const cwdResult = fuzzyMatch(filter, cwd); 113 + const cmdResult = fuzzyMatch(filter, cmd); 114 + if (!nameResult.match && !cwdResult.match && !cmdResult.match) continue; 115 + // Name matches get a large bonus so they always rank above cwd/cmd matches 116 + const score = Math.max( 117 + nameResult.match ? nameResult.score + 10000 : 0, 118 + cwdResult.match ? cwdResult.score : 0, 119 + cmdResult.match ? cmdResult.score : 0, 120 + ); 121 + matches.push({ item: { type: "session", session: s }, score }); 85 122 } 86 - // Stable sort: within same rank, preserve the existing order (running first, alpha) 87 - matches.sort((a, b) => a.rank - b.rank); 123 + // Higher score = better match = first in list 124 + matches.sort((a, b) => b.score - a.score); 88 125 const items = matches.map(m => m.item); 89 126 items.push({ type: "create" }); 90 127 return items; ··· 95 132 // ============================================================ 96 133 97 134 function renderListItem(item: ListItem, _index: number, selected: boolean): UINode[] { 135 + const sel = selected ? "\u25b8 " : " "; 98 136 if (item.type === "create") { 99 - const label = selected ? " + Create new session..." : " + Create new session..."; 100 - return [text(label, selected ? "accent" : "muted", { bold: selected, truncate: true })]; 137 + return [text(sel + "+ Create new session...", selected ? "accent" : "muted", { bold: selected, truncate: true })]; 101 138 } 102 139 const s = item.session!; 103 140 const icon = s.status === "running" ? "\u25cf" : "\u25cb"; 104 - const iconColor: "ok" | "error" = s.status === "running" ? "ok" : "error"; 105 141 const cmd = s.metadata 106 142 ? [s.metadata.displayCommand, ...s.metadata.args].join(" ") 107 143 : ""; ··· 109 145 ? (s.metadata?.cwd ? shortPath(s.metadata.cwd) : "") 110 146 : (s.metadata?.exitedAt ? `(exited ${timeAgo(new Date(s.metadata.exitedAt))})` : ""); 111 147 112 - // Single text node per row — keeps column layout simple and avoids 113 - // the framework's flex distribution splitting the line oddly. 114 - const line = ` ${icon} ${s.name} ${pathStr} ${cmd}`; 148 + const line = `${sel}${icon} ${s.name} ${pathStr} ${cmd}`; 115 149 return [text(line, selected ? "accent" : "primary", { bold: selected, truncate: true })]; 116 150 } 117 151 ··· 138 172 text("", "muted"), // blank line 139 173 selectable(region, items, renderListItem), 140 174 ]), 141 - footer("\u2191\u2193 select \u23ce attach q quit"), 175 + footer(`\u2191\u2193 select \u23ce attach ctrl+g theme (${themeNames[themeIndex.get()]}) q quit`), 142 176 ]; 143 177 }, 144 178 ··· 568 602 569 603 myApp = app({ 570 604 screen: () => currentScreen.get() === "list" ? listScreen : createScreen, 605 + theme: () => currentTheme(), 606 + onKey: (key) => { 607 + if (key.name === "g" && key.ctrl) { cycleTheme(); return true; } 608 + return false; 609 + }, 571 610 }); 572 611 573 612 myApp.start();
+6 -6
src/tui/renderer.ts
··· 268 268 } 269 269 270 270 function renderSeparator(rect: Rect, theme: Theme, boxStyle: BoxStyle): string { 271 - return fg(theme.border[0], theme.border[1], theme.border[2]) 271 + return (theme.border ? fg(theme.border[0], theme.border[1], theme.border[2]) : "") 272 272 + hSep(rect.y + 1, rect.x + 1, rect.width, boxStyle) 273 273 + RESET; 274 274 } ··· 318 318 out += bgColor(theme.bg2) + fillRect(rect.y + 1, rect.x + 1, rect.width, rect.height); 319 319 320 320 // Draw border 321 - out += fg(theme.border[0], theme.border[1], theme.border[2]) 321 + out += (theme.border ? fg(theme.border[0], theme.border[1], theme.border[2]) : "") 322 322 + drawBox(rect.y + 1, rect.x + 1, rect.width, rect.height, { style }); 323 323 324 324 // Title 325 325 if (node.title) { 326 326 out += writeAt(rect.y + 1, rect.x + 3, 327 327 " " + fgColor(theme.fgAc) + BOLD + node.title + RESET 328 - + bgColor(theme.bg2) + fg(theme.border[0], theme.border[1], theme.border[2]) + " "); 328 + + bgColor(theme.bg2) + (theme.border ? fg(theme.border[0], theme.border[1], theme.border[2]) : "") + " "); 329 329 } 330 330 331 331 // Render children ··· 394 394 out += bgColor(theme.bg2) + fillRect(rect.y + 1, rect.x + 1, rect.width, rect.height); 395 395 396 396 // Border 397 - out += fg(theme.border[0], theme.border[1], theme.border[2]) 397 + out += (theme.border ? fg(theme.border[0], theme.border[1], theme.border[2]) : "") 398 398 + drawBox(rect.y + 1, rect.x + 1, rect.width, rect.height, { style }); 399 399 400 400 if (node.active) { ··· 533 533 if (cell.underline) out += "\x1b[4m"; 534 534 const fgC = cell.fg ?? theme.fg1; 535 535 const bgC = cell.bg ?? theme.bg1; 536 - out += fg(fgC[0], fgC[1], fgC[2]); 537 - out += bg(bgC[0], bgC[1], bgC[2]); 536 + if (fgC) out += fg(fgC[0], fgC[1], fgC[2]); 537 + if (bgC) out += bg(bgC[0], bgC[1], bgC[2]); 538 538 out += cell.char; 539 539 out += RESET; 540 540 }
+11 -8
src/tui/screen.ts
··· 297 297 if (row < 0 || row >= buf.rows) return; 298 298 if (c >= buf.cols) return; 299 299 if (c >= 0) { 300 - buf.cells[row][c] = makeCell(ch, fgc, bgc, bold, dim, italic); 300 + // Preserve existing bg when bgc is null (e.g. text inside a panel) 301 + const effectiveBg = bgc ?? buf.cells[row][c]?.bg ?? null; 302 + buf.cells[row][c] = makeCell(ch, fgc, effectiveBg, bold, dim, italic); 301 303 if (cw === 2 && c + 1 < buf.cols) { 302 - buf.cells[row][c + 1] = makeCell("", fgc, bgc, bold, dim, italic); 304 + buf.cells[row][c + 1] = makeCell("", fgc, effectiveBg, bold, dim, italic); 303 305 } 304 306 } 305 307 c += cw; ··· 345 347 } 346 348 347 349 if (c >= 0) { 348 - buf.cells[row][c] = makeCell(ch, fg, null, bold, dim, italic); 350 + const effectiveBg = buf.cells[row][c]?.bg ?? null; 351 + buf.cells[row][c] = makeCell(ch, fg, effectiveBg, bold, dim, italic); 349 352 if (cw === 2 && c + 1 < buf.cols) { 350 - buf.cells[row][c + 1] = makeCell("", fg, null, bold, dim, italic); 353 + buf.cells[row][c + 1] = makeCell("", fg, effectiveBg, bold, dim, italic); 351 354 } 352 355 } 353 356 c += cw; ··· 383 386 width: number, 384 387 height: number, 385 388 style: BoxStyle, 386 - fgc: [number, number, number], 389 + fgc: [number, number, number] | null, 387 390 bgc: [number, number, number] | null, 388 391 ): void { 389 392 const b = boxChars(style); ··· 419 422 col: number, 420 423 width: number, 421 424 style: BoxStyle, 422 - fgc: [number, number, number], 425 + fgc: [number, number, number] | null, 423 426 bgc: [number, number, number] | null, 424 427 ): void { 425 428 if (row < 0 || row >= buf.rows) return; ··· 669 672 const cell = ptyRow[c]; 670 673 buf.cells[absY][absX] = makeCell( 671 674 cell.char || " ", 672 - cell.fg ?? [...theme.fg1], 673 - cell.bg ?? [...theme.bg1], 675 + cell.fg ?? (theme.fg1 ? [...theme.fg1] : null), 676 + cell.bg ?? (theme.bg1 ? [...theme.bg1] : null), 674 677 cell.bold, cell.dim, cell.italic, 675 678 ); 676 679 }
+117 -1
tests/tui-framework.test.ts
··· 270 270 const s = screen({ id: "test", render: () => [text("x")] }); 271 271 const ctx = { rows: 24, cols: 80, theme, boxStyle: "rounded" as const } as any; 272 272 const buf = s.renderToBuffer(ctx); 273 - expect(buf.cells[20][60].bg).toEqual([...theme.bg1]); 273 + expect(buf.cells[20][60].bg).toEqual(theme.bg1 ? [...theme.bg1] : null); 274 + }); 275 + 276 + it("text inside a panel preserves the panel background color", () => { 277 + const s = screen({ 278 + id: "panel-bg-test", 279 + render() { 280 + return [panel("Test", [text("hello", "primary")])]; 281 + }, 282 + }); 283 + const ctx = { rows: 10, cols: 40, theme, boxStyle: "rounded" as const } as any; 284 + const buf = s.renderToBuffer(ctx); 285 + // Find the row with "hello" text 286 + let helloRow = -1; 287 + for (let r = 0; r < buf.rows; r++) { 288 + const rowText = buf.cells[r].map(c => c.char).join(""); 289 + if (rowText.includes("hello")) { helloRow = r; break; } 290 + } 291 + expect(helloRow).toBeGreaterThan(0); 292 + // The cell with "h" should have the panel's bg2 background, not null 293 + const hCol = buf.cells[helloRow].findIndex(c => c.char === "h"); 294 + expect(hCol).toBeGreaterThan(0); 295 + expect(buf.cells[helloRow][hCol].bg).toEqual(theme.bg2 ? [...theme.bg2] : null); 274 296 }); 275 297 }); 276 298 ··· 595 617 expect(typeof a.start).toBe("function"); 596 618 }); 597 619 }); 620 + 621 + // ── Fuzzy match ── 622 + describe("fuzzyMatch", () => { 623 + const { fuzzyMatch } = require("../src/tui/fuzzy.ts"); 624 + 625 + // Basic matching 626 + it("empty query matches everything", () => { 627 + expect(fuzzyMatch("", "anything").match).toBe(true); 628 + }); 629 + 630 + it("exact match", () => { 631 + expect(fuzzyMatch("node", "node").match).toBe(true); 632 + }); 633 + 634 + it("substring match", () => { 635 + expect(fuzzyMatch("server", "node-server").match).toBe(true); 636 + }); 637 + 638 + it("fuzzy match — characters in order but not adjacent", () => { 639 + expect(fuzzyMatch("nsr", "node-server").match).toBe(true); 640 + }); 641 + 642 + it("fuzzy match — skipping characters", () => { 643 + expect(fuzzyMatch("ns", "node-server").match).toBe(true); 644 + }); 645 + 646 + it("no match when characters are out of order", () => { 647 + expect(fuzzyMatch("sn", "node-server").match).toBe(false); 648 + }); 649 + 650 + it("no match when query has characters not in target", () => { 651 + expect(fuzzyMatch("xyz", "node-server").match).toBe(false); 652 + }); 653 + 654 + it("case insensitive", () => { 655 + expect(fuzzyMatch("NODE", "node-server").match).toBe(true); 656 + expect(fuzzyMatch("node", "Node-Server").match).toBe(true); 657 + }); 658 + 659 + it("query longer than target never matches", () => { 660 + expect(fuzzyMatch("longquery", "short").match).toBe(false); 661 + }); 662 + 663 + // Scoring — better matches should score higher 664 + it("exact match scores higher than fuzzy match", () => { 665 + const exact = fuzzyMatch("node", "node"); 666 + const fuzzy = fuzzyMatch("node", "n-o-d-e"); 667 + expect(exact.score).toBeGreaterThan(fuzzy.score); 668 + }); 669 + 670 + it("prefix match scores higher than middle match", () => { 671 + const prefix = fuzzyMatch("node", "node-server"); 672 + const middle = fuzzyMatch("node", "my-node-server"); 673 + expect(prefix.score).toBeGreaterThan(middle.score); 674 + }); 675 + 676 + it("consecutive match scores higher than scattered match", () => { 677 + const consecutive = fuzzyMatch("serve", "server"); 678 + const scattered = fuzzyMatch("serve", "s_e_r_v_e"); 679 + expect(consecutive.score).toBeGreaterThan(scattered.score); 680 + }); 681 + 682 + it("word boundary match scores higher than mid-word match", () => { 683 + const boundary = fuzzyMatch("server", "node-server"); 684 + const midword = fuzzyMatch("server", "nodeserver"); 685 + expect(boundary.score).toBeGreaterThanOrEqual(midword.score); 686 + }); 687 + 688 + it("shorter target scores higher for same query", () => { 689 + const short = fuzzyMatch("node", "node"); 690 + const long = fuzzyMatch("node", "node-server-application"); 691 + expect(short.score).toBeGreaterThan(long.score); 692 + }); 693 + 694 + // Edge cases 695 + it("single character query", () => { 696 + expect(fuzzyMatch("n", "node").match).toBe(true); 697 + expect(fuzzyMatch("z", "node").match).toBe(false); 698 + }); 699 + 700 + it("single character target", () => { 701 + expect(fuzzyMatch("n", "n").match).toBe(true); 702 + expect(fuzzyMatch("no", "n").match).toBe(false); 703 + }); 704 + 705 + it("special characters in query", () => { 706 + expect(fuzzyMatch(".", "file.ts").match).toBe(true); 707 + expect(fuzzyMatch("-", "node-server").match).toBe(true); 708 + }); 709 + 710 + it("spaces in query match naturally", () => { 711 + expect(fuzzyMatch("no se", "node server").match).toBe(true); 712 + }); 713 + });