getting terminal colors working

This commit is contained in:
2026-02-05 13:46:47 -05:00
parent 9fa52d71ca
commit e239b33042
45 changed files with 1718 additions and 2055 deletions

221
src/utils/clipboard.ts Normal file
View File

@@ -0,0 +1,221 @@
import { $ } from "bun"
import { platform, release } from "os"
import { tmpdir } from "os"
import path from "path"
/**
* Writes text to clipboard via OSC 52 escape sequence.
* This allows clipboard operations to work over SSH by having
* the terminal emulator handle the clipboard locally.
*/
function writeOsc52(text: string): void {
if (!process.stdout.isTTY) return
const base64 = Buffer.from(text).toString("base64")
const osc52 = `\x1b]52;c;${base64}\x07`
const passthrough = process.env["TMUX"] || process.env["STY"]
const sequence = passthrough ? `\x1bPtmux;\x1b${osc52}\x1b\\` : osc52
process.stdout.write(sequence)
}
/**
* Lazy initialization for clipboard copy method.
* Detects the best clipboard method for the current platform.
*/
function createLazy<T>(factory: () => T): () => T {
let value: T | undefined
return () => {
if (value === undefined) {
value = factory()
}
return value
}
}
export namespace Clipboard {
export interface Content {
data: string
mime: string
}
/**
* Read content from the clipboard.
* Supports text and image (PNG) content on macOS, Windows, and Linux.
*/
export async function read(): Promise<Content | undefined> {
const os = platform()
// macOS: Try to read PNG image first
if (os === "darwin") {
const tmpfile = path.join(tmpdir(), "podtui-clipboard.png")
try {
await $`osascript -e 'set imageData to the clipboard as "PNGf"' -e 'set fileRef to open for access POSIX file "${tmpfile}" with write permission' -e 'set eof fileRef to 0' -e 'write imageData to fileRef' -e 'close access fileRef'`
.nothrow()
.quiet()
const file = Bun.file(tmpfile)
const buffer = await file.arrayBuffer()
if (buffer.byteLength > 0) {
return { data: Buffer.from(buffer).toString("base64"), mime: "image/png" }
}
} catch {
// Ignore errors, fall through to text
} finally {
await $`rm -f "${tmpfile}"`.nothrow().quiet()
}
}
// Windows/WSL: Try to read PNG image
if (os === "win32" || release().includes("WSL")) {
const script =
"Add-Type -AssemblyName System.Windows.Forms; $img = [System.Windows.Forms.Clipboard]::GetImage(); if ($img) { $ms = New-Object System.IO.MemoryStream; $img.Save($ms, [System.Drawing.Imaging.ImageFormat]::Png); [System.Convert]::ToBase64String($ms.ToArray()) }"
const base64 = await $`powershell.exe -NonInteractive -NoProfile -command "${script}"`.nothrow().text()
if (base64) {
const imageBuffer = Buffer.from(base64.trim(), "base64")
if (imageBuffer.length > 0) {
return { data: imageBuffer.toString("base64"), mime: "image/png" }
}
}
}
// Linux: Try Wayland or X11
if (os === "linux") {
// Try Wayland first
const wayland = await $`wl-paste -t image/png`.nothrow().arrayBuffer()
if (wayland && wayland.byteLength > 0) {
return { data: Buffer.from(wayland).toString("base64"), mime: "image/png" }
}
// Try X11
const x11 = await $`xclip -selection clipboard -t image/png -o`.nothrow().arrayBuffer()
if (x11 && x11.byteLength > 0) {
return { data: Buffer.from(x11).toString("base64"), mime: "image/png" }
}
}
// Fall back to reading text
try {
const text = await readText()
if (text) {
return { data: text, mime: "text/plain" }
}
} catch {
// Ignore errors
}
return undefined
}
/**
* Read text from the clipboard.
*/
export async function readText(): Promise<string | undefined> {
const os = platform()
if (os === "darwin") {
const result = await $`pbpaste`.nothrow().text()
return result || undefined
}
if (os === "linux") {
// Try Wayland first
if (process.env["WAYLAND_DISPLAY"]) {
const result = await $`wl-paste`.nothrow().text()
if (result) return result
}
// Try X11
const result = await $`xclip -selection clipboard -o`.nothrow().text()
return result || undefined
}
if (os === "win32" || release().includes("WSL")) {
const result = await $`powershell.exe -NonInteractive -NoProfile -command "Get-Clipboard"`.nothrow().text()
return result?.trim() || undefined
}
return undefined
}
const getCopyMethod = createLazy(() => {
const os = platform()
if (os === "darwin" && Bun.which("osascript")) {
return async (text: string) => {
const escaped = text.replace(/\\/g, "\\\\").replace(/"/g, '\\"')
await $`osascript -e 'set the clipboard to "${escaped}"'`.nothrow().quiet()
}
}
if (os === "linux") {
if (process.env["WAYLAND_DISPLAY"] && Bun.which("wl-copy")) {
return async (text: string) => {
const proc = Bun.spawn(["wl-copy"], { stdin: "pipe", stdout: "ignore", stderr: "ignore" })
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (Bun.which("xclip")) {
return async (text: string) => {
const proc = Bun.spawn(["xclip", "-selection", "clipboard"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
if (Bun.which("xsel")) {
return async (text: string) => {
const proc = Bun.spawn(["xsel", "--clipboard", "--input"], {
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
})
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
}
if (os === "win32") {
return async (text: string) => {
// Pipe via stdin to avoid PowerShell string interpolation ($env:FOO, $(), etc.)
const proc = Bun.spawn(
[
"powershell.exe",
"-NonInteractive",
"-NoProfile",
"-Command",
"[Console]::InputEncoding = [System.Text.Encoding]::UTF8; Set-Clipboard -Value ([Console]::In.ReadToEnd())",
],
{
stdin: "pipe",
stdout: "ignore",
stderr: "ignore",
},
)
proc.stdin.write(text)
proc.stdin.end()
await proc.exited.catch(() => {})
}
}
// Fallback: No native clipboard support
return async (_text: string) => {
console.warn("No clipboard support available on this platform")
}
})
/**
* Copy text to the clipboard.
* Uses OSC 52 for SSH/tmux support and native clipboard for local.
*/
export async function copy(text: string): Promise<void> {
// Always try OSC 52 first for SSH/tmux support
writeOsc52(text)
// Then use native clipboard
await getCopyMethod()(text)
}
}

View File

@@ -4,6 +4,9 @@ import type { ThemeJson } from "../types/theme-schema"
import { THEME_JSON } from "../constants/themes"
import { validateTheme } from "./theme-loader"
// Files to exclude from theme loading (not actual themes)
const EXCLUDED_FILES = new Set(["schema", "schema.json"])
export async function getCustomThemes() {
const home = process.env.HOME ?? ""
if (!home) return {}
@@ -22,6 +25,10 @@ export async function getCustomThemes() {
const glob = new Bun.Glob("*.json")
for await (const item of glob.scan({ absolute: true, followSymlinks: true, cwd: dir })) {
const name = path.basename(item, ".json")
// Skip non-theme files
if (EXCLUDED_FILES.has(name) || EXCLUDED_FILES.has(path.basename(item))) {
continue
}
const json = (await Bun.file(item).json()) as ThemeJson
validateTheme(json, item)
result[name] = json

136
src/utils/event-bus.ts Normal file
View File

@@ -0,0 +1,136 @@
/**
* Simple event bus for inter-component communication.
*
* This provides a decoupled way for components to communicate without
* direct dependencies. Components can publish events and subscribe to
* events they're interested in.
*
* Usage:
* ```tsx
* // Subscribe to events
* const unsub = EventBus.on("theme.changed", (data) => {
* console.log("Theme changed to:", data.theme)
* })
*
* // Publish events
* EventBus.emit("theme.changed", { theme: "dark" })
*
* // Cleanup
* unsub()
* ```
*/
type EventHandler<T = unknown> = (data: T) => void
// Export EventHandler type for external use
export type { EventHandler }
interface EventBusInstance {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void
once<T = unknown>(event: string, handler: EventHandler<T>): () => void
off<T = unknown>(event: string, handler: EventHandler<T>): void
emit<T = unknown>(event: string, data: T): void
clear(): void
}
function createEventBus(): EventBusInstance {
const handlers = new Map<string, Set<EventHandler>>()
return {
on<T = unknown>(event: string, handler: EventHandler<T>): () => void {
if (!handlers.has(event)) {
handlers.set(event, new Set())
}
handlers.get(event)!.add(handler as EventHandler)
// Return unsubscribe function
return () => {
this.off(event, handler)
}
},
once<T = unknown>(event: string, handler: EventHandler<T>): () => void {
const wrappedHandler: EventHandler<T> = (data) => {
this.off(event, wrappedHandler)
handler(data)
}
return this.on(event, wrappedHandler)
},
off<T = unknown>(event: string, handler: EventHandler<T>): void {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
eventHandlers.delete(handler as EventHandler)
if (eventHandlers.size === 0) {
handlers.delete(event)
}
}
},
emit<T = unknown>(event: string, data: T): void {
const eventHandlers = handlers.get(event)
if (eventHandlers) {
for (const handler of eventHandlers) {
try {
handler(data)
} catch (error) {
console.error(`Error in event handler for "${event}":`, error)
}
}
}
},
clear(): void {
handlers.clear()
},
}
}
// Singleton event bus instance
export const EventBus = createEventBus()
// Common event types for the application
export type AppEvents = {
"theme.changed": { theme: string; mode: "dark" | "light" }
"theme.mode.changed": { mode: "dark" | "light" }
"theme.reload": {}
"navigation.tab.changed": { tab: string; previousTab?: string }
"navigation.layer.changed": { depth: number; previousDepth: number }
"feed.subscribed": { feedId: string; feedUrl: string }
"feed.unsubscribed": { feedId: string }
"player.play": { episodeId: string }
"player.pause": { episodeId: string }
"player.stop": {}
"auth.login": { userId: string }
"auth.logout": {}
"toast.show": { message: string; variant: "info" | "success" | "warning" | "error"; title?: string; duration?: number }
"dialog.open": { dialogId: string }
"dialog.close": { dialogId?: string }
"command.execute": { command: string; args?: unknown }
}
// Type-safe emit and on functions
export function emit<K extends keyof AppEvents>(event: K, data: AppEvents[K]): void {
EventBus.emit(event, data)
}
export function on<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
): () => void {
return EventBus.on(event, handler)
}
export function once<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
): () => void {
return EventBus.once(event, handler)
}
export function off<K extends keyof AppEvents>(
event: K,
handler: EventHandler<AppEvents[K]>
): void {
EventBus.off(event, handler)
}

187
src/utils/keybind.ts Normal file
View File

@@ -0,0 +1,187 @@
import type { ParsedKey } from "@opentui/core"
/**
* Keyboard shortcut parsing and matching utilities.
*
* Supports key combinations like:
* - "ctrl+c" - Control + c
* - "alt+x" - Alt + x
* - "shift+enter" - Shift + Enter
* - "<leader>n" - Leader key followed by n
* - "ctrl+shift+p" - Control + Shift + p
*/
export namespace Keybind {
export interface Info {
key: string
ctrl: boolean
alt: boolean
shift: boolean
meta: boolean
leader: boolean
}
/**
* Parse a keybind string into a structured Info object.
*
* Examples:
* - "ctrl+c" -> { key: "c", ctrl: true, ... }
* - "<leader>n" -> { key: "n", leader: true, ... }
* - "alt+shift+x" -> { key: "x", alt: true, shift: true, ... }
*/
export function parse(input: string): Info[] {
if (!input) return []
// Handle multiple keybinds separated by comma or space
const parts = input.split(/[,\s]+/).filter(Boolean)
return parts.map((part) => {
const info: Info = {
key: "",
ctrl: false,
alt: false,
shift: false,
meta: false,
leader: false,
}
// Check for leader key prefix
if (part.startsWith("<leader>")) {
info.leader = true
part = part.substring(8) // Remove "<leader>"
}
// Split by + for modifiers
const tokens = part.toLowerCase().split("+")
for (const token of tokens) {
switch (token) {
case "ctrl":
case "control":
info.ctrl = true
break
case "alt":
case "option":
info.alt = true
break
case "shift":
info.shift = true
break
case "meta":
case "cmd":
case "command":
case "win":
case "super":
info.meta = true
break
default:
// The last non-modifier token is the key
info.key = token
}
}
return info
})
}
/**
* Convert a ParsedKey event to a Keybind.Info.
*/
export function fromParsedKey(evt: ParsedKey, leader: boolean = false): Info {
// ParsedKey has ctrl, shift, meta but may not have alt directly
// We need to check what properties are available
const evtAny = evt as unknown as Record<string, unknown>
return {
key: evt.name?.toLowerCase() ?? "",
ctrl: evt.ctrl ?? false,
alt: (evtAny.alt as boolean) ?? false,
shift: evt.shift ?? false,
meta: evt.meta ?? false,
leader,
}
}
/**
* Check if a keybind matches a parsed key event.
*/
export function match(keybind: Info, evt: Info): boolean {
return (
keybind.key === evt.key &&
keybind.ctrl === evt.ctrl &&
keybind.alt === evt.alt &&
keybind.shift === evt.shift &&
keybind.meta === evt.meta &&
keybind.leader === evt.leader
)
}
/**
* Convert a keybind Info to a display string.
*/
export function toString(info: Info): string {
const parts: string[] = []
if (info.leader) parts.push("<leader>")
if (info.ctrl) parts.push("Ctrl")
if (info.alt) parts.push("Alt")
if (info.shift) parts.push("Shift")
if (info.meta) parts.push("Cmd")
if (info.key) {
// Capitalize special keys
const displayKey = info.key.length === 1 ? info.key.toUpperCase() : capitalize(info.key)
parts.push(displayKey)
}
return parts.join("+")
}
function capitalize(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1)
}
}
/**
* Default keybindings configuration.
*/
export const DEFAULT_KEYBINDS = {
// Leader key (space by default)
leader: "space",
// Navigation
tab_next: "tab",
tab_prev: "shift+tab",
// App commands
command_list: "ctrl+p",
help: "?",
quit: "ctrl+c",
// Session/content
session_new: "<leader>n",
session_list: "<leader>s",
// Theme
theme_list: "<leader>t",
// Player
player_play: "space",
player_pause: "space",
player_next: "n",
player_prev: "p",
player_seek_forward: "l",
player_seek_backward: "h",
// List navigation
list_up: "k",
list_down: "j",
list_top: "g",
list_bottom: "G",
list_select: "enter",
// Search
search_focus: "/",
search_clear: "escape",
}
export type KeybindsConfig = typeof DEFAULT_KEYBINDS

104
src/utils/theme-observer.ts Normal file
View File

@@ -0,0 +1,104 @@
/**
* Theme observer utility for detecting and responding to theme changes.
*
* This module provides utilities for:
* - Listening to SIGUSR2 signals for theme reload
* - Emitting theme change events via the event bus
* - Tracking theme change state
*/
import { emit, on, off, type EventHandler } from "./event-bus"
/**
* Subscribe to theme reload events.
* These are triggered by SIGUSR2 signals.
*/
export function onThemeReload(handler: EventHandler<{}>): () => void {
return on("theme.reload", handler)
}
/**
* Subscribe to theme changed events.
* These are triggered when the theme selection changes.
*/
export function onThemeChanged(
handler: EventHandler<{ theme: string; mode: "dark" | "light" }>
): () => void {
return on("theme.changed", handler)
}
/**
* Subscribe to theme mode changed events.
* These are triggered when switching between dark/light mode.
*/
export function onThemeModeChanged(
handler: EventHandler<{ mode: "dark" | "light" }>
): () => void {
return on("theme.mode.changed", handler)
}
/**
* Emit a theme reload event.
*/
export function emitThemeReload(): void {
emit("theme.reload", {})
}
/**
* Emit a theme changed event.
*/
export function emitThemeChanged(theme: string, mode: "dark" | "light"): void {
emit("theme.changed", { theme, mode })
}
/**
* Emit a theme mode changed event.
*/
export function emitThemeModeChanged(mode: "dark" | "light"): void {
emit("theme.mode.changed", { mode })
}
/**
* Setup SIGUSR2 signal handler for theme reload.
* This allows external tools to trigger a theme refresh by sending SIGUSR2 to the process.
*
* Usage: `kill -USR2 <pid>` to trigger a theme reload
*
* @param onReload - Callback to execute when SIGUSR2 is received
* @returns Cleanup function to remove the handler
*/
export function setupThemeSignalHandler(onReload: () => void): () => void {
const handler = () => {
emitThemeReload()
onReload()
}
process.on("SIGUSR2", handler)
return () => {
process.off("SIGUSR2", handler)
}
}
/**
* Create a debounced theme change handler to prevent rapid consecutive updates.
*
* @param handler - The handler to debounce
* @param delay - Delay in milliseconds (default: 100ms)
*/
export function createDebouncedThemeHandler<T>(
handler: (event: T) => void,
delay: number = 100
): (event: T) => void {
let timeout: NodeJS.Timeout | null = null
return (event: T) => {
if (timeout) {
clearTimeout(timeout)
}
timeout = setTimeout(() => {
handler(event)
timeout = null
}, delay)
}
}