From 6a559fc8d1e8b4d90553c3a2934d573482116930 Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 7 Jan 2026 20:06:56 -0800 Subject: [PATCH 1/7] feat: add theme system with 29 color themes Add comprehensive theming support with theme files for popular color schemes: - Theme JSON files: catppuccin, dracula, nord, tokyo-night, gruvbox, monokai, etc. - Theme resolver for loading themes from opencode config - ThemeContext provider for reactive theme access - Theme color accessor functions with fallbacks Themes are compatible with opencode's theme format and support dynamic switching based on user preferences. --- src/context/ThemeContext.tsx | 187 +++++++++++++++++++ src/lib/theme-colors.ts | 215 ++++++++++++++++++++++ src/lib/theme-resolver.ts | 167 +++++++++++++++++ src/lib/themes/.gitkeep | 0 src/lib/themes/aura.json | 69 +++++++ src/lib/themes/ayu.json | 80 ++++++++ src/lib/themes/catppuccin-frappe.json | 233 ++++++++++++++++++++++++ src/lib/themes/catppuccin-latte.json | 233 ++++++++++++++++++++++++ src/lib/themes/catppuccin-mocha.json | 233 ++++++++++++++++++++++++ src/lib/themes/cobalt2.json | 72 ++++++++ src/lib/themes/cursor.json | 249 +++++++++++++++++++++++++ src/lib/themes/dracula.json | 234 ++++++++++++++++++++++++ src/lib/themes/everforest.json | 241 ++++++++++++++++++++++++ src/lib/themes/flexoki.json | 237 ++++++++++++++++++++++++ src/lib/themes/github.json | 233 ++++++++++++++++++++++++ src/lib/themes/gruvbox.json | 95 ++++++++++ src/lib/themes/index.ts | 85 +++++++++ src/lib/themes/kanagawa.json | 115 ++++++++++++ src/lib/themes/lucent-orng.json | 233 ++++++++++++++++++++++++ src/lib/themes/material.json | 235 ++++++++++++++++++++++++ src/lib/themes/matrix.json | 235 ++++++++++++++++++++++++ src/lib/themes/mercury.json | 252 ++++++++++++++++++++++++++ src/lib/themes/monokai.json | 235 ++++++++++++++++++++++++ src/lib/themes/nightowl.json | 221 ++++++++++++++++++++++ src/lib/themes/nord.json | 235 ++++++++++++++++++++++++ src/lib/themes/one-dark.json | 84 +++++++++ src/lib/themes/opencode.json | 245 +++++++++++++++++++++++++ src/lib/themes/orng.json | 245 +++++++++++++++++++++++++ src/lib/themes/osaka-jade.json | 93 ++++++++++ src/lib/themes/palenight.json | 222 +++++++++++++++++++++++ src/lib/themes/rosepine.json | 234 ++++++++++++++++++++++++ src/lib/themes/solarized.json | 223 +++++++++++++++++++++++ src/lib/themes/synthwave84.json | 226 +++++++++++++++++++++++ src/lib/themes/tokyonight.json | 235 ++++++++++++++++++++++++ src/lib/themes/vercel.json | 245 +++++++++++++++++++++++++ src/lib/themes/vesper.json | 218 ++++++++++++++++++++++ src/lib/themes/zenburn.json | 223 +++++++++++++++++++++++ tests/unit/theme-resolver.test.ts | 198 ++++++++++++++++++++ 38 files changed, 7315 insertions(+) create mode 100644 src/context/ThemeContext.tsx create mode 100644 src/lib/theme-colors.ts create mode 100644 src/lib/theme-resolver.ts create mode 100644 src/lib/themes/.gitkeep create mode 100644 src/lib/themes/aura.json create mode 100644 src/lib/themes/ayu.json create mode 100644 src/lib/themes/catppuccin-frappe.json create mode 100644 src/lib/themes/catppuccin-latte.json create mode 100644 src/lib/themes/catppuccin-mocha.json create mode 100644 src/lib/themes/cobalt2.json create mode 100644 src/lib/themes/cursor.json create mode 100644 src/lib/themes/dracula.json create mode 100644 src/lib/themes/everforest.json create mode 100644 src/lib/themes/flexoki.json create mode 100644 src/lib/themes/github.json create mode 100644 src/lib/themes/gruvbox.json create mode 100644 src/lib/themes/index.ts create mode 100644 src/lib/themes/kanagawa.json create mode 100644 src/lib/themes/lucent-orng.json create mode 100644 src/lib/themes/material.json create mode 100644 src/lib/themes/matrix.json create mode 100644 src/lib/themes/mercury.json create mode 100644 src/lib/themes/monokai.json create mode 100644 src/lib/themes/nightowl.json create mode 100644 src/lib/themes/nord.json create mode 100644 src/lib/themes/one-dark.json create mode 100644 src/lib/themes/opencode.json create mode 100644 src/lib/themes/orng.json create mode 100644 src/lib/themes/osaka-jade.json create mode 100644 src/lib/themes/palenight.json create mode 100644 src/lib/themes/rosepine.json create mode 100644 src/lib/themes/solarized.json create mode 100644 src/lib/themes/synthwave84.json create mode 100644 src/lib/themes/tokyonight.json create mode 100644 src/lib/themes/vercel.json create mode 100644 src/lib/themes/vesper.json create mode 100644 src/lib/themes/zenburn.json create mode 100644 tests/unit/theme-resolver.test.ts diff --git a/src/context/ThemeContext.tsx b/src/context/ThemeContext.tsx new file mode 100644 index 0000000..6806ef4 --- /dev/null +++ b/src/context/ThemeContext.tsx @@ -0,0 +1,187 @@ +import { + createContext, + useContext, + createSignal, + createMemo, + createEffect, + onMount, + JSX, +} from "solid-js"; +import type { Accessor, Setter } from "solid-js"; +import { resolveTheme, type Theme, type ThemeMode } from "../lib/theme-resolver"; +import { themeNames, defaultTheme } from "../lib/themes/index"; +import { setCurrentTheme } from "../lib/theme-colors"; +import { log } from "../util/log"; + +/** + * Ralph state from kv.json + */ +interface RalphState { + theme?: string; + theme_mode?: ThemeMode; +} + +/** + * Context value interface defining theme access and mutation. + */ +export interface ThemeContextValue { + /** Current resolved theme with all color values */ + theme: Accessor; + /** Current theme name */ + themeName: Accessor; + /** Current theme mode (dark/light) */ + themeMode: Accessor; + /** List of all available theme names */ + themeNames: readonly string[]; + /** Set theme name (persists to kv.json) */ + setThemeName: (name: string) => void; + /** Set theme mode (persists to kv.json) */ + setThemeMode: (mode: ThemeMode) => void; +} + +// Create the context with undefined default (must be used within provider) +const ThemeContext = createContext(); + +/** + * Props for the ThemeProvider component. + */ +export interface ThemeProviderProps { + children: JSX.Element; +} + +/** + * Get the path to Ralph's kv.json state file. + * Creates the directory if it doesn't exist. + */ +function getKvPath(): string { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + return `${homeDir}/.local/state/ralph/kv.json`; +} + +/** + * Ensure the state directory exists. + */ +async function ensureStateDir(): Promise { + const homeDir = process.env.HOME || process.env.USERPROFILE || ""; + const stateDir = `${homeDir}/.local/state/ralph`; + const { mkdir } = await import("fs/promises"); + await mkdir(stateDir, { recursive: true }); +} + +/** + * Read Ralph state from ~/.local/state/ralph/kv.json + */ +async function readRalphState(): Promise { + try { + const kvPath = getKvPath(); + const file = Bun.file(kvPath); + if (await file.exists()) { + const content = await file.text(); + return JSON.parse(content) as RalphState; + } + } catch (error) { + log("theme", "Failed to read Ralph state", { error }); + } + return {}; +} + +/** + * Write Ralph state to ~/.local/state/ralph/kv.json + * Merges with existing state to preserve other keys. + */ +async function writeRalphState(updates: Partial): Promise { + try { + await ensureStateDir(); + const kvPath = getKvPath(); + const existing = await readRalphState(); + const merged = { ...existing, ...updates }; + await Bun.write(kvPath, JSON.stringify(merged, null, 2)); + log("theme", "Saved theme preference", updates); + } catch (error) { + log("theme", "Failed to write Ralph state", { error }); + } +} + +/** + * ThemeProvider component that manages theme state. + * Reads theme preference from Ralph's state file. + */ +export function ThemeProvider(props: ThemeProviderProps) { + // Theme state signals + const [themeName, setThemeNameSignal] = createSignal(defaultTheme); + const [themeMode, setThemeModeSignal] = createSignal("dark"); + + // Derived resolved theme - recomputes when name or mode changes + const theme = createMemo(() => { + return resolveTheme(themeName(), themeMode()); + }); + + // Sync theme state with color accessor module for non-reactive usage + createEffect(() => { + setCurrentTheme(themeName(), themeMode()); + }); + + // Read theme preference from Ralph state on mount + onMount(async () => { + const state = await readRalphState(); + + if (state.theme && themeNames.includes(state.theme)) { + setThemeNameSignal(state.theme); + log("theme", "Loaded theme from Ralph state", { theme: state.theme }); + } + + if (state.theme_mode && (state.theme_mode === "dark" || state.theme_mode === "light")) { + setThemeModeSignal(state.theme_mode); + log("theme", "Loaded theme mode from Ralph state", { mode: state.theme_mode }); + } + }); + + // Wrapper to set theme name and persist + const setThemeName = (name: string) => { + if (!themeNames.includes(name)) { + log("theme", "Invalid theme name", { name, available: themeNames }); + return; + } + setThemeNameSignal(name); + writeRalphState({ theme: name }); + }; + + // Wrapper to set theme mode and persist + const setThemeMode = (mode: ThemeMode) => { + if (mode !== "dark" && mode !== "light") { + log("theme", "Invalid theme mode", { mode }); + return; + } + setThemeModeSignal(mode); + writeRalphState({ theme_mode: mode }); + }; + + const themeValue: ThemeContextValue = { + theme, + themeName, + themeMode, + themeNames, + setThemeName, + setThemeMode, + }; + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the theme context. + * Must be used within a ThemeProvider. + * + * @throws Error if used outside of ThemeProvider + */ +export function useTheme(): ThemeContextValue { + const context = useContext(ThemeContext); + if (!context) { + throw new Error("useTheme must be used within a ThemeProvider"); + } + return context; +} diff --git a/src/lib/theme-colors.ts b/src/lib/theme-colors.ts new file mode 100644 index 0000000..ac9bae1 --- /dev/null +++ b/src/lib/theme-colors.ts @@ -0,0 +1,215 @@ +/** + * Theme-based color accessor functions. + * + * This module provides reactive color accessors that integrate with the theme system. + * These functions allow accessing theme colors outside of Solid components where hooks + * cannot be used, while still respecting the current theme settings. + * + * For components that can use hooks, prefer `useTheme()` from ThemeContext. + * Use these accessors for: + * - Utility functions that need theme colors + * - Non-reactive contexts where direct color access is needed + * - Gradual migration from legacy colors.ts + * + * @example + * // In a utility function + * import { getColor, colors } from "./theme-colors"; + * const errorColor = getColor("error"); + * + * // Or use the colors proxy for cleaner syntax + * const errorColor = colors.error; + */ + +import { resolveTheme, type Theme, type ThemeColorKey, type ThemeMode } from "./theme-resolver"; +import { defaultTheme } from "./themes/index"; + +/** + * Current theme state - can be set by ThemeProvider or kept at defaults. + * This is updated by setCurrentTheme() when the theme changes. + */ +let currentThemeName: string = defaultTheme; +let currentThemeMode: ThemeMode = "dark"; +let cachedTheme: Theme | null = null; + +/** + * Set the current theme state. Called by ThemeProvider when theme changes. + * Invalidates the cached theme to force re-resolution on next access. + * + * @param themeName - Name of the theme to use + * @param mode - Theme mode (dark/light) + */ +export function setCurrentTheme(themeName: string, mode: ThemeMode = "dark"): void { + if (currentThemeName !== themeName || currentThemeMode !== mode) { + currentThemeName = themeName; + currentThemeMode = mode; + cachedTheme = null; // Invalidate cache + } +} + +/** + * Get the currently resolved theme object. + * Uses caching to avoid re-resolving the theme on every access. + * + * @returns The resolved Theme object with all color values + */ +export function getCurrentTheme(): Theme { + if (!cachedTheme) { + cachedTheme = resolveTheme(currentThemeName, currentThemeMode); + } + return cachedTheme; +} + +/** + * Get a single color value from the current theme. + * + * @param key - The theme color key to retrieve + * @returns The hex color string for the specified key + * + * @example + * const errorColor = getColor("error"); // "#ef5350" + * const bgColor = getColor("background"); // "#011627" + */ +export function getColor(key: ThemeColorKey): string { + return getCurrentTheme()[key]; +} + +/** + * Get multiple color values from the current theme at once. + * More efficient than calling getColor() multiple times. + * + * @param keys - Array of theme color keys to retrieve + * @returns Object mapping keys to their color values + * + * @example + * const { error, success, warning } = getColors(["error", "success", "warning"]); + */ +export function getColors(keys: K[]): Pick { + const theme = getCurrentTheme(); + const result = {} as Pick; + for (const key of keys) { + result[key] = theme[key]; + } + return result; +} + +/** + * Proxy object for accessing theme colors with property syntax. + * Provides a more ergonomic API for accessing individual colors. + * + * Note: This is not reactive - it returns the current value at access time. + * For reactive usage in Solid components, use `useTheme()` instead. + * + * @example + * import { colors } from "./theme-colors"; + * const bg = colors.background; + * const fg = colors.text; + */ +export const colors: Readonly = new Proxy({} as Theme, { + get(_, prop: string) { + return getColor(prop as ThemeColorKey); + }, +}); + +/** + * Type-safe color accessor that validates the key at compile time. + * Identical to getColor() but provides better IDE autocomplete. + * + * @param key - The theme color key (with autocomplete) + * @returns The hex color string + */ +export function color(key: K): string { + return getCurrentTheme()[key]; +} + +/** + * Legacy color mapping - maps old color names to theme color keys. + * Use this for gradual migration from the deprecated colors.ts. + * + * @deprecated Use direct theme color keys instead + */ +const legacyColorMap: Record = { + bg: "background", + bgDark: "background", + bgHighlight: "backgroundElement", + bgPanel: "backgroundPanel", + fg: "text", + fgDark: "textMuted", + fgMuted: "textMuted", + green: "success", + red: "error", + yellow: "warning", + blue: "info", + purple: "accent", + cyan: "secondary", + border: "border", + orange: "warning", // No direct equivalent, use warning +}; + +/** + * Get a color using the legacy color name. + * Useful for gradual migration from colors.ts. + * + * @param legacyName - The old color name (e.g., "bg", "fg", "green") + * @returns The corresponding theme color value + * + * @deprecated Use getColor() with theme color keys instead + * + * @example + * // Old code: + * import { colors } from "./colors"; + * const bg = colors.bg; + * + * // Migration step: + * import { getLegacyColor } from "./theme-colors"; + * const bg = getLegacyColor("bg"); + * + * // Final code: + * import { getColor } from "./theme-colors"; + * const bg = getColor("background"); + */ +export function getLegacyColor(legacyName: string): string { + const themeKey = legacyColorMap[legacyName]; + if (themeKey) { + return getColor(themeKey); + } + // Fallback: try as a direct theme key + return getColor(legacyName as ThemeColorKey); +} + +/** + * Legacy colors proxy - maps old color property names to theme colors. + * Drop-in replacement for the deprecated colors object from colors.ts. + * + * @deprecated Use the `colors` proxy or `getColor()` instead + * + * @example + * // Replace: + * import { colors } from "./colors"; + * // With: + * import { legacyColors as colors } from "./theme-colors"; + */ +export const legacyColors: Readonly> = new Proxy({} as Record, { + get(_, prop: string) { + return getLegacyColor(prop); + }, +}); + +/** + * Icons for different tool types displayed in the event log. + * Uses Nerd Font glyphs for a modern look. + */ +export const TOOL_ICONS: Record = { + read: "󰈞", // Read icon + write: "󰏫", // Write icon + edit: "󰛓", // Edit icon + glob: "center", // Glob icon + grep: "󰱽", // Grep icon + bash: "󰆍", // Bash icon + task: "󰙨", // Task icon + webfetch: "󰖟", + websearch: "󰖟", + codesearch: "󰖟", + todowrite: "󰗡", + todoread: "󰗡", + thought: "󰋚", // Reasoning/Thought icon +}; diff --git a/src/lib/theme-resolver.ts b/src/lib/theme-resolver.ts new file mode 100644 index 0000000..1a7b11a --- /dev/null +++ b/src/lib/theme-resolver.ts @@ -0,0 +1,167 @@ +import { themes, defaultTheme, type ThemeJson, type ThemeColorValue } from "./themes/index"; + +/** + * Mode type for dark/light theming + */ +export type ThemeMode = "dark" | "light"; + +/** + * Resolved theme with all color values as hex strings + */ +export interface Theme { + // Core UI colors + primary: string; + secondary: string; + accent: string; + error: string; + warning: string; + success: string; + info: string; + + // Text colors + text: string; + textMuted: string; + + // Background colors + background: string; + backgroundPanel: string; + backgroundElement: string; + + // Border colors + border: string; + borderActive: string; + borderSubtle: string; + + // Diff colors + diffAdded: string; + diffRemoved: string; + diffContext: string; + diffHunkHeader: string; + diffHighlightAdded: string; + diffHighlightRemoved: string; + diffAddedBg: string; + diffRemovedBg: string; + diffContextBg: string; + diffLineNumber: string; + diffAddedLineNumberBg: string; + diffRemovedLineNumberBg: string; + + // Markdown colors + markdownText: string; + markdownHeading: string; + markdownLink: string; + markdownLinkText: string; + markdownCode: string; + markdownBlockQuote: string; + markdownEmph: string; + markdownStrong: string; + markdownHorizontalRule: string; + markdownListItem: string; + markdownListEnumeration: string; + markdownImage: string; + markdownImageText: string; + markdownCodeBlock: string; + + // Syntax highlighting colors + syntaxComment: string; + syntaxKeyword: string; + syntaxFunction: string; + syntaxVariable: string; + syntaxString: string; + syntaxNumber: string; + syntaxType: string; + syntaxOperator: string; + syntaxPunctuation: string; +} + +/** + * All possible theme color keys + */ +export type ThemeColorKey = keyof Theme; + +/** + * Resolve a color value, handling variable references to defs + */ +function resolveColorValue( + value: ThemeColorValue, + defs: Record, + mode: ThemeMode +): string { + // Handle dark/light mode object + if (typeof value === "object" && value !== null) { + const modeValue = value[mode]; + // Resolve the mode-specific value (may be a def reference or direct hex) + return resolveColorRef(modeValue, defs); + } + + // Direct string value + return resolveColorRef(value, defs); +} + +/** + * Resolve a single color reference (either a def key or direct hex) + */ +function resolveColorRef(value: string, defs: Record): string { + // If it starts with #, it's a direct hex color + if (value.startsWith("#")) { + return value; + } + + // Otherwise, it's a reference to a def + const resolved = defs[value]; + if (resolved) { + return resolved; + } + + // Fallback if def not found - return the value as-is (shouldn't happen with valid themes) + return value; +} + +/** + * Resolve a theme JSON to a Theme object with all hex color values + */ +export function resolveTheme( + themeName: string = defaultTheme, + mode: ThemeMode = "dark" +): Theme { + const themeJson = themes[themeName] ?? themes[defaultTheme]; + + if (!themeJson) { + throw new Error(`Theme "${themeName}" not found and no default theme available`); + } + + const { defs, theme } = themeJson; + + // Build the resolved theme by resolving each color key + const resolved: Partial = {}; + + for (const [key, value] of Object.entries(theme)) { + (resolved as Record)[key] = resolveColorValue(value, defs, mode); + } + + return resolved as Theme; +} + +/** + * Get a single resolved color from a theme + */ +export function getThemeColor( + themeName: string, + colorKey: ThemeColorKey, + mode: ThemeMode = "dark" +): string { + const theme = resolveTheme(themeName, mode); + return theme[colorKey]; +} + +/** + * Check if a theme name is valid + */ +export function isValidTheme(themeName: string): boolean { + return themeName in themes; +} + +/** + * Get list of available theme names + */ +export { themeNames } from "./themes/index"; diff --git a/src/lib/themes/.gitkeep b/src/lib/themes/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/src/lib/themes/aura.json b/src/lib/themes/aura.json new file mode 100644 index 0000000..e7798d5 --- /dev/null +++ b/src/lib/themes/aura.json @@ -0,0 +1,69 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0f0f0f", + "darkBgPanel": "#15141b", + "darkBorder": "#2d2d2d", + "darkFgMuted": "#6d6d6d", + "darkFg": "#edecee", + "purple": "#a277ff", + "pink": "#f694ff", + "blue": "#82e2ff", + "red": "#ff6767", + "orange": "#ffca85", + "cyan": "#61ffca", + "green": "#9dff65" + }, + "theme": { + "primary": "purple", + "secondary": "pink", + "accent": "purple", + "error": "red", + "warning": "orange", + "success": "cyan", + "info": "purple", + "text": "darkFg", + "textMuted": "darkFgMuted", + "background": "darkBg", + "backgroundPanel": "darkBgPanel", + "backgroundElement": "darkBgPanel", + "border": "darkBorder", + "borderActive": "darkFgMuted", + "borderSubtle": "darkBorder", + "diffAdded": "cyan", + "diffRemoved": "red", + "diffContext": "darkFgMuted", + "diffHunkHeader": "darkFgMuted", + "diffHighlightAdded": "cyan", + "diffHighlightRemoved": "red", + "diffAddedBg": "#354933", + "diffRemovedBg": "#3f191a", + "diffContextBg": "darkBgPanel", + "diffLineNumber": "darkBorder", + "diffAddedLineNumberBg": "#162620", + "diffRemovedLineNumberBg": "#26161a", + "markdownText": "darkFg", + "markdownHeading": "purple", + "markdownLink": "pink", + "markdownLinkText": "purple", + "markdownCode": "cyan", + "markdownBlockQuote": "darkFgMuted", + "markdownEmph": "orange", + "markdownStrong": "purple", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "purple", + "markdownListEnumeration": "purple", + "markdownImage": "pink", + "markdownImageText": "purple", + "markdownCodeBlock": "darkFg", + "syntaxComment": "darkFgMuted", + "syntaxKeyword": "pink", + "syntaxFunction": "purple", + "syntaxVariable": "purple", + "syntaxString": "cyan", + "syntaxNumber": "green", + "syntaxType": "purple", + "syntaxOperator": "pink", + "syntaxPunctuation": "darkFg" + } +} diff --git a/src/lib/themes/ayu.json b/src/lib/themes/ayu.json new file mode 100644 index 0000000..a42fce4 --- /dev/null +++ b/src/lib/themes/ayu.json @@ -0,0 +1,80 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0B0E14", + "darkBgAlt": "#0D1017", + "darkLine": "#11151C", + "darkPanel": "#0F131A", + "darkFg": "#BFBDB6", + "darkFgMuted": "#565B66", + "darkGutter": "#6C7380", + "darkTag": "#39BAE6", + "darkFunc": "#FFB454", + "darkEntity": "#59C2FF", + "darkString": "#AAD94C", + "darkRegexp": "#95E6CB", + "darkMarkup": "#F07178", + "darkKeyword": "#FF8F40", + "darkSpecial": "#E6B673", + "darkComment": "#ACB6BF", + "darkConstant": "#D2A6FF", + "darkOperator": "#F29668", + "darkAdded": "#7FD962", + "darkRemoved": "#F26D78", + "darkAccent": "#E6B450", + "darkError": "#D95757", + "darkIndentActive": "#6C7380" + }, + "theme": { + "primary": "darkEntity", + "secondary": "darkConstant", + "accent": "darkAccent", + "error": "darkError", + "warning": "darkSpecial", + "success": "darkAdded", + "info": "darkTag", + "text": "darkFg", + "textMuted": "darkFgMuted", + "background": "darkBg", + "backgroundPanel": "darkPanel", + "backgroundElement": "darkBgAlt", + "border": "darkGutter", + "borderActive": "darkIndentActive", + "borderSubtle": "darkLine", + "diffAdded": "darkAdded", + "diffRemoved": "darkRemoved", + "diffContext": "darkComment", + "diffHunkHeader": "darkComment", + "diffHighlightAdded": "darkString", + "diffHighlightRemoved": "darkMarkup", + "diffAddedBg": "#20303b", + "diffRemovedBg": "#37222c", + "diffContextBg": "darkPanel", + "diffLineNumber": "darkGutter", + "diffAddedLineNumberBg": "#1b2b34", + "diffRemovedLineNumberBg": "#2d1f26", + "markdownText": "darkFg", + "markdownHeading": "darkConstant", + "markdownLink": "darkEntity", + "markdownLinkText": "darkTag", + "markdownCode": "darkString", + "markdownBlockQuote": "darkSpecial", + "markdownEmph": "darkSpecial", + "markdownStrong": "darkFunc", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "darkEntity", + "markdownListEnumeration": "darkTag", + "markdownImage": "darkEntity", + "markdownImageText": "darkTag", + "markdownCodeBlock": "darkFg", + "syntaxComment": "darkComment", + "syntaxKeyword": "darkKeyword", + "syntaxFunction": "darkFunc", + "syntaxVariable": "darkEntity", + "syntaxString": "darkString", + "syntaxNumber": "darkConstant", + "syntaxType": "darkSpecial", + "syntaxOperator": "darkOperator", + "syntaxPunctuation": "darkFg" + } +} diff --git a/src/lib/themes/catppuccin-frappe.json b/src/lib/themes/catppuccin-frappe.json new file mode 100644 index 0000000..79e56ee --- /dev/null +++ b/src/lib/themes/catppuccin-frappe.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "frappeRosewater": "#f2d5cf", + "frappeFlamingo": "#eebebe", + "frappePink": "#f4b8e4", + "frappeMauve": "#ca9ee6", + "frappeRed": "#e78284", + "frappeMaroon": "#ea999c", + "frappePeach": "#ef9f76", + "frappeYellow": "#e5c890", + "frappeGreen": "#a6d189", + "frappeTeal": "#81c8be", + "frappeSky": "#99d1db", + "frappeSapphire": "#85c1dc", + "frappeBlue": "#8da4e2", + "frappeLavender": "#babbf1", + "frappeText": "#c6d0f5", + "frappeSubtext1": "#b5bfe2", + "frappeSubtext0": "#a5adce", + "frappeOverlay2": "#949cb8", + "frappeOverlay1": "#838ba7", + "frappeOverlay0": "#737994", + "frappeSurface2": "#626880", + "frappeSurface1": "#51576d", + "frappeSurface0": "#414559", + "frappeBase": "#303446", + "frappeMantle": "#292c3c", + "frappeCrust": "#232634" + }, + "theme": { + "primary": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "secondary": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "accent": { + "dark": "frappePink", + "light": "frappePink" + }, + "error": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "warning": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "success": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "info": { + "dark": "frappeTeal", + "light": "frappeTeal" + }, + "text": { + "dark": "frappeText", + "light": "frappeText" + }, + "textMuted": { + "dark": "frappeSubtext1", + "light": "frappeSubtext1" + }, + "background": { + "dark": "frappeBase", + "light": "frappeBase" + }, + "backgroundPanel": { + "dark": "frappeMantle", + "light": "frappeMantle" + }, + "backgroundElement": { + "dark": "frappeCrust", + "light": "frappeCrust" + }, + "border": { + "dark": "frappeSurface0", + "light": "frappeSurface0" + }, + "borderActive": { + "dark": "frappeSurface1", + "light": "frappeSurface1" + }, + "borderSubtle": { + "dark": "frappeSurface2", + "light": "frappeSurface2" + }, + "diffAdded": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "diffRemoved": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "diffContext": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "diffHunkHeader": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "diffHighlightAdded": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "diffHighlightRemoved": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "diffAddedBg": { + "dark": "#29342b", + "light": "#29342b" + }, + "diffRemovedBg": { + "dark": "#3a2a31", + "light": "#3a2a31" + }, + "diffContextBg": { + "dark": "frappeMantle", + "light": "frappeMantle" + }, + "diffLineNumber": { + "dark": "frappeSurface1", + "light": "frappeSurface1" + }, + "diffAddedLineNumberBg": { + "dark": "#223025", + "light": "#223025" + }, + "diffRemovedLineNumberBg": { + "dark": "#2f242b", + "light": "#2f242b" + }, + "markdownText": { + "dark": "frappeText", + "light": "frappeText" + }, + "markdownHeading": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "markdownLink": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownLinkText": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownCode": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "markdownBlockQuote": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "markdownEmph": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "markdownStrong": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "markdownHorizontalRule": { + "dark": "frappeSubtext0", + "light": "frappeSubtext0" + }, + "markdownListItem": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownListEnumeration": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownImage": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "markdownImageText": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "markdownCodeBlock": { + "dark": "frappeText", + "light": "frappeText" + }, + "syntaxComment": { + "dark": "frappeOverlay2", + "light": "frappeOverlay2" + }, + "syntaxKeyword": { + "dark": "frappeMauve", + "light": "frappeMauve" + }, + "syntaxFunction": { + "dark": "frappeBlue", + "light": "frappeBlue" + }, + "syntaxVariable": { + "dark": "frappeRed", + "light": "frappeRed" + }, + "syntaxString": { + "dark": "frappeGreen", + "light": "frappeGreen" + }, + "syntaxNumber": { + "dark": "frappePeach", + "light": "frappePeach" + }, + "syntaxType": { + "dark": "frappeYellow", + "light": "frappeYellow" + }, + "syntaxOperator": { + "dark": "frappeSky", + "light": "frappeSky" + }, + "syntaxPunctuation": { + "dark": "frappeText", + "light": "frappeText" + } + } +} diff --git a/src/lib/themes/catppuccin-latte.json b/src/lib/themes/catppuccin-latte.json new file mode 100644 index 0000000..0c4f2cb --- /dev/null +++ b/src/lib/themes/catppuccin-latte.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "latteRosewater": "#dc8a78", + "latteFlamingo": "#dd7878", + "lattePink": "#ea76cb", + "latteMauve": "#8839ef", + "latteRed": "#d20f39", + "latteMaroon": "#e64553", + "lattePeach": "#fe640b", + "latteYellow": "#df8e1d", + "latteGreen": "#40a02b", + "latteTeal": "#179299", + "latteSky": "#04a5e5", + "latteSapphire": "#209fb5", + "latteBlue": "#1e66f5", + "latteLavender": "#7287fd", + "latteText": "#4c4f69", + "latteSubtext1": "#5c5f77", + "latteSubtext0": "#6c6f85", + "latteOverlay2": "#7c7f93", + "latteOverlay1": "#8c8fa1", + "latteOverlay0": "#9ca0b0", + "latteSurface2": "#acb0be", + "latteSurface1": "#bcc0cc", + "latteSurface0": "#ccd0da", + "latteBase": "#eff1f5", + "latteMantle": "#e6e9ef", + "latteCrust": "#dce0e8" + }, + "theme": { + "primary": { + "dark": "latteBlue", + "light": "latteBlue" + }, + "secondary": { + "dark": "latteMauve", + "light": "latteMauve" + }, + "accent": { + "dark": "lattePink", + "light": "lattePink" + }, + "error": { + "dark": "latteRed", + "light": "latteRed" + }, + "warning": { + "dark": "latteYellow", + "light": "latteYellow" + }, + "success": { + "dark": "latteGreen", + "light": "latteGreen" + }, + "info": { + "dark": "latteTeal", + "light": "latteTeal" + }, + "text": { + "dark": "latteText", + "light": "latteText" + }, + "textMuted": { + "dark": "latteSubtext1", + "light": "latteSubtext1" + }, + "background": { + "dark": "latteBase", + "light": "latteBase" + }, + "backgroundPanel": { + "dark": "latteMantle", + "light": "latteMantle" + }, + "backgroundElement": { + "dark": "latteCrust", + "light": "latteCrust" + }, + "border": { + "dark": "latteSurface0", + "light": "latteSurface0" + }, + "borderActive": { + "dark": "latteSurface1", + "light": "latteSurface1" + }, + "borderSubtle": { + "dark": "latteSurface2", + "light": "latteSurface2" + }, + "diffAdded": { + "dark": "latteGreen", + "light": "latteGreen" + }, + "diffRemoved": { + "dark": "latteRed", + "light": "latteRed" + }, + "diffContext": { + "dark": "latteOverlay2", + "light": "latteOverlay2" + }, + "diffHunkHeader": { + "dark": "lattePeach", + "light": "lattePeach" + }, + "diffHighlightAdded": { + "dark": "latteGreen", + "light": "latteGreen" + }, + "diffHighlightRemoved": { + "dark": "latteRed", + "light": "latteRed" + }, + "diffAddedBg": { + "dark": "#cde8c6", + "light": "#cde8c6" + }, + "diffRemovedBg": { + "dark": "#f5c6cb", + "light": "#f5c6cb" + }, + "diffContextBg": { + "dark": "latteMantle", + "light": "latteMantle" + }, + "diffLineNumber": { + "dark": "latteSurface1", + "light": "latteSurface1" + }, + "diffAddedLineNumberBg": { + "dark": "#d8efd3", + "light": "#d8efd3" + }, + "diffRemovedLineNumberBg": { + "dark": "#f9dde0", + "light": "#f9dde0" + }, + "markdownText": { + "dark": "latteText", + "light": "latteText" + }, + "markdownHeading": { + "dark": "latteMauve", + "light": "latteMauve" + }, + "markdownLink": { + "dark": "latteBlue", + "light": "latteBlue" + }, + "markdownLinkText": { + "dark": "latteSky", + "light": "latteSky" + }, + "markdownCode": { + "dark": "latteGreen", + "light": "latteGreen" + }, + "markdownBlockQuote": { + "dark": "latteYellow", + "light": "latteYellow" + }, + "markdownEmph": { + "dark": "latteYellow", + "light": "latteYellow" + }, + "markdownStrong": { + "dark": "lattePeach", + "light": "lattePeach" + }, + "markdownHorizontalRule": { + "dark": "latteSubtext0", + "light": "latteSubtext0" + }, + "markdownListItem": { + "dark": "latteBlue", + "light": "latteBlue" + }, + "markdownListEnumeration": { + "dark": "latteSky", + "light": "latteSky" + }, + "markdownImage": { + "dark": "latteBlue", + "light": "latteBlue" + }, + "markdownImageText": { + "dark": "latteSky", + "light": "latteSky" + }, + "markdownCodeBlock": { + "dark": "latteText", + "light": "latteText" + }, + "syntaxComment": { + "dark": "latteOverlay2", + "light": "latteOverlay2" + }, + "syntaxKeyword": { + "dark": "latteMauve", + "light": "latteMauve" + }, + "syntaxFunction": { + "dark": "latteBlue", + "light": "latteBlue" + }, + "syntaxVariable": { + "dark": "latteRed", + "light": "latteRed" + }, + "syntaxString": { + "dark": "latteGreen", + "light": "latteGreen" + }, + "syntaxNumber": { + "dark": "lattePeach", + "light": "lattePeach" + }, + "syntaxType": { + "dark": "latteYellow", + "light": "latteYellow" + }, + "syntaxOperator": { + "dark": "latteSky", + "light": "latteSky" + }, + "syntaxPunctuation": { + "dark": "latteText", + "light": "latteText" + } + } +} diff --git a/src/lib/themes/catppuccin-mocha.json b/src/lib/themes/catppuccin-mocha.json new file mode 100644 index 0000000..0fe9292 --- /dev/null +++ b/src/lib/themes/catppuccin-mocha.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "mochaRosewater": "#f5e0dc", + "mochaFlamingo": "#f2cdcd", + "mochaPink": "#f5c2e7", + "mochaMauve": "#cba6f7", + "mochaRed": "#f38ba8", + "mochaMaroon": "#eba0ac", + "mochaPeach": "#fab387", + "mochaYellow": "#f9e2af", + "mochaGreen": "#a6e3a1", + "mochaTeal": "#94e2d5", + "mochaSky": "#89dceb", + "mochaSapphire": "#74c7ec", + "mochaBlue": "#89b4fa", + "mochaLavender": "#b4befe", + "mochaText": "#cdd6f4", + "mochaSubtext1": "#bac2de", + "mochaSubtext0": "#a6adc8", + "mochaOverlay2": "#9399b2", + "mochaOverlay1": "#7f849c", + "mochaOverlay0": "#6c7086", + "mochaSurface2": "#585b70", + "mochaSurface1": "#45475a", + "mochaSurface0": "#313244", + "mochaBase": "#1e1e2e", + "mochaMantle": "#181825", + "mochaCrust": "#11111b" + }, + "theme": { + "primary": { + "dark": "mochaBlue", + "light": "mochaBlue" + }, + "secondary": { + "dark": "mochaMauve", + "light": "mochaMauve" + }, + "accent": { + "dark": "mochaPink", + "light": "mochaPink" + }, + "error": { + "dark": "mochaRed", + "light": "mochaRed" + }, + "warning": { + "dark": "mochaYellow", + "light": "mochaYellow" + }, + "success": { + "dark": "mochaGreen", + "light": "mochaGreen" + }, + "info": { + "dark": "mochaTeal", + "light": "mochaTeal" + }, + "text": { + "dark": "mochaText", + "light": "mochaText" + }, + "textMuted": { + "dark": "mochaSubtext1", + "light": "mochaSubtext1" + }, + "background": { + "dark": "mochaBase", + "light": "mochaBase" + }, + "backgroundPanel": { + "dark": "mochaMantle", + "light": "mochaMantle" + }, + "backgroundElement": { + "dark": "mochaCrust", + "light": "mochaCrust" + }, + "border": { + "dark": "mochaSurface0", + "light": "mochaSurface0" + }, + "borderActive": { + "dark": "mochaSurface1", + "light": "mochaSurface1" + }, + "borderSubtle": { + "dark": "mochaSurface2", + "light": "mochaSurface2" + }, + "diffAdded": { + "dark": "mochaGreen", + "light": "mochaGreen" + }, + "diffRemoved": { + "dark": "mochaRed", + "light": "mochaRed" + }, + "diffContext": { + "dark": "mochaOverlay2", + "light": "mochaOverlay2" + }, + "diffHunkHeader": { + "dark": "mochaPeach", + "light": "mochaPeach" + }, + "diffHighlightAdded": { + "dark": "mochaGreen", + "light": "mochaGreen" + }, + "diffHighlightRemoved": { + "dark": "mochaRed", + "light": "mochaRed" + }, + "diffAddedBg": { + "dark": "#1e3627", + "light": "#1e3627" + }, + "diffRemovedBg": { + "dark": "#3b1d24", + "light": "#3b1d24" + }, + "diffContextBg": { + "dark": "mochaMantle", + "light": "mochaMantle" + }, + "diffLineNumber": { + "dark": "mochaSurface1", + "light": "mochaSurface1" + }, + "diffAddedLineNumberBg": { + "dark": "#1a2e22", + "light": "#1a2e22" + }, + "diffRemovedLineNumberBg": { + "dark": "#2f1a1f", + "light": "#2f1a1f" + }, + "markdownText": { + "dark": "mochaText", + "light": "mochaText" + }, + "markdownHeading": { + "dark": "mochaMauve", + "light": "mochaMauve" + }, + "markdownLink": { + "dark": "mochaBlue", + "light": "mochaBlue" + }, + "markdownLinkText": { + "dark": "mochaSky", + "light": "mochaSky" + }, + "markdownCode": { + "dark": "mochaGreen", + "light": "mochaGreen" + }, + "markdownBlockQuote": { + "dark": "mochaYellow", + "light": "mochaYellow" + }, + "markdownEmph": { + "dark": "mochaYellow", + "light": "mochaYellow" + }, + "markdownStrong": { + "dark": "mochaPeach", + "light": "mochaPeach" + }, + "markdownHorizontalRule": { + "dark": "mochaSubtext0", + "light": "mochaSubtext0" + }, + "markdownListItem": { + "dark": "mochaBlue", + "light": "mochaBlue" + }, + "markdownListEnumeration": { + "dark": "mochaSky", + "light": "mochaSky" + }, + "markdownImage": { + "dark": "mochaBlue", + "light": "mochaBlue" + }, + "markdownImageText": { + "dark": "mochaSky", + "light": "mochaSky" + }, + "markdownCodeBlock": { + "dark": "mochaText", + "light": "mochaText" + }, + "syntaxComment": { + "dark": "mochaOverlay2", + "light": "mochaOverlay2" + }, + "syntaxKeyword": { + "dark": "mochaMauve", + "light": "mochaMauve" + }, + "syntaxFunction": { + "dark": "mochaBlue", + "light": "mochaBlue" + }, + "syntaxVariable": { + "dark": "mochaRed", + "light": "mochaRed" + }, + "syntaxString": { + "dark": "mochaGreen", + "light": "mochaGreen" + }, + "syntaxNumber": { + "dark": "mochaPeach", + "light": "mochaPeach" + }, + "syntaxType": { + "dark": "mochaYellow", + "light": "mochaYellow" + }, + "syntaxOperator": { + "dark": "mochaSky", + "light": "mochaSky" + }, + "syntaxPunctuation": { + "dark": "mochaText", + "light": "mochaText" + } + } +} diff --git a/src/lib/themes/cobalt2.json b/src/lib/themes/cobalt2.json new file mode 100644 index 0000000..6325ff4 --- /dev/null +++ b/src/lib/themes/cobalt2.json @@ -0,0 +1,72 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#193549", + "darkBgPanel": "#122738", + "darkBgSidebar": "#15232d", + "darkBorder": "#0d3a58", + "darkFgMuted": "#aaaaaa", + "darkFg": "#ffffff", + "yellow": "#ffc600", + "orange": "#ff9d00", + "blue": "#0088ff", + "cyan": "#9effff", + "pink": "#ff628c", + "green": "#3ad900", + "greenLight": "#a5ff90", + "red": "#A22929", + "magenta": "#fb94ff" + }, + "theme": { + "primary": "yellow", + "secondary": "blue", + "accent": "yellow", + "error": "red", + "warning": "orange", + "success": "green", + "info": "blue", + "text": "darkFg", + "textMuted": "darkFgMuted", + "background": "darkBg", + "backgroundPanel": "darkBgPanel", + "backgroundElement": "darkBgSidebar", + "border": "darkBorder", + "borderActive": "yellow", + "borderSubtle": "darkBorder", + "diffAdded": "green", + "diffRemoved": "pink", + "diffContext": "darkFgMuted", + "diffHunkHeader": "darkFgMuted", + "diffHighlightAdded": "greenLight", + "diffHighlightRemoved": "pink", + "diffAddedBg": "#2F7366", + "diffRemovedBg": "#3f191a", + "diffContextBg": "darkBgPanel", + "diffLineNumber": "darkBorder", + "diffAddedLineNumberBg": "#1a3d2e", + "diffRemovedLineNumberBg": "#3f191a", + "markdownText": "darkFg", + "markdownHeading": "yellow", + "markdownLink": "blue", + "markdownLinkText": "cyan", + "markdownCode": "greenLight", + "markdownBlockQuote": "cyan", + "markdownEmph": "orange", + "markdownStrong": "yellow", + "markdownHorizontalRule": "darkFgMuted", + "markdownListItem": "yellow", + "markdownListEnumeration": "yellow", + "markdownImage": "blue", + "markdownImageText": "cyan", + "markdownCodeBlock": "darkFg", + "syntaxComment": "blue", + "syntaxKeyword": "orange", + "syntaxFunction": "yellow", + "syntaxVariable": "cyan", + "syntaxString": "greenLight", + "syntaxNumber": "pink", + "syntaxType": "magenta", + "syntaxOperator": "orange", + "syntaxPunctuation": "darkFg" + } +} diff --git a/src/lib/themes/cursor.json b/src/lib/themes/cursor.json new file mode 100644 index 0000000..ab518db --- /dev/null +++ b/src/lib/themes/cursor.json @@ -0,0 +1,249 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#181818", + "darkPanel": "#141414", + "darkElement": "#262626", + "darkFg": "#e4e4e4", + "darkMuted": "#e4e4e45e", + "darkBorder": "#e4e4e413", + "darkBorderActive": "#e4e4e426", + "darkCyan": "#88c0d0", + "darkBlue": "#81a1c1", + "darkGreen": "#3fa266", + "darkGreenBright": "#70b489", + "darkRed": "#e34671", + "darkRedBright": "#fc6b83", + "darkYellow": "#f1b467", + "darkOrange": "#d2943e", + "darkPink": "#E394DC", + "darkPurple": "#AAA0FA", + "darkTeal": "#82D2CE", + "darkSyntaxYellow": "#F8C762", + "darkSyntaxOrange": "#EFB080", + "darkSyntaxGreen": "#A8CC7C", + "darkSyntaxBlue": "#87C3FF", + "lightBg": "#fcfcfc", + "lightPanel": "#f3f3f3", + "lightElement": "#ededed", + "lightFg": "#141414", + "lightMuted": "#141414ad", + "lightBorder": "#14141413", + "lightBorderActive": "#14141426", + "lightTeal": "#6f9ba6", + "lightBlue": "#3c7cab", + "lightBlueDark": "#206595", + "lightGreen": "#1f8a65", + "lightGreenBright": "#55a583", + "lightRed": "#cf2d56", + "lightRedBright": "#e75e78", + "lightOrange": "#db704b", + "lightYellow": "#c08532", + "lightPurple": "#9e94d5", + "lightPurpleDark": "#6049b3", + "lightPink": "#b8448b", + "lightMagenta": "#b3003f" + }, + "theme": { + "primary": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "secondary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "accent": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkCyan", + "light": "lightTeal" + }, + "borderSubtle": { + "dark": "#0f0f0f", + "light": "#e0e0e0" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreenBright", + "light": "lightGreenBright" + }, + "diffHighlightRemoved": { + "dark": "darkRedBright", + "light": "lightRedBright" + }, + "diffAddedBg": { + "dark": "#3fa26633", + "light": "#1f8a651f" + }, + "diffRemovedBg": { + "dark": "#b8004933", + "light": "#cf2d5614" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#e4e4e442", + "light": "#1414147a" + }, + "diffAddedLineNumberBg": { + "dark": "#3fa26633", + "light": "#1f8a651f" + }, + "diffRemovedLineNumberBg": { + "dark": "#b8004933", + "light": "#cf2d5614" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkPurple", + "light": "lightBlueDark" + }, + "markdownLink": { + "dark": "darkTeal", + "light": "lightBlueDark" + }, + "markdownLinkText": { + "dark": "darkBlue", + "light": "lightMuted" + }, + "markdownCode": { + "dark": "darkPink", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "markdownEmph": { + "dark": "darkTeal", + "light": "lightFg" + }, + "markdownStrong": { + "dark": "darkSyntaxYellow", + "light": "lightFg" + }, + "markdownHorizontalRule": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "markdownListItem": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightMuted" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightBlueDark" + }, + "markdownImageText": { + "dark": "darkBlue", + "light": "lightMuted" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "syntaxKeyword": { + "dark": "darkTeal", + "light": "lightMagenta" + }, + "syntaxFunction": { + "dark": "darkSyntaxOrange", + "light": "lightOrange" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkPink", + "light": "lightPurple" + }, + "syntaxNumber": { + "dark": "darkSyntaxYellow", + "light": "lightPink" + }, + "syntaxType": { + "dark": "darkSyntaxOrange", + "light": "lightBlueDark" + }, + "syntaxOperator": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/dracula.json b/src/lib/themes/dracula.json new file mode 100644 index 0000000..1921d2c --- /dev/null +++ b/src/lib/themes/dracula.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#14151f", + "darkPanel": "#181926", + "darkElement": "#191a26", + "darkFg": "#f8f8f2", + "darkMuted": "#b6b9e4", + "darkBorder": "#2d2f3c", + "darkBorderActive": "#4a4d6d", + "darkPurple": "#bd93f9", + "darkPink": "#ff79c6", + "darkCyan": "#8be9fd", + "darkGreen": "#50fa7b", + "darkRed": "#ff5555", + "darkOrange": "#ffb86c", + "darkYellow": "#f1fa8c", + "lightBg": "#f8f8f2", + "lightPanel": "#f1f2ed", + "lightElement": "#f2f2ec", + "lightFg": "#1f1f2f", + "lightMuted": "#52526b", + "lightBorder": "#c4c6ba", + "lightBorderActive": "#b0b2a7", + "lightPurple": "#7c6bf5", + "lightPink": "#d16090", + "lightCyan": "#1d7fc5", + "lightGreen": "#2fbf71", + "lightRed": "#d9536f", + "lightOrange": "#f7a14d" + }, + "theme": { + "primary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "secondary": { + "dark": "darkPink", + "light": "lightPink" + }, + "accent": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "borderSubtle": { + "dark": "#0f0f18", + "light": "#e2e3da" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#1f2a2f", + "light": "#e4f5e6" + }, + "diffRemovedBg": { + "dark": "#2d1f27", + "light": "#fae4eb" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#606488", + "light": "#9fa293" + }, + "diffAddedLineNumberBg": { + "dark": "#1f2a2f", + "light": "#e4f5e6" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f27", + "light": "#fae4eb" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownLink": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownEmph": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownStrong": { + "dark": "darkPink", + "light": "lightPink" + }, + "markdownHorizontalRule": { + "dark": "#44475a", + "light": "#c3c5d4" + }, + "markdownListItem": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "syntaxKeyword": { + "dark": "darkPink", + "light": "lightPink" + }, + "syntaxFunction": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxType": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxOperator": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/everforest.json b/src/lib/themes/everforest.json new file mode 100644 index 0000000..62dfb31 --- /dev/null +++ b/src/lib/themes/everforest.json @@ -0,0 +1,241 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#2d353b", + "darkStep2": "#333c43", + "darkStep3": "#343f44", + "darkStep4": "#3d484d", + "darkStep5": "#475258", + "darkStep6": "#7a8478", + "darkStep7": "#859289", + "darkStep8": "#9da9a0", + "darkStep9": "#a7c080", + "darkStep10": "#83c092", + "darkStep11": "#7a8478", + "darkStep12": "#d3c6aa", + "darkRed": "#e67e80", + "darkOrange": "#e69875", + "darkGreen": "#a7c080", + "darkCyan": "#83c092", + "darkYellow": "#dbbc7f", + "lightStep1": "#fdf6e3", + "lightStep2": "#efebd4", + "lightStep3": "#f4f0d9", + "lightStep4": "#efebd4", + "lightStep5": "#e6e2cc", + "lightStep6": "#a6b0a0", + "lightStep7": "#939f91", + "lightStep8": "#829181", + "lightStep9": "#8da101", + "lightStep10": "#35a77c", + "lightStep11": "#a6b0a0", + "lightStep12": "#5c6a72", + "lightRed": "#f85552", + "lightOrange": "#f57d26", + "lightGreen": "#8da101", + "lightCyan": "#35a77c", + "lightYellow": "#dfa000" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "#7fbbb3", + "light": "#3a94c5" + }, + "accent": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "#d699b6", + "light": "#df69ba" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/lib/themes/flexoki.json b/src/lib/themes/flexoki.json new file mode 100644 index 0000000..e525705 --- /dev/null +++ b/src/lib/themes/flexoki.json @@ -0,0 +1,237 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "black": "#100F0F", + "base950": "#1C1B1A", + "base900": "#282726", + "base850": "#343331", + "base800": "#403E3C", + "base700": "#575653", + "base600": "#6F6E69", + "base500": "#878580", + "base300": "#B7B5AC", + "base200": "#CECDC3", + "base150": "#DAD8CE", + "base100": "#E6E4D9", + "base50": "#F2F0E5", + "paper": "#FFFCF0", + "red400": "#D14D41", + "red600": "#AF3029", + "orange400": "#DA702C", + "orange600": "#BC5215", + "yellow400": "#D0A215", + "yellow600": "#AD8301", + "green400": "#879A39", + "green600": "#66800B", + "cyan400": "#3AA99F", + "cyan600": "#24837B", + "blue400": "#4385BE", + "blue600": "#205EA6", + "purple400": "#8B7EC8", + "purple600": "#5E409D", + "magenta400": "#CE5D97", + "magenta600": "#A02F6F" + }, + "theme": { + "primary": { + "dark": "orange400", + "light": "blue600" + }, + "secondary": { + "dark": "blue400", + "light": "purple600" + }, + "accent": { + "dark": "purple400", + "light": "orange600" + }, + "error": { + "dark": "red400", + "light": "red600" + }, + "warning": { + "dark": "orange400", + "light": "orange600" + }, + "success": { + "dark": "green400", + "light": "green600" + }, + "info": { + "dark": "cyan400", + "light": "cyan600" + }, + "text": { + "dark": "base200", + "light": "black" + }, + "textMuted": { + "dark": "base600", + "light": "base600" + }, + "background": { + "dark": "black", + "light": "paper" + }, + "backgroundPanel": { + "dark": "base950", + "light": "base50" + }, + "backgroundElement": { + "dark": "base900", + "light": "base100" + }, + "border": { + "dark": "base700", + "light": "base300" + }, + "borderActive": { + "dark": "base600", + "light": "base500" + }, + "borderSubtle": { + "dark": "base800", + "light": "base200" + }, + "diffAdded": { + "dark": "green400", + "light": "green600" + }, + "diffRemoved": { + "dark": "red400", + "light": "red600" + }, + "diffContext": { + "dark": "base600", + "light": "base600" + }, + "diffHunkHeader": { + "dark": "blue400", + "light": "blue600" + }, + "diffHighlightAdded": { + "dark": "green400", + "light": "green600" + }, + "diffHighlightRemoved": { + "dark": "red400", + "light": "red600" + }, + "diffAddedBg": { + "dark": "#1A2D1A", + "light": "#D5E5D5" + }, + "diffRemovedBg": { + "dark": "#2D1A1A", + "light": "#F7D8DB" + }, + "diffContextBg": { + "dark": "base950", + "light": "base50" + }, + "diffLineNumber": { + "dark": "base600", + "light": "base600" + }, + "diffAddedLineNumberBg": { + "dark": "#152515", + "light": "#C5D5C5" + }, + "diffRemovedLineNumberBg": { + "dark": "#251515", + "light": "#E7C8CB" + }, + "markdownText": { + "dark": "base200", + "light": "black" + }, + "markdownHeading": { + "dark": "purple400", + "light": "purple600" + }, + "markdownLink": { + "dark": "blue400", + "light": "blue600" + }, + "markdownLinkText": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownCode": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownBlockQuote": { + "dark": "yellow400", + "light": "yellow600" + }, + "markdownEmph": { + "dark": "yellow400", + "light": "yellow600" + }, + "markdownStrong": { + "dark": "orange400", + "light": "orange600" + }, + "markdownHorizontalRule": { + "dark": "base600", + "light": "base600" + }, + "markdownListItem": { + "dark": "orange400", + "light": "orange600" + }, + "markdownListEnumeration": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownImage": { + "dark": "magenta400", + "light": "magenta600" + }, + "markdownImageText": { + "dark": "cyan400", + "light": "cyan600" + }, + "markdownCodeBlock": { + "dark": "base200", + "light": "black" + }, + "syntaxComment": { + "dark": "base600", + "light": "base600" + }, + "syntaxKeyword": { + "dark": "green400", + "light": "green600" + }, + "syntaxFunction": { + "dark": "orange400", + "light": "orange600" + }, + "syntaxVariable": { + "dark": "blue400", + "light": "blue600" + }, + "syntaxString": { + "dark": "cyan400", + "light": "cyan600" + }, + "syntaxNumber": { + "dark": "purple400", + "light": "purple600" + }, + "syntaxType": { + "dark": "yellow400", + "light": "yellow600" + }, + "syntaxOperator": { + "dark": "base300", + "light": "base600" + }, + "syntaxPunctuation": { + "dark": "base300", + "light": "base600" + } + } +} diff --git a/src/lib/themes/github.json b/src/lib/themes/github.json new file mode 100644 index 0000000..99a8087 --- /dev/null +++ b/src/lib/themes/github.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#0d1117", + "darkBgAlt": "#010409", + "darkBgPanel": "#161b22", + "darkFg": "#c9d1d9", + "darkFgMuted": "#8b949e", + "darkBlue": "#58a6ff", + "darkGreen": "#3fb950", + "darkRed": "#f85149", + "darkOrange": "#d29922", + "darkPurple": "#bc8cff", + "darkPink": "#ff7b72", + "darkYellow": "#e3b341", + "darkCyan": "#39c5cf", + "lightBg": "#ffffff", + "lightBgAlt": "#f6f8fa", + "lightBgPanel": "#f0f3f6", + "lightFg": "#24292f", + "lightFgMuted": "#57606a", + "lightBlue": "#0969da", + "lightGreen": "#1a7f37", + "lightRed": "#cf222e", + "lightOrange": "#bc4c00", + "lightPurple": "#8250df", + "lightPink": "#bf3989", + "lightYellow": "#9a6700", + "lightCyan": "#1b7c83" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#30363d", + "light": "#d0d7de" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#21262d", + "light": "#d8dee4" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffHighlightAdded": { + "dark": "#3fb950", + "light": "#1a7f37" + }, + "diffHighlightRemoved": { + "dark": "#f85149", + "light": "#cf222e" + }, + "diffAddedBg": { + "dark": "#033a16", + "light": "#dafbe1" + }, + "diffRemovedBg": { + "dark": "#67060c", + "light": "#ffebe9" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#484f58", + "light": "#afb8c1" + }, + "diffAddedLineNumberBg": { + "dark": "#033a16", + "light": "#dafbe1" + }, + "diffRemovedLineNumberBg": { + "dark": "#67060c", + "light": "#ffebe9" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkPink", + "light": "lightPink" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#30363d", + "light": "#d0d7de" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkPink", + "light": "lightRed" + }, + "syntaxFunction": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxVariable": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxString": { + "dark": "darkCyan", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "darkBlue", + "light": "lightCyan" + }, + "syntaxType": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxOperator": { + "dark": "darkPink", + "light": "lightRed" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/gruvbox.json b/src/lib/themes/gruvbox.json new file mode 100644 index 0000000..c3101b5 --- /dev/null +++ b/src/lib/themes/gruvbox.json @@ -0,0 +1,95 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg0": "#282828", + "darkBg1": "#3c3836", + "darkBg2": "#504945", + "darkBg3": "#665c54", + "darkFg0": "#fbf1c7", + "darkFg1": "#ebdbb2", + "darkGray": "#928374", + "darkRed": "#cc241d", + "darkGreen": "#98971a", + "darkYellow": "#d79921", + "darkBlue": "#458588", + "darkPurple": "#b16286", + "darkAqua": "#689d6a", + "darkOrange": "#d65d0e", + "darkRedBright": "#fb4934", + "darkGreenBright": "#b8bb26", + "darkYellowBright": "#fabd2f", + "darkBlueBright": "#83a598", + "darkPurpleBright": "#d3869b", + "darkAquaBright": "#8ec07c", + "darkOrangeBright": "#fe8019", + "lightBg0": "#fbf1c7", + "lightBg1": "#ebdbb2", + "lightBg2": "#d5c4a1", + "lightBg3": "#bdae93", + "lightFg0": "#282828", + "lightFg1": "#3c3836", + "lightGray": "#7c6f64", + "lightRed": "#9d0006", + "lightGreen": "#79740e", + "lightYellow": "#b57614", + "lightBlue": "#076678", + "lightPurple": "#8f3f71", + "lightAqua": "#427b58", + "lightOrange": "#af3a03" + }, + "theme": { + "primary": { "dark": "darkBlueBright", "light": "lightBlue" }, + "secondary": { "dark": "darkPurpleBright", "light": "lightPurple" }, + "accent": { "dark": "darkAquaBright", "light": "lightAqua" }, + "error": { "dark": "darkRedBright", "light": "lightRed" }, + "warning": { "dark": "darkOrangeBright", "light": "lightOrange" }, + "success": { "dark": "darkGreenBright", "light": "lightGreen" }, + "info": { "dark": "darkYellowBright", "light": "lightYellow" }, + "text": { "dark": "darkFg1", "light": "lightFg1" }, + "textMuted": { "dark": "darkGray", "light": "lightGray" }, + "background": { "dark": "darkBg0", "light": "lightBg0" }, + "backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" }, + "backgroundElement": { "dark": "darkBg2", "light": "lightBg2" }, + "border": { "dark": "darkBg3", "light": "lightBg3" }, + "borderActive": { "dark": "darkFg1", "light": "lightFg1" }, + "borderSubtle": { "dark": "darkBg2", "light": "lightBg2" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkGray", "light": "lightGray" }, + "diffHunkHeader": { "dark": "darkAqua", "light": "lightAqua" }, + "diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" }, + "diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" }, + "diffAddedBg": { "dark": "#32302f", "light": "#e2e0b5" }, + "diffRemovedBg": { "dark": "#322929", "light": "#e9d8d5" }, + "diffContextBg": { "dark": "darkBg1", "light": "lightBg1" }, + "diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" }, + "diffAddedLineNumberBg": { "dark": "#2a2827", "light": "#d4d2a9" }, + "diffRemovedLineNumberBg": { "dark": "#2a2222", "light": "#d8cbc8" }, + "markdownText": { "dark": "darkFg1", "light": "lightFg1" }, + "markdownHeading": { "dark": "darkBlueBright", "light": "lightBlue" }, + "markdownLink": { "dark": "darkAquaBright", "light": "lightAqua" }, + "markdownLinkText": { "dark": "darkGreenBright", "light": "lightGreen" }, + "markdownCode": { "dark": "darkYellowBright", "light": "lightYellow" }, + "markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" }, + "markdownEmph": { "dark": "darkPurpleBright", "light": "lightPurple" }, + "markdownStrong": { "dark": "darkOrangeBright", "light": "lightOrange" }, + "markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" }, + "markdownListItem": { "dark": "darkBlueBright", "light": "lightBlue" }, + "markdownListEnumeration": { + "dark": "darkAquaBright", + "light": "lightAqua" + }, + "markdownImage": { "dark": "darkAquaBright", "light": "lightAqua" }, + "markdownImageText": { "dark": "darkGreenBright", "light": "lightGreen" }, + "markdownCodeBlock": { "dark": "darkFg1", "light": "lightFg1" }, + "syntaxComment": { "dark": "darkGray", "light": "lightGray" }, + "syntaxKeyword": { "dark": "darkRedBright", "light": "lightRed" }, + "syntaxFunction": { "dark": "darkGreenBright", "light": "lightGreen" }, + "syntaxVariable": { "dark": "darkBlueBright", "light": "lightBlue" }, + "syntaxString": { "dark": "darkYellowBright", "light": "lightYellow" }, + "syntaxNumber": { "dark": "darkPurpleBright", "light": "lightPurple" }, + "syntaxType": { "dark": "darkAquaBright", "light": "lightAqua" }, + "syntaxOperator": { "dark": "darkOrangeBright", "light": "lightOrange" }, + "syntaxPunctuation": { "dark": "darkFg1", "light": "lightFg1" } + } +} diff --git a/src/lib/themes/index.ts b/src/lib/themes/index.ts new file mode 100644 index 0000000..02f221b --- /dev/null +++ b/src/lib/themes/index.ts @@ -0,0 +1,85 @@ +import aura from "./aura.json"; +import ayu from "./ayu.json"; +import catppuccinFrappe from "./catppuccin-frappe.json"; +import catppuccinLatte from "./catppuccin-latte.json"; +import catppuccinMocha from "./catppuccin-mocha.json"; +import cobalt2 from "./cobalt2.json"; +import cursor from "./cursor.json"; +import dracula from "./dracula.json"; +import everforest from "./everforest.json"; +import flexoki from "./flexoki.json"; +import github from "./github.json"; +import gruvbox from "./gruvbox.json"; +import kanagawa from "./kanagawa.json"; +import lucentOrng from "./lucent-orng.json"; +import material from "./material.json"; +import matrix from "./matrix.json"; +import mercury from "./mercury.json"; +import monokai from "./monokai.json"; +import nightowl from "./nightowl.json"; +import nord from "./nord.json"; +import oneDark from "./one-dark.json"; +import opencode from "./opencode.json"; +import orng from "./orng.json"; +import osakaJade from "./osaka-jade.json"; +import palenight from "./palenight.json"; +import rosepine from "./rosepine.json"; +import solarized from "./solarized.json"; +import synthwave84 from "./synthwave84.json"; +import tokyonight from "./tokyonight.json"; +import vercel from "./vercel.json"; +import vesper from "./vesper.json"; +import zenburn from "./zenburn.json"; + +/** + * Theme color value - either a direct hex color or a reference to a def + */ +export type ThemeColorValue = string | { dark: string; light: string }; + +/** + * Theme JSON structure + */ +export interface ThemeJson { + $schema?: string; + defs: Record; + theme: Record; +} + +export const themes: Record = { + aura: aura as ThemeJson, + ayu: ayu as ThemeJson, + "catppuccin-frappe": catppuccinFrappe as ThemeJson, + "catppuccin-latte": catppuccinLatte as ThemeJson, + "catppuccin-mocha": catppuccinMocha as ThemeJson, + cobalt2: cobalt2 as ThemeJson, + cursor: cursor as ThemeJson, + dracula: dracula as ThemeJson, + everforest: everforest as ThemeJson, + flexoki: flexoki as ThemeJson, + github: github as ThemeJson, + gruvbox: gruvbox as ThemeJson, + kanagawa: kanagawa as ThemeJson, + "lucent-orng": lucentOrng as ThemeJson, + material: material as ThemeJson, + matrix: matrix as ThemeJson, + mercury: mercury as ThemeJson, + monokai: monokai as ThemeJson, + nightowl: nightowl as ThemeJson, + nord: nord as ThemeJson, + "one-dark": oneDark as ThemeJson, + opencode: opencode as ThemeJson, + orng: orng as ThemeJson, + "osaka-jade": osakaJade as ThemeJson, + palenight: palenight as ThemeJson, + rosepine: rosepine as ThemeJson, + solarized: solarized as ThemeJson, + synthwave84: synthwave84 as ThemeJson, + tokyonight: tokyonight as ThemeJson, + vercel: vercel as ThemeJson, + vesper: vesper as ThemeJson, + zenburn: zenburn as ThemeJson, +}; + +export const themeNames = Object.keys(themes); + +export const defaultTheme = "opencode"; diff --git a/src/lib/themes/kanagawa.json b/src/lib/themes/kanagawa.json new file mode 100644 index 0000000..0f968c6 --- /dev/null +++ b/src/lib/themes/kanagawa.json @@ -0,0 +1,115 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "sumiInk0": "#16161D", + "sumiInk1": "#1F1F28", + "sumiInk2": "#2A2A37", + "sumiInk3": "#363646", + "sumiInk4": "#54546D", + "waveBlue1": "#223249", + "waveBlue2": "#2D4F67", + "winterGreen": "#2B3328", + "winterRed": "#43242B", + "fujiWhite": "#DCD7BA", + "oldWhite": "#C8C093", + "fujiGray": "#727169", + "springViolet1": "#938AA9", + "oniViolet": "#957FB8", + "crystalBlue": "#7E9CD8", + "springViolet2": "#9CABCA", + "springBlue": "#7FB4CA", + "waveAqua2": "#7AA89F", + "springGreen": "#98BB6C", + "boatYellow2": "#C0A36E", + "carpYellow": "#E6C384", + "sakuraPink": "#D27E99", + "waveRed": "#E46876", + "peachRed": "#FF5D62", + "surimiOrange": "#FFA066", + "samuraiRed": "#E82424", + "roninYellow": "#FF9E3B", + "autumnGreen": "#76946A", + "autumnRed": "#C34043", + "autumnYellow": "#DCA561", + "dragonBlue": "#658594", + "lotusInk1": "#545464", + "lotusInk2": "#43436c", + "lotusGray2": "#716e61", + "lotusGray3": "#8a8980", + "lotusWhite1": "#dcd5ac", + "lotusWhite2": "#e5ddb0", + "lotusWhite3": "#f2ecbc", + "lotusViolet1": "#a09cac", + "lotusViolet2": "#766b90", + "lotusViolet4": "#624c83", + "lotusBlue3": "#9fb5c9", + "lotusBlue4": "#4d699b", + "lotusBlue5": "#5d57a3", + "lotusGreen": "#6f894e", + "lotusGreen2": "#6e915f", + "lotusGreen3": "#b7d0ae", + "lotusPink": "#b35b79", + "lotusOrange": "#cc6d00", + "lotusOrange2": "#e98a00", + "lotusYellow": "#77713f", + "lotusRed": "#c84053", + "lotusRed2": "#d7474b", + "lotusRed3": "#e82424", + "lotusAqua": "#597b75", + "lotusAqua2": "#5e857a", + "lotusTeal1": "#4e8ca2", + "lotusTeal2": "#6693bf" + }, + "theme": { + "primary": { "dark": "crystalBlue", "light": "lotusBlue4" }, + "secondary": { "dark": "oniViolet", "light": "lotusViolet4" }, + "accent": { "dark": "springGreen", "light": "lotusGreen" }, + "error": { "dark": "peachRed", "light": "lotusRed" }, + "warning": { "dark": "roninYellow", "light": "lotusOrange2" }, + "success": { "dark": "springGreen", "light": "lotusGreen2" }, + "info": { "dark": "springBlue", "light": "lotusTeal1" }, + "text": { "dark": "fujiWhite", "light": "lotusInk1" }, + "textMuted": { "dark": "fujiGray", "light": "lotusGray3" }, + "background": { "dark": "sumiInk1", "light": "lotusWhite3" }, + "backgroundPanel": { "dark": "sumiInk2", "light": "lotusWhite2" }, + "backgroundElement": { "dark": "sumiInk3", "light": "lotusWhite1" }, + "border": { "dark": "sumiInk4", "light": "lotusGray2" }, + "borderActive": { "dark": "crystalBlue", "light": "lotusBlue4" }, + "borderSubtle": { "dark": "sumiInk3", "light": "lotusWhite1" }, + "diffAdded": { "dark": "autumnGreen", "light": "lotusGreen" }, + "diffRemoved": { "dark": "autumnRed", "light": "lotusRed" }, + "diffContext": { "dark": "fujiGray", "light": "lotusGray3" }, + "diffHunkHeader": { "dark": "waveAqua2", "light": "lotusAqua" }, + "diffHighlightAdded": { "dark": "springGreen", "light": "lotusGreen2" }, + "diffHighlightRemoved": { "dark": "peachRed", "light": "lotusRed2" }, + "diffAddedBg": { "dark": "winterGreen", "light": "lotusGreen3" }, + "diffRemovedBg": { "dark": "winterRed", "light": "#e9d8d5" }, + "diffContextBg": { "dark": "sumiInk2", "light": "lotusWhite2" }, + "diffLineNumber": { "dark": "sumiInk4", "light": "lotusGray2" }, + "diffAddedLineNumberBg": { "dark": "#252b25", "light": "#c8dfc1" }, + "diffRemovedLineNumberBg": { "dark": "#352226", "light": "#d8c8c5" }, + "markdownText": { "dark": "fujiWhite", "light": "lotusInk1" }, + "markdownHeading": { "dark": "crystalBlue", "light": "lotusBlue4" }, + "markdownLink": { "dark": "springBlue", "light": "lotusTeal1" }, + "markdownLinkText": { "dark": "springGreen", "light": "lotusGreen" }, + "markdownCode": { "dark": "carpYellow", "light": "lotusYellow" }, + "markdownBlockQuote": { "dark": "fujiGray", "light": "lotusGray3" }, + "markdownEmph": { "dark": "sakuraPink", "light": "lotusPink" }, + "markdownStrong": { "dark": "surimiOrange", "light": "lotusOrange" }, + "markdownHorizontalRule": { "dark": "fujiGray", "light": "lotusGray3" }, + "markdownListItem": { "dark": "crystalBlue", "light": "lotusBlue4" }, + "markdownListEnumeration": { "dark": "springBlue", "light": "lotusTeal2" }, + "markdownImage": { "dark": "waveAqua2", "light": "lotusAqua2" }, + "markdownImageText": { "dark": "springGreen", "light": "lotusGreen" }, + "markdownCodeBlock": { "dark": "fujiWhite", "light": "lotusInk1" }, + "syntaxComment": { "dark": "fujiGray", "light": "lotusGray3" }, + "syntaxKeyword": { "dark": "oniViolet", "light": "lotusViolet4" }, + "syntaxFunction": { "dark": "crystalBlue", "light": "lotusBlue4" }, + "syntaxVariable": { "dark": "springViolet2", "light": "lotusViolet2" }, + "syntaxString": { "dark": "springGreen", "light": "lotusGreen" }, + "syntaxNumber": { "dark": "sakuraPink", "light": "lotusPink" }, + "syntaxType": { "dark": "waveAqua2", "light": "lotusAqua" }, + "syntaxOperator": { "dark": "boatYellow2", "light": "lotusYellow" }, + "syntaxPunctuation": { "dark": "oldWhite", "light": "lotusInk2" } + } +} diff --git a/src/lib/themes/lucent-orng.json b/src/lib/themes/lucent-orng.json new file mode 100644 index 0000000..3d73a30 --- /dev/null +++ b/src/lib/themes/lucent-orng.json @@ -0,0 +1,233 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep6": "#3c3c3c", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#EE7948", + "darkAccent": "#FFF7F1", + "darkRed": "#e06c75", + "darkOrange": "#EC5B2B", + "darkBlue": "#6ba1e6", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "darkPanelBg": "#2a1a1599", + "lightStep6": "#d4d4d4", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#EE7948", + "lightAccent": "#c94d24", + "lightRed": "#d1383d", + "lightOrange": "#EC5B2B", + "lightBlue": "#0062d1", + "lightCyan": "#318795", + "lightYellow": "#b0851f", + "lightPanelBg": "#fff5f099" + }, + "theme": { + "primary": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundPanel": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundElement": { + "dark": "transparent", + "light": "transparent" + }, + "backgroundMenu": { + "dark": "darkPanelBg", + "light": "lightPanelBg" + }, + "border": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "borderActive": { + "dark": "darkSecondary", + "light": "lightAccent" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffRemovedBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffContextBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffLineNumber": { + "dark": "#666666", + "light": "#999999" + }, + "diffAddedLineNumberBg": { + "dark": "transparent", + "light": "transparent" + }, + "diffRemovedLineNumberBg": { + "dark": "transparent", + "light": "transparent" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownLink": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownBlockQuote": { + "dark": "darkAccent", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkSecondary", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxFunction": { + "dark": "darkSecondary", + "light": "lightAccent" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "darkAccent", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/lib/themes/material.json b/src/lib/themes/material.json new file mode 100644 index 0000000..c3a1068 --- /dev/null +++ b/src/lib/themes/material.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#263238", + "darkBgAlt": "#1e272c", + "darkBgPanel": "#37474f", + "darkFg": "#eeffff", + "darkFgMuted": "#546e7a", + "darkRed": "#f07178", + "darkPink": "#f78c6c", + "darkOrange": "#ffcb6b", + "darkYellow": "#ffcb6b", + "darkGreen": "#c3e88d", + "darkCyan": "#89ddff", + "darkBlue": "#82aaff", + "darkPurple": "#c792ea", + "darkViolet": "#bb80b3", + "lightBg": "#fafafa", + "lightBgAlt": "#f5f5f5", + "lightBgPanel": "#e7e7e8", + "lightFg": "#263238", + "lightFgMuted": "#90a4ae", + "lightRed": "#e53935", + "lightPink": "#ec407a", + "lightOrange": "#f4511e", + "lightYellow": "#ffb300", + "lightGreen": "#91b859", + "lightCyan": "#39adb5", + "lightBlue": "#6182b8", + "lightPurple": "#7c4dff", + "lightViolet": "#945eb8" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "accent": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#37474f", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#1e272c", + "light": "#eeeeee" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#37474f", + "light": "#cfd8dc" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#37474f", + "light": "#e0e0e0" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxFunction": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/matrix.json b/src/lib/themes/matrix.json new file mode 100644 index 0000000..34b36c8 --- /dev/null +++ b/src/lib/themes/matrix.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#000000", + "darkBgAlt": "#0a0f0a", + "darkBgPanel": "#0d1a0d", + "darkFg": "#00ff41", + "darkFgMuted": "#2e5a32", + "darkRed": "#ff3030", + "darkPink": "#ff6b9d", + "darkOrange": "#7fff00", + "darkYellow": "#b5ff00", + "darkGreen": "#00ff41", + "darkCyan": "#00ffc8", + "darkBlue": "#00ff9f", + "darkPurple": "#20c997", + "darkViolet": "#17a085", + "lightBg": "#0d1a0d", + "lightBgAlt": "#142414", + "lightBgPanel": "#1a2e1a", + "lightFg": "#00ff41", + "lightFgMuted": "#3d7a42", + "lightRed": "#ff4040", + "lightPink": "#ff7bab", + "lightOrange": "#8fff00", + "lightYellow": "#c5ff10", + "lightGreen": "#00ff41", + "lightCyan": "#00ffd0", + "lightBlue": "#00ffaf", + "lightPurple": "#30d9a7", + "lightViolet": "#27b095" + }, + "theme": { + "primary": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "secondary": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "accent": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "backgroundElement": { + "dark": "darkBgPanel", + "light": "lightBgPanel" + }, + "border": { + "dark": "#0d3d0d", + "light": "#1a4d1a" + }, + "borderActive": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "borderSubtle": { + "dark": "#071407", + "light": "#102810" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "diffHunkHeader": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#0a1f0a", + "light": "#142814" + }, + "diffRemovedBg": { + "dark": "#1f0a0a", + "light": "#281414" + }, + "diffContextBg": { + "dark": "darkBgAlt", + "light": "lightBgAlt" + }, + "diffLineNumber": { + "dark": "#0d3d0d", + "light": "#1a4d1a" + }, + "diffAddedLineNumberBg": { + "dark": "#0a1f0a", + "light": "#142814" + }, + "diffRemovedLineNumberBg": { + "dark": "#1f0a0a", + "light": "#281414" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownCode": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownBlockQuote": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownEmph": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownStrong": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownHorizontalRule": { + "dark": "#0d3d0d", + "light": "#1a4d1a" + }, + "markdownListItem": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "syntaxKeyword": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxFunction": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/mercury.json b/src/lib/themes/mercury.json new file mode 100644 index 0000000..dfd4f35 --- /dev/null +++ b/src/lib/themes/mercury.json @@ -0,0 +1,252 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "purple-800": "#3442a6", + "purple-700": "#465bd1", + "purple-600": "#5266eb", + "purple-400": "#8da4f5", + "purple-300": "#a7b6f8", + + "red-700": "#b0175f", + "red-600": "#d03275", + "red-400": "#fc92b4", + + "green-700": "#036e43", + "green-600": "#188554", + "green-400": "#77c599", + + "orange-700": "#a44200", + "orange-600": "#c45000", + "orange-400": "#fc9b6f", + + "blue-600": "#007f95", + "blue-400": "#77becf", + + "neutral-1000": "#10101a", + "neutral-950": "#171721", + "neutral-900": "#1e1e2a", + "neutral-800": "#272735", + "neutral-700": "#363644", + "neutral-600": "#535461", + "neutral-500": "#70707d", + "neutral-400": "#9d9da8", + "neutral-300": "#c3c3cc", + "neutral-200": "#dddde5", + "neutral-100": "#f4f5f9", + "neutral-050": "#fbfcfd", + "neutral-000": "#ffffff", + "neutral-150": "#ededf3", + + "border-light": "#7073931a", + "border-light-subtle": "#7073930f", + "border-dark": "#b4b7c81f", + "border-dark-subtle": "#b4b7c814", + + "diff-added-light": "#1885541a", + "diff-removed-light": "#d032751a", + "diff-added-dark": "#77c59933", + "diff-removed-dark": "#fc92b433" + }, + "theme": { + "primary": { + "light": "purple-600", + "dark": "purple-400" + }, + "secondary": { + "light": "purple-700", + "dark": "purple-300" + }, + "accent": { + "light": "purple-400", + "dark": "purple-400" + }, + "error": { + "light": "red-700", + "dark": "red-400" + }, + "warning": { + "light": "orange-700", + "dark": "orange-400" + }, + "success": { + "light": "green-700", + "dark": "green-400" + }, + "info": { + "light": "blue-600", + "dark": "blue-400" + }, + "text": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "textMuted": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "background": { + "light": "neutral-000", + "dark": "neutral-950" + }, + "backgroundPanel": { + "light": "neutral-050", + "dark": "neutral-1000" + }, + "backgroundElement": { + "light": "neutral-100", + "dark": "neutral-800" + }, + "border": { + "light": "border-light", + "dark": "border-dark" + }, + "borderActive": { + "light": "purple-600", + "dark": "purple-400" + }, + "borderSubtle": { + "light": "border-light-subtle", + "dark": "border-dark-subtle" + }, + "diffAdded": { + "light": "green-700", + "dark": "green-400" + }, + "diffRemoved": { + "light": "red-700", + "dark": "red-400" + }, + "diffContext": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "diffHunkHeader": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "diffHighlightAdded": { + "light": "green-700", + "dark": "green-400" + }, + "diffHighlightRemoved": { + "light": "red-700", + "dark": "red-400" + }, + "diffAddedBg": { + "light": "diff-added-light", + "dark": "diff-added-dark" + }, + "diffRemovedBg": { + "light": "diff-removed-light", + "dark": "diff-removed-dark" + }, + "diffContextBg": { + "light": "neutral-050", + "dark": "neutral-900" + }, + "diffLineNumber": { + "light": "neutral-600", + "dark": "neutral-300" + }, + "diffAddedLineNumberBg": { + "light": "diff-added-light", + "dark": "diff-added-dark" + }, + "diffRemovedLineNumberBg": { + "light": "diff-removed-light", + "dark": "diff-removed-dark" + }, + "markdownText": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "markdownHeading": { + "light": "neutral-900", + "dark": "neutral-000" + }, + "markdownLink": { + "light": "purple-700", + "dark": "purple-400" + }, + "markdownLinkText": { + "light": "purple-600", + "dark": "purple-300" + }, + "markdownCode": { + "light": "green-700", + "dark": "green-400" + }, + "markdownBlockQuote": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "markdownEmph": { + "light": "orange-700", + "dark": "orange-400" + }, + "markdownStrong": { + "light": "neutral-900", + "dark": "neutral-100" + }, + "markdownHorizontalRule": { + "light": "border-light", + "dark": "border-dark" + }, + "markdownListItem": { + "light": "neutral-900", + "dark": "neutral-000" + }, + "markdownListEnumeration": { + "light": "purple-600", + "dark": "purple-400" + }, + "markdownImage": { + "light": "purple-700", + "dark": "purple-400" + }, + "markdownImageText": { + "light": "purple-600", + "dark": "purple-300" + }, + "markdownCodeBlock": { + "light": "neutral-700", + "dark": "neutral-200" + }, + "syntaxComment": { + "light": "neutral-500", + "dark": "neutral-400" + }, + "syntaxKeyword": { + "light": "purple-700", + "dark": "purple-400" + }, + "syntaxFunction": { + "light": "purple-600", + "dark": "purple-400" + }, + "syntaxVariable": { + "light": "blue-600", + "dark": "blue-400" + }, + "syntaxString": { + "light": "green-700", + "dark": "green-400" + }, + "syntaxNumber": { + "light": "orange-700", + "dark": "orange-400" + }, + "syntaxType": { + "light": "blue-600", + "dark": "blue-400" + }, + "syntaxOperator": { + "light": "purple-700", + "dark": "purple-400" + }, + "syntaxPunctuation": { + "light": "neutral-700", + "dark": "neutral-200" + } + } +} diff --git a/src/lib/themes/monokai.json b/src/lib/themes/monokai.json new file mode 100644 index 0000000..56bc7f3 --- /dev/null +++ b/src/lib/themes/monokai.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#272822", + "darkPanel": "#2d2e27", + "darkElement": "#3e3d32", + "darkFg": "#f8f8f2", + "darkMuted": "#75715e", + "darkBorder": "#49483e", + "darkBorderActive": "#5c5b4f", + "darkPink": "#f92672", + "darkGreen": "#a6e22e", + "darkCyan": "#66d9ef", + "darkOrange": "#fd971f", + "darkPurple": "#ae81ff", + "darkYellow": "#e6db74", + "darkRed": "#f92672", + "lightBg": "#f9f9f5", + "lightPanel": "#f0f0ec", + "lightElement": "#e8e8e4", + "lightFg": "#272822", + "lightMuted": "#75715e", + "lightBorder": "#d0d0c8", + "lightBorderActive": "#b8b8b0", + "lightPink": "#d12060", + "lightGreen": "#7ca021", + "lightCyan": "#4a9fc5", + "lightOrange": "#d07818", + "lightPurple": "#8a60d0", + "lightYellow": "#b8a830", + "lightRed": "#d12060" + }, + "theme": { + "primary": { + "dark": "darkPink", + "light": "lightPink" + }, + "secondary": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "accent": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkPink", + "light": "lightPink" + }, + "borderSubtle": { + "dark": "#1e1f1a", + "light": "#e0e0d8" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#2f3a28", + "light": "#e4f5e6" + }, + "diffRemovedBg": { + "dark": "#3a2830", + "light": "#fae4eb" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#5c5b4f", + "light": "#a0a098" + }, + "diffAddedLineNumberBg": { + "dark": "#2f3a28", + "light": "#e4f5e6" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a2830", + "light": "#fae4eb" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkPink", + "light": "lightPink" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "#49483e", + "light": "#c8c8c0" + }, + "markdownListItem": { + "dark": "darkPink", + "light": "lightPink" + }, + "markdownListEnumeration": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "syntaxKeyword": { + "dark": "darkPink", + "light": "lightPink" + }, + "syntaxFunction": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxNumber": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxType": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxOperator": { + "dark": "darkPink", + "light": "lightPink" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/nightowl.json b/src/lib/themes/nightowl.json new file mode 100644 index 0000000..24c7473 --- /dev/null +++ b/src/lib/themes/nightowl.json @@ -0,0 +1,221 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "nightOwlBg": "#011627", + "nightOwlFg": "#d6deeb", + "nightOwlBlue": "#82AAFF", + "nightOwlCyan": "#7fdbca", + "nightOwlGreen": "#c5e478", + "nightOwlYellow": "#ecc48d", + "nightOwlOrange": "#F78C6C", + "nightOwlRed": "#EF5350", + "nightOwlPink": "#ff5874", + "nightOwlPurple": "#c792ea", + "nightOwlMuted": "#5f7e97", + "nightOwlGray": "#637777", + "nightOwlLightGray": "#89a4bb", + "nightOwlPanel": "#0b253a" + }, + "theme": { + "primary": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "secondary": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "accent": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "error": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "warning": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "success": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "info": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "text": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "textMuted": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "background": { + "dark": "nightOwlBg", + "light": "nightOwlBg" + }, + "backgroundPanel": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "backgroundElement": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "border": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "borderActive": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "borderSubtle": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffAdded": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "diffRemoved": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "diffContext": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffHunkHeader": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffHighlightAdded": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "diffHighlightRemoved": { + "dark": "nightOwlRed", + "light": "nightOwlRed" + }, + "diffAddedBg": { + "dark": "#0a2e1a", + "light": "#0a2e1a" + }, + "diffRemovedBg": { + "dark": "#2d1b1b", + "light": "#2d1b1b" + }, + "diffContextBg": { + "dark": "nightOwlPanel", + "light": "nightOwlPanel" + }, + "diffLineNumber": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#0a2e1a", + "light": "#0a2e1a" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1b1b", + "light": "#2d1b1b" + }, + "markdownText": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "markdownHeading": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownLink": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownLinkText": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownCode": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "markdownBlockQuote": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "markdownEmph": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "markdownStrong": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "markdownHorizontalRule": { + "dark": "nightOwlMuted", + "light": "nightOwlMuted" + }, + "markdownListItem": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownListEnumeration": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownImage": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "markdownImageText": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "markdownCodeBlock": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "syntaxComment": { + "dark": "nightOwlGray", + "light": "nightOwlGray" + }, + "syntaxKeyword": { + "dark": "nightOwlPurple", + "light": "nightOwlPurple" + }, + "syntaxFunction": { + "dark": "nightOwlBlue", + "light": "nightOwlBlue" + }, + "syntaxVariable": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + }, + "syntaxString": { + "dark": "nightOwlYellow", + "light": "nightOwlYellow" + }, + "syntaxNumber": { + "dark": "nightOwlOrange", + "light": "nightOwlOrange" + }, + "syntaxType": { + "dark": "nightOwlGreen", + "light": "nightOwlGreen" + }, + "syntaxOperator": { + "dark": "nightOwlCyan", + "light": "nightOwlCyan" + }, + "syntaxPunctuation": { + "dark": "nightOwlFg", + "light": "nightOwlFg" + } + } +} diff --git a/src/lib/themes/nord.json b/src/lib/themes/nord.json new file mode 100644 index 0000000..3de98a3 --- /dev/null +++ b/src/lib/themes/nord.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#2e3440", + "darkPanel": "#3b4252", + "darkElement": "#434c5e", + "darkFg": "#eceff4", + "darkMuted": "#4c566a", + "darkBorder": "#4c566a", + "darkBorderActive": "#5e81ac", + "darkBlue": "#88c0d0", + "darkCyan": "#8fbcbb", + "darkGreen": "#a3be8c", + "darkYellow": "#ebcb8b", + "darkOrange": "#d08770", + "darkRed": "#bf616a", + "darkPurple": "#b48ead", + "lightBg": "#eceff4", + "lightPanel": "#e5e9f0", + "lightElement": "#d8dee9", + "lightFg": "#2e3440", + "lightMuted": "#4c566a", + "lightBorder": "#d8dee9", + "lightBorderActive": "#5e81ac", + "lightBlue": "#5e81ac", + "lightCyan": "#8fbcbb", + "lightGreen": "#a3be8c", + "lightYellow": "#d08770", + "lightOrange": "#d08770", + "lightRed": "#bf616a", + "lightPurple": "#b48ead" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "accent": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "#d8dee9", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#3b4252", + "light": "#e5e9f0" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#2e3a2e", + "light": "#e4f0e4" + }, + "diffRemovedBg": { + "dark": "#3a2e2e", + "light": "#f4e1e4" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#616e88", + "light": "#8392a8" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3a2e", + "light": "#e4f0e4" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a2e2e", + "light": "#f4e1e4" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownEmph": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownStrong": { + "dark": "darkRed", + "light": "lightRed" + }, + "markdownHorizontalRule": { + "dark": "#4c566a", + "light": "#d8dee9" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "#616e88", + "light": "#8392a8" + }, + "syntaxKeyword": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxFunction": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxPunctuation": { + "dark": "darkFg", + "light": "lightFg" + } + } +} diff --git a/src/lib/themes/one-dark.json b/src/lib/themes/one-dark.json new file mode 100644 index 0000000..73b24e9 --- /dev/null +++ b/src/lib/themes/one-dark.json @@ -0,0 +1,84 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#282c34", + "darkBgAlt": "#21252b", + "darkBgPanel": "#353b45", + "darkFg": "#abb2bf", + "darkFgMuted": "#5c6370", + "darkPurple": "#c678dd", + "darkBlue": "#61afef", + "darkRed": "#e06c75", + "darkGreen": "#98c379", + "darkYellow": "#e5c07b", + "darkOrange": "#d19a66", + "darkCyan": "#56b6c2", + "lightBg": "#fafafa", + "lightBgAlt": "#f0f0f1", + "lightBgPanel": "#eaeaeb", + "lightFg": "#383a42", + "lightFgMuted": "#a0a1a7", + "lightPurple": "#a626a4", + "lightBlue": "#4078f2", + "lightRed": "#e45649", + "lightGreen": "#50a14f", + "lightYellow": "#c18401", + "lightOrange": "#986801", + "lightCyan": "#0184bc" + }, + "theme": { + "primary": { "dark": "darkBlue", "light": "lightBlue" }, + "secondary": { "dark": "darkPurple", "light": "lightPurple" }, + "accent": { "dark": "darkCyan", "light": "lightCyan" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellow", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkOrange", "light": "lightOrange" }, + "text": { "dark": "darkFg", "light": "lightFg" }, + "textMuted": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "background": { "dark": "darkBg", "light": "lightBg" }, + "backgroundPanel": { "dark": "darkBgAlt", "light": "lightBgAlt" }, + "backgroundElement": { "dark": "darkBgPanel", "light": "lightBgPanel" }, + "border": { "dark": "#393f4a", "light": "#d1d1d2" }, + "borderActive": { "dark": "darkBlue", "light": "lightBlue" }, + "borderSubtle": { "dark": "#2c313a", "light": "#e0e0e1" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, + "diffHighlightAdded": { "dark": "#aad482", "light": "#489447" }, + "diffHighlightRemoved": { "dark": "#e8828b", "light": "#d65145" }, + "diffAddedBg": { "dark": "#2c382b", "light": "#eafbe9" }, + "diffRemovedBg": { "dark": "#3a2d2f", "light": "#fce9e8" }, + "diffContextBg": { "dark": "darkBgAlt", "light": "lightBgAlt" }, + "diffLineNumber": { "dark": "#495162", "light": "#c9c9ca" }, + "diffAddedLineNumberBg": { "dark": "#283427", "light": "#e1f3df" }, + "diffRemovedLineNumberBg": { "dark": "#36292b", "light": "#f5e2e1" }, + "markdownText": { "dark": "darkFg", "light": "lightFg" }, + "markdownHeading": { "dark": "darkPurple", "light": "lightPurple" }, + "markdownLink": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownLinkText": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownCode": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "markdownEmph": { "dark": "darkYellow", "light": "lightYellow" }, + "markdownStrong": { "dark": "darkOrange", "light": "lightOrange" }, + "markdownHorizontalRule": { + "dark": "darkFgMuted", + "light": "lightFgMuted" + }, + "markdownListItem": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownListEnumeration": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownImage": { "dark": "darkBlue", "light": "lightBlue" }, + "markdownImageText": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownCodeBlock": { "dark": "darkFg", "light": "lightFg" }, + "syntaxComment": { "dark": "darkFgMuted", "light": "lightFgMuted" }, + "syntaxKeyword": { "dark": "darkPurple", "light": "lightPurple" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkRed", "light": "lightRed" }, + "syntaxString": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkOrange", "light": "lightOrange" }, + "syntaxType": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxOperator": { "dark": "darkCyan", "light": "lightCyan" }, + "syntaxPunctuation": { "dark": "darkFg", "light": "lightFg" } + } +} diff --git a/src/lib/themes/opencode.json b/src/lib/themes/opencode.json new file mode 100644 index 0000000..8f585a4 --- /dev/null +++ b/src/lib/themes/opencode.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#fab283", + "darkStep10": "#ffc09f", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#5c9cf5", + "darkAccent": "#9d7cd8", + "darkRed": "#e06c75", + "darkOrange": "#f5a742", + "darkGreen": "#7fd88f", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#fafafa", + "lightStep3": "#f5f5f5", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#3b7dd8", + "lightStep10": "#2968c3", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#7b5bb6", + "lightAccent": "#d68c27", + "lightRed": "#d1383d", + "lightOrange": "#d68c27", + "lightGreen": "#3d9a57", + "lightCyan": "#318795", + "lightYellow": "#b0851f" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "darkStep7", + "light": "lightStep7" + }, + "borderActive": { + "dark": "darkStep8", + "light": "lightStep8" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#4fd6be", + "light": "#1e725c" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#b8db87", + "light": "#4db380" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#20303b", + "light": "#d5e5d5" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#1b2b34", + "light": "#c5d5c5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "syntaxFunction": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/lib/themes/orng.json b/src/lib/themes/orng.json new file mode 100644 index 0000000..1228f10 --- /dev/null +++ b/src/lib/themes/orng.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkStep1": "#0a0a0a", + "darkStep2": "#141414", + "darkStep3": "#1e1e1e", + "darkStep4": "#282828", + "darkStep5": "#323232", + "darkStep6": "#3c3c3c", + "darkStep7": "#484848", + "darkStep8": "#606060", + "darkStep9": "#EC5B2B", + "darkStep10": "#EE7948", + "darkStep11": "#808080", + "darkStep12": "#eeeeee", + "darkSecondary": "#EE7948", + "darkAccent": "#FFF7F1", + "darkRed": "#e06c75", + "darkOrange": "#EC5B2B", + "darkBlue": "#6ba1e6", + "darkCyan": "#56b6c2", + "darkYellow": "#e5c07b", + "lightStep1": "#ffffff", + "lightStep2": "#FFF7F1", + "lightStep3": "#f5f0eb", + "lightStep4": "#ebebeb", + "lightStep5": "#e1e1e1", + "lightStep6": "#d4d4d4", + "lightStep7": "#b8b8b8", + "lightStep8": "#a0a0a0", + "lightStep9": "#EC5B2B", + "lightStep10": "#c94d24", + "lightStep11": "#8a8a8a", + "lightStep12": "#1a1a1a", + "lightSecondary": "#EE7948", + "lightAccent": "#c94d24", + "lightRed": "#d1383d", + "lightOrange": "#EC5B2B", + "lightBlue": "#0062d1", + "lightCyan": "#318795", + "lightYellow": "#b0851f" + }, + "theme": { + "primary": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "secondary": { + "dark": "darkSecondary", + "light": "lightSecondary" + }, + "accent": { + "dark": "darkAccent", + "light": "lightAccent" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "info": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "text": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "textMuted": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "background": { + "dark": "darkStep1", + "light": "lightStep1" + }, + "backgroundPanel": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "backgroundElement": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "border": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "borderActive": { + "dark": "#EE7948", + "light": "#c94d24" + }, + "borderSubtle": { + "dark": "darkStep6", + "light": "lightStep6" + }, + "diffAdded": { + "dark": "#6ba1e6", + "light": "#0062d1" + }, + "diffRemoved": { + "dark": "#c53b53", + "light": "#c53b53" + }, + "diffContext": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHunkHeader": { + "dark": "#828bb8", + "light": "#7086b5" + }, + "diffHighlightAdded": { + "dark": "#6ba1e6", + "light": "#0062d1" + }, + "diffHighlightRemoved": { + "dark": "#e26a75", + "light": "#f52a65" + }, + "diffAddedBg": { + "dark": "#1a2a3d", + "light": "#e0edfa" + }, + "diffRemovedBg": { + "dark": "#37222c", + "light": "#f7d8db" + }, + "diffContextBg": { + "dark": "darkStep2", + "light": "lightStep2" + }, + "diffLineNumber": { + "dark": "darkStep3", + "light": "lightStep3" + }, + "diffAddedLineNumberBg": { + "dark": "#162535", + "light": "#d0e5f5" + }, + "diffRemovedLineNumberBg": { + "dark": "#2d1f26", + "light": "#e7c8cb" + }, + "markdownText": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "markdownHeading": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "markdownLink": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownLinkText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCode": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownBlockQuote": { + "dark": "#FFF7F1", + "light": "lightYellow" + }, + "markdownEmph": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "markdownStrong": { + "dark": "#EE7948", + "light": "#EC5B2B" + }, + "markdownHorizontalRule": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "markdownListItem": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkStep9", + "light": "lightStep9" + }, + "markdownImageText": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownCodeBlock": { + "dark": "darkStep12", + "light": "lightStep12" + }, + "syntaxComment": { + "dark": "darkStep11", + "light": "lightStep11" + }, + "syntaxKeyword": { + "dark": "#EC5B2B", + "light": "#EC5B2B" + }, + "syntaxFunction": { + "dark": "#EE7948", + "light": "#c94d24" + }, + "syntaxVariable": { + "dark": "darkRed", + "light": "lightRed" + }, + "syntaxString": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxNumber": { + "dark": "#FFF7F1", + "light": "#EC5B2B" + }, + "syntaxType": { + "dark": "darkYellow", + "light": "lightYellow" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "darkStep12", + "light": "lightStep12" + } + } +} diff --git a/src/lib/themes/osaka-jade.json b/src/lib/themes/osaka-jade.json new file mode 100644 index 0000000..1c9de92 --- /dev/null +++ b/src/lib/themes/osaka-jade.json @@ -0,0 +1,93 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg0": "#111c18", + "darkBg1": "#1a2520", + "darkBg2": "#23372B", + "darkBg3": "#3d4a44", + "darkFg0": "#C1C497", + "darkFg1": "#9aa88a", + "darkGray": "#53685B", + "darkRed": "#FF5345", + "darkGreen": "#549e6a", + "darkYellow": "#459451", + "darkBlue": "#509475", + "darkMagenta": "#D2689C", + "darkCyan": "#2DD5B7", + "darkWhite": "#F6F5DD", + "darkRedBright": "#db9f9c", + "darkGreenBright": "#63b07a", + "darkYellowBright": "#E5C736", + "darkBlueBright": "#ACD4CF", + "darkMagentaBright": "#75bbb3", + "darkCyanBright": "#8CD3CB", + "lightBg0": "#F6F5DD", + "lightBg1": "#E8E7CC", + "lightBg2": "#D5D4B8", + "lightBg3": "#A8A78C", + "lightFg0": "#111c18", + "lightFg1": "#1a2520", + "lightGray": "#53685B", + "lightRed": "#c7392d", + "lightGreen": "#3d7a52", + "lightYellow": "#b5a020", + "lightBlue": "#3d7560", + "lightMagenta": "#a8527a", + "lightCyan": "#1faa90" + }, + "theme": { + "primary": { "dark": "darkCyan", "light": "lightCyan" }, + "secondary": { "dark": "darkMagenta", "light": "lightMagenta" }, + "accent": { "dark": "darkGreen", "light": "lightGreen" }, + "error": { "dark": "darkRed", "light": "lightRed" }, + "warning": { "dark": "darkYellowBright", "light": "lightYellow" }, + "success": { "dark": "darkGreen", "light": "lightGreen" }, + "info": { "dark": "darkCyan", "light": "lightCyan" }, + "text": { "dark": "darkFg0", "light": "lightFg0" }, + "textMuted": { "dark": "darkGray", "light": "lightGray" }, + "background": { "dark": "darkBg0", "light": "lightBg0" }, + "backgroundPanel": { "dark": "darkBg1", "light": "lightBg1" }, + "backgroundElement": { "dark": "darkBg2", "light": "lightBg2" }, + "border": { "dark": "darkBg3", "light": "lightBg3" }, + "borderActive": { "dark": "darkCyan", "light": "lightCyan" }, + "borderSubtle": { "dark": "darkBg2", "light": "lightBg2" }, + "diffAdded": { "dark": "darkGreen", "light": "lightGreen" }, + "diffRemoved": { "dark": "darkRed", "light": "lightRed" }, + "diffContext": { "dark": "darkGray", "light": "lightGray" }, + "diffHunkHeader": { "dark": "darkCyan", "light": "lightCyan" }, + "diffHighlightAdded": { "dark": "darkGreenBright", "light": "lightGreen" }, + "diffHighlightRemoved": { "dark": "darkRedBright", "light": "lightRed" }, + "diffAddedBg": { "dark": "#15241c", "light": "#e0eee5" }, + "diffRemovedBg": { "dark": "#241515", "light": "#eee0e0" }, + "diffContextBg": { "dark": "darkBg1", "light": "lightBg1" }, + "diffLineNumber": { "dark": "darkBg3", "light": "lightBg3" }, + "diffAddedLineNumberBg": { "dark": "#121f18", "light": "#d5e5da" }, + "diffRemovedLineNumberBg": { "dark": "#1f1212", "light": "#e5d5d5" }, + "markdownText": { "dark": "darkFg0", "light": "lightFg0" }, + "markdownHeading": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownLink": { "dark": "darkCyanBright", "light": "lightCyan" }, + "markdownLinkText": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownCode": { "dark": "darkGreenBright", "light": "lightGreen" }, + "markdownBlockQuote": { "dark": "darkGray", "light": "lightGray" }, + "markdownEmph": { "dark": "darkMagenta", "light": "lightMagenta" }, + "markdownStrong": { "dark": "darkFg0", "light": "lightFg0" }, + "markdownHorizontalRule": { "dark": "darkGray", "light": "lightGray" }, + "markdownListItem": { "dark": "darkCyan", "light": "lightCyan" }, + "markdownListEnumeration": { + "dark": "darkCyanBright", + "light": "lightCyan" + }, + "markdownImage": { "dark": "darkCyanBright", "light": "lightCyan" }, + "markdownImageText": { "dark": "darkGreen", "light": "lightGreen" }, + "markdownCodeBlock": { "dark": "darkFg0", "light": "lightFg0" }, + "syntaxComment": { "dark": "darkGray", "light": "lightGray" }, + "syntaxKeyword": { "dark": "darkCyan", "light": "lightCyan" }, + "syntaxFunction": { "dark": "darkBlue", "light": "lightBlue" }, + "syntaxVariable": { "dark": "darkFg0", "light": "lightFg0" }, + "syntaxString": { "dark": "darkGreenBright", "light": "lightGreen" }, + "syntaxNumber": { "dark": "darkMagenta", "light": "lightMagenta" }, + "syntaxType": { "dark": "darkGreen", "light": "lightGreen" }, + "syntaxOperator": { "dark": "darkYellow", "light": "lightYellow" }, + "syntaxPunctuation": { "dark": "darkFg0", "light": "lightFg0" } + } +} diff --git a/src/lib/themes/palenight.json b/src/lib/themes/palenight.json new file mode 100644 index 0000000..79f7c59 --- /dev/null +++ b/src/lib/themes/palenight.json @@ -0,0 +1,222 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#292d3e", + "backgroundAlt": "#1e2132", + "backgroundPanel": "#32364a", + "foreground": "#a6accd", + "foregroundBright": "#bfc7d5", + "comment": "#676e95", + "red": "#f07178", + "orange": "#f78c6c", + "yellow": "#ffcb6b", + "green": "#c3e88d", + "cyan": "#89ddff", + "blue": "#82aaff", + "purple": "#c792ea", + "magenta": "#ff5370", + "pink": "#f07178" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#4976eb" + }, + "secondary": { + "dark": "purple", + "light": "#a854f2" + }, + "accent": { + "dark": "cyan", + "light": "#00acc1" + }, + "error": { + "dark": "red", + "light": "#e53935" + }, + "warning": { + "dark": "yellow", + "light": "#ffb300" + }, + "success": { + "dark": "green", + "light": "#91b859" + }, + "info": { + "dark": "orange", + "light": "#f4511e" + }, + "text": { + "dark": "foreground", + "light": "#292d3e" + }, + "textMuted": { + "dark": "comment", + "light": "#8796b0" + }, + "background": { + "dark": "#292d3e", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e2132", + "light": "#f5f5f5" + }, + "backgroundElement": { + "dark": "#32364a", + "light": "#e7e7e8" + }, + "border": { + "dark": "#32364a", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "blue", + "light": "#4976eb" + }, + "borderSubtle": { + "dark": "#1e2132", + "light": "#eeeeee" + }, + "diffAdded": { + "dark": "green", + "light": "#91b859" + }, + "diffRemoved": { + "dark": "red", + "light": "#e53935" + }, + "diffContext": { + "dark": "comment", + "light": "#8796b0" + }, + "diffHunkHeader": { + "dark": "cyan", + "light": "#00acc1" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "#91b859" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "#e53935" + }, + "diffAddedBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#1e2132", + "light": "#f5f5f5" + }, + "diffLineNumber": { + "dark": "#444760", + "light": "#cfd8dc" + }, + "diffAddedLineNumberBg": { + "dark": "#2e3c2b", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3c2b2b", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#292d3e" + }, + "markdownHeading": { + "dark": "purple", + "light": "#a854f2" + }, + "markdownLink": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownCode": { + "dark": "green", + "light": "#91b859" + }, + "markdownBlockQuote": { + "dark": "comment", + "light": "#8796b0" + }, + "markdownEmph": { + "dark": "yellow", + "light": "#ffb300" + }, + "markdownStrong": { + "dark": "orange", + "light": "#f4511e" + }, + "markdownHorizontalRule": { + "dark": "comment", + "light": "#8796b0" + }, + "markdownListItem": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownImage": { + "dark": "blue", + "light": "#4976eb" + }, + "markdownImageText": { + "dark": "cyan", + "light": "#00acc1" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#292d3e" + }, + "syntaxComment": { + "dark": "comment", + "light": "#8796b0" + }, + "syntaxKeyword": { + "dark": "purple", + "light": "#a854f2" + }, + "syntaxFunction": { + "dark": "blue", + "light": "#4976eb" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#292d3e" + }, + "syntaxString": { + "dark": "green", + "light": "#91b859" + }, + "syntaxNumber": { + "dark": "orange", + "light": "#f4511e" + }, + "syntaxType": { + "dark": "yellow", + "light": "#ffb300" + }, + "syntaxOperator": { + "dark": "cyan", + "light": "#00acc1" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#292d3e" + } + } +} diff --git a/src/lib/themes/rosepine.json b/src/lib/themes/rosepine.json new file mode 100644 index 0000000..444cdbd --- /dev/null +++ b/src/lib/themes/rosepine.json @@ -0,0 +1,234 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "base": "#191724", + "surface": "#1f1d2e", + "overlay": "#26233a", + "muted": "#6e6a86", + "subtle": "#908caa", + "text": "#e0def4", + "love": "#eb6f92", + "gold": "#f6c177", + "rose": "#ebbcba", + "pine": "#31748f", + "foam": "#9ccfd8", + "iris": "#c4a7e7", + "highlightLow": "#21202e", + "highlightMed": "#403d52", + "highlightHigh": "#524f67", + "moonBase": "#232136", + "moonSurface": "#2a273f", + "moonOverlay": "#393552", + "moonMuted": "#6e6a86", + "moonSubtle": "#908caa", + "moonText": "#e0def4", + "dawnBase": "#faf4ed", + "dawnSurface": "#fffaf3", + "dawnOverlay": "#f2e9e1", + "dawnMuted": "#9893a5", + "dawnSubtle": "#797593", + "dawnText": "#575279" + }, + "theme": { + "primary": { + "dark": "foam", + "light": "pine" + }, + "secondary": { + "dark": "iris", + "light": "#907aa9" + }, + "accent": { + "dark": "rose", + "light": "#d7827e" + }, + "error": { + "dark": "love", + "light": "#b4637a" + }, + "warning": { + "dark": "gold", + "light": "#ea9d34" + }, + "success": { + "dark": "pine", + "light": "#286983" + }, + "info": { + "dark": "foam", + "light": "#56949f" + }, + "text": { + "dark": "#e0def4", + "light": "#575279" + }, + "textMuted": { + "dark": "muted", + "light": "dawnMuted" + }, + "background": { + "dark": "base", + "light": "dawnBase" + }, + "backgroundPanel": { + "dark": "surface", + "light": "dawnSurface" + }, + "backgroundElement": { + "dark": "overlay", + "light": "dawnOverlay" + }, + "border": { + "dark": "highlightMed", + "light": "#dfdad9" + }, + "borderActive": { + "dark": "foam", + "light": "pine" + }, + "borderSubtle": { + "dark": "highlightLow", + "light": "#f4ede8" + }, + "diffAdded": { + "dark": "pine", + "light": "#286983" + }, + "diffRemoved": { + "dark": "love", + "light": "#b4637a" + }, + "diffContext": { + "dark": "muted", + "light": "dawnMuted" + }, + "diffHunkHeader": { + "dark": "iris", + "light": "#907aa9" + }, + "diffHighlightAdded": { + "dark": "pine", + "light": "#286983" + }, + "diffHighlightRemoved": { + "dark": "love", + "light": "#b4637a" + }, + "diffAddedBg": { + "dark": "#1f2d3a", + "light": "#e5f2f3" + }, + "diffRemovedBg": { + "dark": "#3a1f2d", + "light": "#fce5e8" + }, + "diffContextBg": { + "dark": "surface", + "light": "dawnSurface" + }, + "diffLineNumber": { + "dark": "muted", + "light": "dawnMuted" + }, + "diffAddedLineNumberBg": { + "dark": "#1f2d3a", + "light": "#e5f2f3" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1f2d", + "light": "#fce5e8" + }, + "markdownText": { + "dark": "#e0def4", + "light": "#575279" + }, + "markdownHeading": { + "dark": "iris", + "light": "#907aa9" + }, + "markdownLink": { + "dark": "foam", + "light": "pine" + }, + "markdownLinkText": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownCode": { + "dark": "pine", + "light": "#286983" + }, + "markdownBlockQuote": { + "dark": "muted", + "light": "dawnMuted" + }, + "markdownEmph": { + "dark": "gold", + "light": "#ea9d34" + }, + "markdownStrong": { + "dark": "love", + "light": "#b4637a" + }, + "markdownHorizontalRule": { + "dark": "highlightMed", + "light": "#dfdad9" + }, + "markdownListItem": { + "dark": "foam", + "light": "pine" + }, + "markdownListEnumeration": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownImage": { + "dark": "foam", + "light": "pine" + }, + "markdownImageText": { + "dark": "rose", + "light": "#d7827e" + }, + "markdownCodeBlock": { + "dark": "#e0def4", + "light": "#575279" + }, + "syntaxComment": { + "dark": "muted", + "light": "dawnMuted" + }, + "syntaxKeyword": { + "dark": "pine", + "light": "#286983" + }, + "syntaxFunction": { + "dark": "rose", + "light": "#d7827e" + }, + "syntaxVariable": { + "dark": "#e0def4", + "light": "#575279" + }, + "syntaxString": { + "dark": "gold", + "light": "#ea9d34" + }, + "syntaxNumber": { + "dark": "iris", + "light": "#907aa9" + }, + "syntaxType": { + "dark": "foam", + "light": "#56949f" + }, + "syntaxOperator": { + "dark": "subtle", + "light": "dawnSubtle" + }, + "syntaxPunctuation": { + "dark": "subtle", + "light": "dawnSubtle" + } + } +} diff --git a/src/lib/themes/solarized.json b/src/lib/themes/solarized.json new file mode 100644 index 0000000..e4de113 --- /dev/null +++ b/src/lib/themes/solarized.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "base03": "#002b36", + "base02": "#073642", + "base01": "#586e75", + "base00": "#657b83", + "base0": "#839496", + "base1": "#93a1a1", + "base2": "#eee8d5", + "base3": "#fdf6e3", + "yellow": "#b58900", + "orange": "#cb4b16", + "red": "#dc322f", + "magenta": "#d33682", + "violet": "#6c71c4", + "blue": "#268bd2", + "cyan": "#2aa198", + "green": "#859900" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "blue" + }, + "secondary": { + "dark": "violet", + "light": "violet" + }, + "accent": { + "dark": "cyan", + "light": "cyan" + }, + "error": { + "dark": "red", + "light": "red" + }, + "warning": { + "dark": "yellow", + "light": "yellow" + }, + "success": { + "dark": "green", + "light": "green" + }, + "info": { + "dark": "orange", + "light": "orange" + }, + "text": { + "dark": "base0", + "light": "base00" + }, + "textMuted": { + "dark": "base01", + "light": "base1" + }, + "background": { + "dark": "base03", + "light": "base3" + }, + "backgroundPanel": { + "dark": "base02", + "light": "base2" + }, + "backgroundElement": { + "dark": "#073642", + "light": "#eee8d5" + }, + "border": { + "dark": "base02", + "light": "base2" + }, + "borderActive": { + "dark": "base01", + "light": "base1" + }, + "borderSubtle": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffAdded": { + "dark": "green", + "light": "green" + }, + "diffRemoved": { + "dark": "red", + "light": "red" + }, + "diffContext": { + "dark": "base01", + "light": "base1" + }, + "diffHunkHeader": { + "dark": "base01", + "light": "base1" + }, + "diffHighlightAdded": { + "dark": "green", + "light": "green" + }, + "diffHighlightRemoved": { + "dark": "red", + "light": "red" + }, + "diffAddedBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffRemovedBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffContextBg": { + "dark": "base02", + "light": "base2" + }, + "diffLineNumber": { + "dark": "base01", + "light": "base1" + }, + "diffAddedLineNumberBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "diffRemovedLineNumberBg": { + "dark": "#073642", + "light": "#eee8d5" + }, + "markdownText": { + "dark": "base0", + "light": "base00" + }, + "markdownHeading": { + "dark": "blue", + "light": "blue" + }, + "markdownLink": { + "dark": "cyan", + "light": "cyan" + }, + "markdownLinkText": { + "dark": "violet", + "light": "violet" + }, + "markdownCode": { + "dark": "green", + "light": "green" + }, + "markdownBlockQuote": { + "dark": "base01", + "light": "base1" + }, + "markdownEmph": { + "dark": "yellow", + "light": "yellow" + }, + "markdownStrong": { + "dark": "orange", + "light": "orange" + }, + "markdownHorizontalRule": { + "dark": "base01", + "light": "base1" + }, + "markdownListItem": { + "dark": "blue", + "light": "blue" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImage": { + "dark": "cyan", + "light": "cyan" + }, + "markdownImageText": { + "dark": "violet", + "light": "violet" + }, + "markdownCodeBlock": { + "dark": "base0", + "light": "base00" + }, + "syntaxComment": { + "dark": "base01", + "light": "base1" + }, + "syntaxKeyword": { + "dark": "green", + "light": "green" + }, + "syntaxFunction": { + "dark": "blue", + "light": "blue" + }, + "syntaxVariable": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxString": { + "dark": "cyan", + "light": "cyan" + }, + "syntaxNumber": { + "dark": "magenta", + "light": "magenta" + }, + "syntaxType": { + "dark": "yellow", + "light": "yellow" + }, + "syntaxOperator": { + "dark": "green", + "light": "green" + }, + "syntaxPunctuation": { + "dark": "base0", + "light": "base00" + } + } +} diff --git a/src/lib/themes/synthwave84.json b/src/lib/themes/synthwave84.json new file mode 100644 index 0000000..d25bf3b --- /dev/null +++ b/src/lib/themes/synthwave84.json @@ -0,0 +1,226 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background": "#262335", + "backgroundAlt": "#1e1a29", + "backgroundPanel": "#2a2139", + "foreground": "#ffffff", + "foregroundMuted": "#848bbd", + "pink": "#ff7edb", + "pinkBright": "#ff92df", + "cyan": "#36f9f6", + "cyanBright": "#72f1f8", + "yellow": "#fede5d", + "yellowBright": "#fff95d", + "orange": "#ff8b39", + "orangeBright": "#ff9f43", + "purple": "#b084eb", + "purpleBright": "#c792ea", + "red": "#fe4450", + "redBright": "#ff5e5b", + "green": "#72f1b8", + "greenBright": "#97f1d8" + }, + "theme": { + "primary": { + "dark": "cyan", + "light": "#00bcd4" + }, + "secondary": { + "dark": "pink", + "light": "#e91e63" + }, + "accent": { + "dark": "purple", + "light": "#9c27b0" + }, + "error": { + "dark": "red", + "light": "#f44336" + }, + "warning": { + "dark": "yellow", + "light": "#ff9800" + }, + "success": { + "dark": "green", + "light": "#4caf50" + }, + "info": { + "dark": "orange", + "light": "#ff5722" + }, + "text": { + "dark": "foreground", + "light": "#262335" + }, + "textMuted": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "background": { + "dark": "#262335", + "light": "#fafafa" + }, + "backgroundPanel": { + "dark": "#1e1a29", + "light": "#f5f5f5" + }, + "backgroundElement": { + "dark": "#2a2139", + "light": "#eeeeee" + }, + "border": { + "dark": "#495495", + "light": "#e0e0e0" + }, + "borderActive": { + "dark": "cyan", + "light": "#00bcd4" + }, + "borderSubtle": { + "dark": "#241b2f", + "light": "#f0f0f0" + }, + "diffAdded": { + "dark": "green", + "light": "#4caf50" + }, + "diffRemoved": { + "dark": "red", + "light": "#f44336" + }, + "diffContext": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "diffHunkHeader": { + "dark": "purple", + "light": "#9c27b0" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#4caf50" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#f44336" + }, + "diffAddedBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "diffContextBg": { + "dark": "#1e1a29", + "light": "#f5f5f5" + }, + "diffLineNumber": { + "dark": "#495495", + "light": "#b0b0b0" + }, + "diffAddedLineNumberBg": { + "dark": "#1a3a2a", + "light": "#e8f5e9" + }, + "diffRemovedLineNumberBg": { + "dark": "#3a1a2a", + "light": "#ffebee" + }, + "markdownText": { + "dark": "foreground", + "light": "#262335" + }, + "markdownHeading": { + "dark": "pink", + "light": "#e91e63" + }, + "markdownLink": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownLinkText": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownCode": { + "dark": "green", + "light": "#4caf50" + }, + "markdownBlockQuote": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "markdownEmph": { + "dark": "yellow", + "light": "#ff9800" + }, + "markdownStrong": { + "dark": "orange", + "light": "#ff5722" + }, + "markdownHorizontalRule": { + "dark": "#495495", + "light": "#e0e0e0" + }, + "markdownListItem": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownListEnumeration": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownImage": { + "dark": "cyan", + "light": "#00bcd4" + }, + "markdownImageText": { + "dark": "purple", + "light": "#9c27b0" + }, + "markdownCodeBlock": { + "dark": "foreground", + "light": "#262335" + }, + "syntaxComment": { + "dark": "foregroundMuted", + "light": "#5c5c8a" + }, + "syntaxKeyword": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxFunction": { + "dark": "orange", + "light": "#ff5722" + }, + "syntaxVariable": { + "dark": "foreground", + "light": "#262335" + }, + "syntaxString": { + "dark": "yellow", + "light": "#ff9800" + }, + "syntaxNumber": { + "dark": "purple", + "light": "#9c27b0" + }, + "syntaxType": { + "dark": "cyan", + "light": "#00bcd4" + }, + "syntaxOperator": { + "dark": "pink", + "light": "#e91e63" + }, + "syntaxPunctuation": { + "dark": "foreground", + "light": "#262335" + } + } +} diff --git a/src/lib/themes/tokyonight.json b/src/lib/themes/tokyonight.json new file mode 100644 index 0000000..5b422ee --- /dev/null +++ b/src/lib/themes/tokyonight.json @@ -0,0 +1,235 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "darkBg": "#1a1b26", + "darkPanel": "#16161e", + "darkElement": "#24283b", + "darkFg": "#a9b1d6", + "darkMuted": "#565f89", + "darkBorder": "#565f89", + "darkBorderActive": "#7aa2f7", + "darkBlue": "#7aa2f7", + "darkCyan": "#7dcfff", + "darkGreen": "#9ece6a", + "darkYellow": "#e0af68", + "darkOrange": "#ff9e64", + "darkRed": "#f7768e", + "darkPurple": "#bb9af7", + "lightBg": "#d5d6db", + "lightPanel": "#e9e9ec", + "lightElement": "#c4c8da", + "lightFg": "#343b58", + "lightMuted": "#6172b0", + "lightBorder": "#c4c8da", + "lightBorderActive": "#2e7de9", + "lightBlue": "#2e7de9", + "lightCyan": "#007197", + "lightGreen": "#587539", + "lightYellow": "#8c6c3e", + "lightOrange": "#965027", + "lightRed": "#f52a65", + "lightPurple": "#9854f1" + }, + "theme": { + "primary": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "secondary": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "accent": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "error": { + "dark": "darkRed", + "light": "lightRed" + }, + "warning": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "success": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "info": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "text": { + "dark": "darkFg", + "light": "lightFg" + }, + "textMuted": { + "dark": "#787c99", + "light": "lightMuted" + }, + "background": { + "dark": "darkBg", + "light": "lightBg" + }, + "backgroundPanel": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "backgroundElement": { + "dark": "darkElement", + "light": "lightElement" + }, + "border": { + "dark": "darkBorder", + "light": "lightBorder" + }, + "borderActive": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "borderSubtle": { + "dark": "#24283b", + "light": "#c4c8da" + }, + "diffAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffContext": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHunkHeader": { + "dark": "darkMuted", + "light": "lightMuted" + }, + "diffHighlightAdded": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "diffHighlightRemoved": { + "dark": "darkRed", + "light": "lightRed" + }, + "diffAddedBg": { + "dark": "#1a2e1a", + "light": "#dfeadf" + }, + "diffRemovedBg": { + "dark": "#2e1a1a", + "light": "#f4e1e4" + }, + "diffContextBg": { + "dark": "darkPanel", + "light": "lightPanel" + }, + "diffLineNumber": { + "dark": "#3d59a1", + "light": "#6172b0" + }, + "diffAddedLineNumberBg": { + "dark": "#1a2e1a", + "light": "#dfeadf" + }, + "diffRemovedLineNumberBg": { + "dark": "#2e1a1a", + "light": "#f4e1e4" + }, + "markdownText": { + "dark": "darkFg", + "light": "lightFg" + }, + "markdownHeading": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownLink": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownLinkText": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownCode": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "markdownBlockQuote": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownEmph": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "markdownStrong": { + "dark": "darkRed", + "light": "lightRed" + }, + "markdownHorizontalRule": { + "dark": "#565f89", + "light": "#c4c8da" + }, + "markdownListItem": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownListEnumeration": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImage": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "markdownImageText": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "markdownCodeBlock": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxComment": { + "dark": "#565f89", + "light": "#9699a3" + }, + "syntaxKeyword": { + "dark": "darkPurple", + "light": "lightPurple" + }, + "syntaxFunction": { + "dark": "darkBlue", + "light": "lightBlue" + }, + "syntaxVariable": { + "dark": "darkFg", + "light": "lightFg" + }, + "syntaxString": { + "dark": "darkGreen", + "light": "lightGreen" + }, + "syntaxNumber": { + "dark": "darkOrange", + "light": "lightOrange" + }, + "syntaxType": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxOperator": { + "dark": "darkCyan", + "light": "lightCyan" + }, + "syntaxPunctuation": { + "dark": "#89ddff", + "light": "#6172b0" + } + } +} diff --git a/src/lib/themes/vercel.json b/src/lib/themes/vercel.json new file mode 100644 index 0000000..86b965b --- /dev/null +++ b/src/lib/themes/vercel.json @@ -0,0 +1,245 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "background100": "#0A0A0A", + "background200": "#000000", + "gray100": "#1A1A1A", + "gray200": "#1F1F1F", + "gray300": "#292929", + "gray400": "#2E2E2E", + "gray500": "#454545", + "gray600": "#878787", + "gray700": "#8F8F8F", + "gray900": "#A1A1A1", + "gray1000": "#EDEDED", + "blue600": "#0099FF", + "blue700": "#0070F3", + "blue900": "#52A8FF", + "blue1000": "#EBF8FF", + "red700": "#E5484D", + "red900": "#FF6166", + "red1000": "#FDECED", + "amber700": "#FFB224", + "amber900": "#F2A700", + "amber1000": "#FDF4DC", + "green700": "#46A758", + "green900": "#63C46D", + "green1000": "#E6F9E9", + "teal700": "#12A594", + "teal900": "#0AC7AC", + "purple700": "#8E4EC6", + "purple900": "#BF7AF0", + "pink700": "#E93D82", + "pink900": "#F75590", + "highlightPink": "#FF0080", + "highlightPurple": "#F81CE5", + "cyan": "#50E3C2", + "lightBackground": "#FFFFFF", + "lightGray100": "#FAFAFA", + "lightGray200": "#EAEAEA", + "lightGray600": "#666666", + "lightGray1000": "#171717" + }, + "theme": { + "primary": { + "dark": "blue700", + "light": "blue700" + }, + "secondary": { + "dark": "blue900", + "light": "#0062D1" + }, + "accent": { + "dark": "purple700", + "light": "purple700" + }, + "error": { + "dark": "red700", + "light": "#DC3545" + }, + "warning": { + "dark": "amber700", + "light": "#FF9500" + }, + "success": { + "dark": "green700", + "light": "#388E3C" + }, + "info": { + "dark": "blue900", + "light": "blue700" + }, + "text": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "textMuted": { + "dark": "gray600", + "light": "lightGray600" + }, + "background": { + "dark": "background200", + "light": "lightBackground" + }, + "backgroundPanel": { + "dark": "gray100", + "light": "lightGray100" + }, + "backgroundElement": { + "dark": "gray300", + "light": "lightGray200" + }, + "border": { + "dark": "gray200", + "light": "lightGray200" + }, + "borderActive": { + "dark": "gray500", + "light": "#999999" + }, + "borderSubtle": { + "dark": "gray100", + "light": "#EAEAEA" + }, + "diffAdded": { + "dark": "green900", + "light": "green700" + }, + "diffRemoved": { + "dark": "red900", + "light": "red700" + }, + "diffContext": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffHunkHeader": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffHighlightAdded": { + "dark": "green900", + "light": "green700" + }, + "diffHighlightRemoved": { + "dark": "red900", + "light": "red700" + }, + "diffAddedBg": { + "dark": "#0B1D0F", + "light": "#E6F9E9" + }, + "diffRemovedBg": { + "dark": "#2A1314", + "light": "#FDECED" + }, + "diffContextBg": { + "dark": "background200", + "light": "lightBackground" + }, + "diffLineNumber": { + "dark": "gray600", + "light": "lightGray600" + }, + "diffAddedLineNumberBg": { + "dark": "#0F2613", + "light": "#D6F5D6" + }, + "diffRemovedLineNumberBg": { + "dark": "#3C1618", + "light": "#FFE5E5" + }, + "markdownText": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "markdownHeading": { + "dark": "purple900", + "light": "purple700" + }, + "markdownLink": { + "dark": "blue900", + "light": "blue700" + }, + "markdownLinkText": { + "dark": "teal900", + "light": "teal700" + }, + "markdownCode": { + "dark": "green900", + "light": "green700" + }, + "markdownBlockQuote": { + "dark": "gray600", + "light": "lightGray600" + }, + "markdownEmph": { + "dark": "amber900", + "light": "amber700" + }, + "markdownStrong": { + "dark": "pink900", + "light": "pink700" + }, + "markdownHorizontalRule": { + "dark": "gray500", + "light": "#999999" + }, + "markdownListItem": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "markdownListEnumeration": { + "dark": "blue900", + "light": "blue700" + }, + "markdownImage": { + "dark": "teal900", + "light": "teal700" + }, + "markdownImageText": { + "dark": "cyan", + "light": "teal700" + }, + "markdownCodeBlock": { + "dark": "gray1000", + "light": "lightGray1000" + }, + "syntaxComment": { + "dark": "gray600", + "light": "#888888" + }, + "syntaxKeyword": { + "dark": "pink900", + "light": "pink700" + }, + "syntaxFunction": { + "dark": "purple900", + "light": "purple700" + }, + "syntaxVariable": { + "dark": "blue900", + "light": "blue700" + }, + "syntaxString": { + "dark": "green900", + "light": "green700" + }, + "syntaxNumber": { + "dark": "amber900", + "light": "amber700" + }, + "syntaxType": { + "dark": "teal900", + "light": "teal700" + }, + "syntaxOperator": { + "dark": "pink900", + "light": "pink700" + }, + "syntaxPunctuation": { + "dark": "gray1000", + "light": "lightGray1000" + } + } +} diff --git a/src/lib/themes/vesper.json b/src/lib/themes/vesper.json new file mode 100644 index 0000000..758c8f2 --- /dev/null +++ b/src/lib/themes/vesper.json @@ -0,0 +1,218 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "vesperBg": "#101010", + "vesperFg": "#FFF", + "vesperComment": "#8b8b8b", + "vesperKeyword": "#A0A0A0", + "vesperFunction": "#FFC799", + "vesperString": "#99FFE4", + "vesperNumber": "#FFC799", + "vesperError": "#FF8080", + "vesperWarning": "#FFC799", + "vesperSuccess": "#99FFE4", + "vesperMuted": "#A0A0A0" + }, + "theme": { + "primary": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "secondary": { + "dark": "#99FFE4", + "light": "#99FFE4" + }, + "accent": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "error": { + "dark": "vesperError", + "light": "vesperError" + }, + "warning": { + "dark": "vesperWarning", + "light": "vesperWarning" + }, + "success": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "info": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "text": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "textMuted": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "background": { + "dark": "vesperBg", + "light": "#FFF" + }, + "backgroundPanel": { + "dark": "vesperBg", + "light": "#F0F0F0" + }, + "backgroundElement": { + "dark": "vesperBg", + "light": "#E0E0E0" + }, + "border": { + "dark": "#282828", + "light": "#D0D0D0" + }, + "borderActive": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "borderSubtle": { + "dark": "#1C1C1C", + "light": "#E8E8E8" + }, + "diffAdded": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "diffRemoved": { + "dark": "vesperError", + "light": "vesperError" + }, + "diffContext": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "diffHunkHeader": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "diffHighlightAdded": { + "dark": "vesperSuccess", + "light": "vesperSuccess" + }, + "diffHighlightRemoved": { + "dark": "vesperError", + "light": "vesperError" + }, + "diffAddedBg": { + "dark": "#0d2818", + "light": "#e8f5e8" + }, + "diffRemovedBg": { + "dark": "#281a1a", + "light": "#f5e8e8" + }, + "diffContextBg": { + "dark": "vesperBg", + "light": "#F8F8F8" + }, + "diffLineNumber": { + "dark": "#505050", + "light": "#808080" + }, + "diffAddedLineNumberBg": { + "dark": "#0d2818", + "light": "#e8f5e8" + }, + "diffRemovedLineNumberBg": { + "dark": "#281a1a", + "light": "#f5e8e8" + }, + "markdownText": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownHeading": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownLink": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownLinkText": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownCode": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownBlockQuote": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownEmph": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownStrong": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownHorizontalRule": { + "dark": "#65737E", + "light": "#65737E" + }, + "markdownListItem": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownListEnumeration": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "markdownImage": { + "dark": "#FFC799", + "light": "#FFC799" + }, + "markdownImageText": { + "dark": "vesperMuted", + "light": "vesperMuted" + }, + "markdownCodeBlock": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "syntaxComment": { + "dark": "vesperComment", + "light": "vesperComment" + }, + "syntaxKeyword": { + "dark": "vesperKeyword", + "light": "vesperKeyword" + }, + "syntaxFunction": { + "dark": "vesperFunction", + "light": "vesperFunction" + }, + "syntaxVariable": { + "dark": "vesperFg", + "light": "vesperBg" + }, + "syntaxString": { + "dark": "vesperString", + "light": "vesperString" + }, + "syntaxNumber": { + "dark": "vesperNumber", + "light": "vesperNumber" + }, + "syntaxType": { + "dark": "vesperFunction", + "light": "vesperFunction" + }, + "syntaxOperator": { + "dark": "vesperKeyword", + "light": "vesperKeyword" + }, + "syntaxPunctuation": { + "dark": "vesperFg", + "light": "vesperBg" + } + } +} diff --git a/src/lib/themes/zenburn.json b/src/lib/themes/zenburn.json new file mode 100644 index 0000000..c447592 --- /dev/null +++ b/src/lib/themes/zenburn.json @@ -0,0 +1,223 @@ +{ + "$schema": "https://opencode.ai/theme.json", + "defs": { + "bg": "#3f3f3f", + "bgAlt": "#4f4f4f", + "bgPanel": "#5f5f5f", + "fg": "#dcdccc", + "fgMuted": "#9f9f9f", + "red": "#cc9393", + "redBright": "#dca3a3", + "green": "#7f9f7f", + "greenBright": "#8fb28f", + "yellow": "#f0dfaf", + "yellowDim": "#e0cf9f", + "blue": "#8cd0d3", + "blueDim": "#7cb8bb", + "magenta": "#dc8cc3", + "cyan": "#93e0e3", + "orange": "#dfaf8f" + }, + "theme": { + "primary": { + "dark": "blue", + "light": "#5f7f8f" + }, + "secondary": { + "dark": "magenta", + "light": "#8f5f8f" + }, + "accent": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "error": { + "dark": "red", + "light": "#8f5f5f" + }, + "warning": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "success": { + "dark": "green", + "light": "#5f8f5f" + }, + "info": { + "dark": "orange", + "light": "#8f7f5f" + }, + "text": { + "dark": "fg", + "light": "#3f3f3f" + }, + "textMuted": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "background": { + "dark": "bg", + "light": "#ffffef" + }, + "backgroundPanel": { + "dark": "bgAlt", + "light": "#f5f5e5" + }, + "backgroundElement": { + "dark": "bgPanel", + "light": "#ebebdb" + }, + "border": { + "dark": "#5f5f5f", + "light": "#d0d0c0" + }, + "borderActive": { + "dark": "blue", + "light": "#5f7f8f" + }, + "borderSubtle": { + "dark": "#4f4f4f", + "light": "#e0e0d0" + }, + "diffAdded": { + "dark": "green", + "light": "#5f8f5f" + }, + "diffRemoved": { + "dark": "red", + "light": "#8f5f5f" + }, + "diffContext": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "diffHunkHeader": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "diffHighlightAdded": { + "dark": "greenBright", + "light": "#5f8f5f" + }, + "diffHighlightRemoved": { + "dark": "redBright", + "light": "#8f5f5f" + }, + "diffAddedBg": { + "dark": "#4f5f4f", + "light": "#efffef" + }, + "diffRemovedBg": { + "dark": "#5f4f4f", + "light": "#ffefef" + }, + "diffContextBg": { + "dark": "bgAlt", + "light": "#f5f5e5" + }, + "diffLineNumber": { + "dark": "#6f6f6f", + "light": "#b0b0a0" + }, + "diffAddedLineNumberBg": { + "dark": "#4f5f4f", + "light": "#efffef" + }, + "diffRemovedLineNumberBg": { + "dark": "#5f4f4f", + "light": "#ffefef" + }, + "markdownText": { + "dark": "fg", + "light": "#3f3f3f" + }, + "markdownHeading": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "markdownLink": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownLinkText": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownCode": { + "dark": "green", + "light": "#5f8f5f" + }, + "markdownBlockQuote": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "markdownEmph": { + "dark": "yellowDim", + "light": "#8f8f5f" + }, + "markdownStrong": { + "dark": "orange", + "light": "#8f7f5f" + }, + "markdownHorizontalRule": { + "dark": "fgMuted", + "light": "#6f6f6f" + }, + "markdownListItem": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownListEnumeration": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownImage": { + "dark": "blue", + "light": "#5f7f8f" + }, + "markdownImageText": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "markdownCodeBlock": { + "dark": "fg", + "light": "#3f3f3f" + }, + "syntaxComment": { + "dark": "#7f9f7f", + "light": "#5f7f5f" + }, + "syntaxKeyword": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "syntaxFunction": { + "dark": "blue", + "light": "#5f7f8f" + }, + "syntaxVariable": { + "dark": "fg", + "light": "#3f3f3f" + }, + "syntaxString": { + "dark": "red", + "light": "#8f5f5f" + }, + "syntaxNumber": { + "dark": "greenBright", + "light": "#5f8f5f" + }, + "syntaxType": { + "dark": "cyan", + "light": "#5f8f8f" + }, + "syntaxOperator": { + "dark": "yellow", + "light": "#8f8f5f" + }, + "syntaxPunctuation": { + "dark": "fg", + "light": "#3f3f3f" + } + } +} diff --git a/tests/unit/theme-resolver.test.ts b/tests/unit/theme-resolver.test.ts new file mode 100644 index 0000000..075431f --- /dev/null +++ b/tests/unit/theme-resolver.test.ts @@ -0,0 +1,198 @@ +import { describe, it, expect } from "bun:test"; +import { + resolveTheme, + getThemeColor, + isValidTheme, + themeNames, + type Theme, + type ThemeMode, +} from "../../src/lib/theme-resolver"; + +describe("resolveTheme", () => { + it("should resolve the default theme (opencode) when no name provided", () => { + const theme = resolveTheme(); + + // Verify it returns a Theme object with expected properties + expect(theme.primary).toBeDefined(); + expect(theme.background).toBeDefined(); + expect(theme.text).toBeDefined(); + }); + + it("should resolve a named theme", () => { + const theme = resolveTheme("dracula"); + + expect(theme.primary).toBeDefined(); + expect(typeof theme.primary).toBe("string"); + expect(theme.primary.startsWith("#")).toBe(true); + }); + + it("should fall back to default theme for invalid theme name", () => { + const theme = resolveTheme("nonexistent-theme"); + const defaultTheme = resolveTheme("opencode"); + + // Should resolve to the same as the default theme + expect(theme.primary).toBe(defaultTheme.primary); + }); + + it("should resolve dark mode colors by default", () => { + const theme = resolveTheme("opencode", "dark"); + + // The opencode theme's dark primary is darkStep9 = #fab283 + expect(theme.primary).toBe("#fab283"); + }); + + it("should resolve light mode colors when specified", () => { + const theme = resolveTheme("opencode", "light"); + + // The opencode theme's light primary is lightStep9 = #3b7dd8 + expect(theme.primary).toBe("#3b7dd8"); + }); + + it("should resolve def references to hex colors", () => { + const theme = resolveTheme("opencode", "dark"); + + // All resolved values should be hex colors + expect(theme.text).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(theme.background).toMatch(/^#[0-9a-fA-F]{6}$/); + expect(theme.border).toMatch(/^#[0-9a-fA-F]{6}$/); + }); + + it("should preserve direct hex values in theme", () => { + const theme = resolveTheme("opencode", "dark"); + + // diffAdded in opencode has a direct hex value: "#4fd6be" + expect(theme.diffAdded).toBe("#4fd6be"); + }); + + it("should resolve all theme color keys", () => { + const theme = resolveTheme("opencode"); + + // Check all expected keys are present and are hex colors + const expectedKeys: (keyof Theme)[] = [ + "primary", + "secondary", + "accent", + "error", + "warning", + "success", + "info", + "text", + "textMuted", + "background", + "backgroundPanel", + "backgroundElement", + "border", + "borderActive", + "borderSubtle", + "diffAdded", + "diffRemoved", + "diffContext", + "diffHunkHeader", + "diffHighlightAdded", + "diffHighlightRemoved", + "diffAddedBg", + "diffRemovedBg", + "diffContextBg", + "diffLineNumber", + "diffAddedLineNumberBg", + "diffRemovedLineNumberBg", + "markdownText", + "markdownHeading", + "markdownLink", + "markdownLinkText", + "markdownCode", + "markdownBlockQuote", + "markdownEmph", + "markdownStrong", + "markdownHorizontalRule", + "markdownListItem", + "markdownListEnumeration", + "markdownImage", + "markdownImageText", + "markdownCodeBlock", + "syntaxComment", + "syntaxKeyword", + "syntaxFunction", + "syntaxVariable", + "syntaxString", + "syntaxNumber", + "syntaxType", + "syntaxOperator", + "syntaxPunctuation", + ]; + + for (const key of expectedKeys) { + expect(theme[key]).toBeDefined(); + expect(typeof theme[key]).toBe("string"); + expect(theme[key].startsWith("#")).toBe(true); + } + }); + + it("should resolve different themes with different colors", () => { + const opencode = resolveTheme("opencode", "dark"); + const dracula = resolveTheme("dracula", "dark"); + + // Different themes should have different primary colors + // (unless by coincidence they're the same, which is unlikely) + expect(opencode.primary !== dracula.primary || opencode.background !== dracula.background).toBe(true); + }); +}); + +describe("getThemeColor", () => { + it("should get a single color from a theme", () => { + const primary = getThemeColor("opencode", "primary", "dark"); + + expect(primary).toBe("#fab283"); + }); + + it("should respect mode parameter", () => { + const darkPrimary = getThemeColor("opencode", "primary", "dark"); + const lightPrimary = getThemeColor("opencode", "primary", "light"); + + expect(darkPrimary).not.toBe(lightPrimary); + }); + + it("should default to dark mode", () => { + const color = getThemeColor("opencode", "primary"); + const darkColor = getThemeColor("opencode", "primary", "dark"); + + expect(color).toBe(darkColor); + }); +}); + +describe("isValidTheme", () => { + it("should return true for valid theme names", () => { + expect(isValidTheme("opencode")).toBe(true); + expect(isValidTheme("dracula")).toBe(true); + expect(isValidTheme("nord")).toBe(true); + }); + + it("should return false for invalid theme names", () => { + expect(isValidTheme("nonexistent")).toBe(false); + expect(isValidTheme("")).toBe(false); + expect(isValidTheme("OPENCODE")).toBe(false); // case sensitive + }); +}); + +describe("themeNames", () => { + it("should be an array of strings", () => { + expect(Array.isArray(themeNames)).toBe(true); + expect(themeNames.length).toBeGreaterThan(0); + themeNames.forEach(name => { + expect(typeof name).toBe("string"); + }); + }); + + it("should include known themes", () => { + expect(themeNames).toContain("opencode"); + expect(themeNames).toContain("dracula"); + expect(themeNames).toContain("catppuccin-mocha"); + expect(themeNames).toContain("gruvbox"); + }); + + it("should have all valid themes", () => { + themeNames.forEach(name => { + expect(isValidTheme(name)).toBe(true); + }); + }); +}); From 880ae5dd8aa5889b08c11cebb116a23ecd9cfe2c Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 7 Jan 2026 20:07:07 -0800 Subject: [PATCH 2/7] feat: add dialog and toast notification systems Add reusable UI infrastructure for user interaction: Dialog System: - DialogContext with stack-based dialog management - Dialog base component with overlay and Escape handling - DialogAlert for message notifications - DialogConfirm with Y/N keyboard shortcuts - DialogPrompt with text input field - DialogSelect for selection with fuzzy search support Toast System: - ToastContext with ToastProvider for global notifications - ToastStack component for displaying stacked notifications - Auto-dismiss with fade-out animation - Support for info/success/error toast types --- src/components/toast.tsx | 107 ++++++++++ src/context/DialogContext.tsx | 201 +++++++++++++++++++ src/context/ToastContext.tsx | 236 ++++++++++++++++++++++ src/ui/Dialog.tsx | 59 ++++++ src/ui/DialogAlert.tsx | 99 ++++++++++ src/ui/DialogConfirm.tsx | 88 +++++++++ src/ui/DialogPrompt.tsx | 115 +++++++++++ src/ui/DialogSelect.tsx | 357 ++++++++++++++++++++++++++++++++++ tests/unit/toast.test.ts | 178 +++++++++++++++++ 9 files changed, 1440 insertions(+) create mode 100644 src/components/toast.tsx create mode 100644 src/context/DialogContext.tsx create mode 100644 src/context/ToastContext.tsx create mode 100644 src/ui/Dialog.tsx create mode 100644 src/ui/DialogAlert.tsx create mode 100644 src/ui/DialogConfirm.tsx create mode 100644 src/ui/DialogPrompt.tsx create mode 100644 src/ui/DialogSelect.tsx create mode 100644 tests/unit/toast.test.ts diff --git a/src/components/toast.tsx b/src/components/toast.tsx new file mode 100644 index 0000000..25cba22 --- /dev/null +++ b/src/components/toast.tsx @@ -0,0 +1,107 @@ +import { For, Show } from "solid-js"; +import { useTheme } from "../context/ThemeContext"; +import { useToast, type Toast, type ToastVariant } from "../context/ToastContext"; + +/** + * Get the foreground color for a toast variant. + * Uses theme colors for consistency. + */ +function getVariantColor(variant: ToastVariant, theme: ReturnType["theme"]>): string { + switch (variant) { + case "success": + return theme.success; + case "error": + return theme.error; + case "warning": + return theme.warning; + case "info": + default: + return theme.info; + } +} + +/** + * Get the icon for a toast variant. + * Uses simple ASCII characters for terminal compatibility. + */ +function getVariantIcon(variant: ToastVariant): string { + switch (variant) { + case "success": + return "✓"; + case "error": + return "✗"; + case "warning": + return "⚠"; + case "info": + default: + return "ℹ"; + } +} + +/** + * Single toast item component. + * Displays the icon, message, and applies variant-specific styling. + * When fading, uses muted colors to simulate fade-out animation. + * + * NOTE: Uses reactive theme getter `t()` for proper theme updates. + */ +function ToastItem(props: { toast: Toast }) { + const { theme } = useTheme(); + // Reactive getter ensures theme updates propagate correctly + const t = () => theme(); + const isFading = () => props.toast.fading ?? false; + + // Use muted color when fading out + const variantColor = () => isFading() + ? t().textMuted + : getVariantColor(props.toast.variant, t()); + const textColor = () => isFading() ? t().textMuted : t().text; + const icon = getVariantIcon(props.toast.variant); + + return ( + + {icon} + {props.toast.message} + + ); +} + +/** + * ToastStack component that renders all active toasts. + * Positioned at the bottom of the screen above the footer. + * Renders toasts in order with newest at the bottom. + * + * NOTE: Uses reactive theme getter `t()` and for proper reactivity. + */ +export function ToastStack() { + const { toasts } = useToast(); + const { theme } = useTheme(); + // Reactive getter ensures theme updates propagate correctly + const t = () => theme(); + + // Use for reactive conditional rendering - early return is not reactive in SolidJS + return ( + 0}> + + + {(toast) => } + + + + ); +} diff --git a/src/context/DialogContext.tsx b/src/context/DialogContext.tsx new file mode 100644 index 0000000..54fb050 --- /dev/null +++ b/src/context/DialogContext.tsx @@ -0,0 +1,201 @@ +import { + createContext, + useContext, + createSignal, + For, + Show, + JSX, +} from "solid-js"; +import type { Accessor } from "solid-js"; + +/** + * Type for a dialog component that can be rendered in the stack. + * Dialogs are functions that return JSX elements. + */ +export type DialogComponent = () => JSX.Element; + +/** + * Context value interface defining all dialog operations. + */ +export interface DialogContextValue { + /** Push a dialog onto the stack */ + show: (dialog: DialogComponent) => void; + /** Replace the top dialog with a new one */ + replace: (dialog: DialogComponent) => void; + /** Clear all dialogs from the stack */ + clear: () => void; + /** Remove the top dialog from the stack */ + pop: () => void; + /** Accessor for the current dialog stack */ + stack: Accessor; + /** Check if there are any dialogs open */ + hasDialogs: Accessor; +} + +/** + * Input focus management interface. + * Tracks whether any dialog input is currently focused. + */ +export interface InputFocusValue { + /** Signal indicating if input is focused */ + inputFocused: Accessor; + /** Set input focused state */ + setInputFocused: (focused: boolean) => void; + /** Check if any input is currently focused (convenience accessor) */ + isInputFocused: () => boolean; +} + +// Create the context with undefined default (must be used within provider) +const DialogContext = createContext(); + +// Create input focus context +const InputFocusContext = createContext(); + +/** + * Props for the DialogProvider component. + */ +export interface DialogProviderProps { + children: JSX.Element; +} + +/** + * DialogProvider component that manages a stack of dialogs. + * Wraps children with dialog context and renders the dialog stack overlay. + */ +export function DialogProvider(props: DialogProviderProps) { + // Dialog stack signal - stores array of dialog components + const [stack, setStack] = createSignal([]); + + // Input focus signal - tracks if any dialog input is focused + const [inputFocused, setInputFocused] = createSignal(false); + + // Derived accessor for checking if any dialogs are open + const hasDialogs: Accessor = () => stack().length > 0; + + /** + * Push a dialog onto the stack. + * Also sets inputFocused to true when a dialog opens. + */ + const show = (dialog: DialogComponent) => { + setStack((prev) => [...prev, dialog]); + setInputFocused(true); + }; + + /** + * Replace the top dialog with a new one. + * If stack is empty, just pushes the dialog. + */ + const replace = (dialog: DialogComponent) => { + setStack((prev) => { + if (prev.length === 0) { + return [dialog]; + } + return [...prev.slice(0, -1), dialog]; + }); + }; + + /** + * Clear all dialogs from the stack. + * Resets inputFocused to false. + */ + const clear = () => { + setStack([]); + setInputFocused(false); + }; + + /** + * Remove the top dialog from the stack. + * Resets inputFocused to false if stack becomes empty. + */ + const pop = () => { + setStack((prev) => { + const newStack = prev.slice(0, -1); + if (newStack.length === 0) { + setInputFocused(false); + } + return newStack; + }); + }; + + const dialogValue: DialogContextValue = { + show, + replace, + clear, + pop, + stack, + hasDialogs, + }; + + const inputFocusValue: InputFocusValue = { + inputFocused, + setInputFocused, + isInputFocused: () => inputFocused(), + }; + + return ( + + + {props.children} + + + ); +} + +/** + * Hook to access the dialog context. + * Must be used within a DialogProvider. + * + * @throws Error if used outside of DialogProvider + */ +export function useDialog(): DialogContextValue { + const context = useContext(DialogContext); + if (!context) { + throw new Error("useDialog must be used within a DialogProvider"); + } + return context; +} + +/** + * Hook to access the input focus context. + * Must be used within a DialogProvider. + * + * @throws Error if used outside of DialogProvider + */ +export function useInputFocus(): InputFocusValue { + const context = useContext(InputFocusContext); + if (!context) { + throw new Error("useInputFocus must be used within a DialogProvider"); + } + return context; +} + +/** + * DialogStack component that renders all dialogs in the stack. + * Each dialog is rendered with proper z-indexing (later dialogs on top). + * Only renders when there are dialogs to show. + * + * IMPORTANT: Uses for reactive conditional rendering. + * An early `if (!hasDialogs()) return null;` would NOT be reactive + * in Solid.js - the component body only runs once, so the dialog + * would never appear when added later. + */ +export function DialogStack() { + const { stack, hasDialogs } = useDialog(); + + return ( + + + {(Dialog, index) => ( + + + + )} + + + ); +} diff --git a/src/context/ToastContext.tsx b/src/context/ToastContext.tsx new file mode 100644 index 0000000..4997731 --- /dev/null +++ b/src/context/ToastContext.tsx @@ -0,0 +1,236 @@ +import { + createContext, + useContext, + createSignal, + For, + onCleanup, + JSX, +} from "solid-js"; +import type { Accessor } from "solid-js"; + +/** + * Toast variant types for styling. + */ +export type ToastVariant = "success" | "error" | "info" | "warning"; + +/** + * Options for showing a toast notification. + */ +export interface ToastOptions { + /** The variant determines the toast's color scheme */ + variant: ToastVariant; + /** The message to display in the toast */ + message: string; + /** Duration in milliseconds before auto-dismiss (default: 3000) */ + duration?: number; +} + +/** + * Internal toast type with unique ID for tracking. + */ +export interface Toast extends ToastOptions { + /** Unique identifier for this toast instance */ + id: string; + /** Whether the toast is currently fading out */ + fading?: boolean; +} + +/** + * Context value interface defining toast operations. + */ +export interface ToastContextValue { + /** Show a new toast notification */ + show: (options: ToastOptions) => void; + /** Current list of active toasts */ + toasts: Accessor; + /** Dismiss a specific toast by ID */ + dismiss: (id: string) => void; + /** Clear all toasts */ + clear: () => void; +} + +// Create the context with undefined default (must be used within provider) +const ToastContext = createContext(); + +/** + * Props for the ToastProvider component. + */ +export interface ToastProviderProps { + children: JSX.Element; + /** Maximum number of visible toasts (default: 3) */ + maxVisible?: number; +} + +/** Counter for generating unique toast IDs */ +let toastIdCounter = 0; + +/** + * Generate a unique toast ID. + */ +function generateToastId(): string { + return `toast-${++toastIdCounter}-${Date.now()}`; +} + +/** + * ToastProvider component that manages toast notifications. + * Wraps children with toast context. + */ +export function ToastProvider(props: ToastProviderProps) { + const maxVisible = props.maxVisible ?? 3; + + // Toasts array signal - stores active toast notifications + const [toasts, setToasts] = createSignal([]); + + // Map to track timeout IDs for each toast (dismiss and fade) + const timeouts = new Map>(); + const fadeTimeouts = new Map>(); + + /** Duration for fade out animation in milliseconds */ + const FADE_DURATION = 300; + + /** + * Clean up all timeouts on unmount. + */ + onCleanup(() => { + timeouts.forEach((timeout) => clearTimeout(timeout)); + timeouts.clear(); + fadeTimeouts.forEach((timeout) => clearTimeout(timeout)); + fadeTimeouts.clear(); + }); + + /** + * Remove a toast immediately without fade animation. + */ + const removeToast = (id: string) => { + // Clear any pending timeouts + const timeout = timeouts.get(id); + if (timeout) { + clearTimeout(timeout); + timeouts.delete(id); + } + const fadeTimeout = fadeTimeouts.get(id); + if (fadeTimeout) { + clearTimeout(fadeTimeout); + fadeTimeouts.delete(id); + } + + // Remove from toasts array + setToasts((prev) => prev.filter((t) => t.id !== id)); + }; + + /** + * Dismiss a toast by ID with fade out animation. + */ + const dismiss = (id: string) => { + // Clear the auto-dismiss timeout if it exists + const timeout = timeouts.get(id); + if (timeout) { + clearTimeout(timeout); + timeouts.delete(id); + } + + // Check if already fading or doesn't exist + const toast = toasts().find((t) => t.id === id); + if (!toast || toast.fading) { + return; + } + + // Mark toast as fading + setToasts((prev) => + prev.map((t) => (t.id === id ? { ...t, fading: true } : t)) + ); + + // After fade duration, remove the toast + const fadeTimeout = setTimeout(() => { + removeToast(id); + }, FADE_DURATION); + + fadeTimeouts.set(id, fadeTimeout); + }; + + /** + * Clear all toasts immediately. + */ + const clear = () => { + // Clear all timeouts + timeouts.forEach((timeout) => clearTimeout(timeout)); + timeouts.clear(); + fadeTimeouts.forEach((timeout) => clearTimeout(timeout)); + fadeTimeouts.clear(); + + // Clear toasts array + setToasts([]); + }; + + /** + * Show a new toast notification. + * Auto-dismisses after the specified duration. + * Limits visible toasts to maxVisible (removes oldest). + */ + const show = (options: ToastOptions) => { + const id = generateToastId(); + const duration = options.duration ?? 3000; + + const toast: Toast = { + ...options, + id, + }; + + // Add toast to array + setToasts((prev) => { + // If we're at max visible, remove the oldest toast immediately + if (prev.length >= maxVisible) { + const oldest = prev[0]; + if (oldest) { + // Clear timeouts for the oldest toast + const oldestTimeout = timeouts.get(oldest.id); + if (oldestTimeout) { + clearTimeout(oldestTimeout); + timeouts.delete(oldest.id); + } + const oldestFadeTimeout = fadeTimeouts.get(oldest.id); + if (oldestFadeTimeout) { + clearTimeout(oldestFadeTimeout); + fadeTimeouts.delete(oldest.id); + } + } + return [...prev.slice(1), toast]; + } + return [...prev, toast]; + }); + + // Set up auto-dismiss timeout + const timeout = setTimeout(() => { + dismiss(id); + }, duration); + + timeouts.set(id, timeout); + }; + + const toastValue: ToastContextValue = { + show, + toasts, + dismiss, + clear, + }; + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the toast context. + * Must be used within a ToastProvider. + * + * @throws Error if used outside of ToastProvider + */ +export function useToast(): ToastContextValue { + const context = useContext(ToastContext); + if (!context) { + throw new Error("useToast must be used within a ToastProvider"); + } + return context; +} diff --git a/src/ui/Dialog.tsx b/src/ui/Dialog.tsx new file mode 100644 index 0000000..ed4dae4 --- /dev/null +++ b/src/ui/Dialog.tsx @@ -0,0 +1,59 @@ +import { useKeyboard } from "@opentui/solid"; +import type { KeyEvent } from "@opentui/core"; +import type { JSX } from "solid-js"; +import { useDialog } from "../context/DialogContext"; +import { useTheme } from "../context/ThemeContext"; + +export type DialogProps = { + /** Dialog content */ + children: JSX.Element; + /** Optional custom border color (defaults to colors.border) */ + borderColor?: string; + /** Optional width as percentage (defaults to "60%") */ + width?: `${number}%` | number | "auto"; + /** Optional callback when dialog is closed via Escape */ + onClose?: () => void; +}; + +/** + * Base dialog component with dark overlay, centered content box, and Escape key handling. + * Used as the foundation for all dialog types (confirm, prompt, alert, etc.). + */ +export function Dialog(props: DialogProps) { + const { pop } = useDialog(); + const { theme } = useTheme(); + + // Handle Escape key to close dialog + useKeyboard((e: KeyEvent) => { + if (e.name === "escape" || e.name === "Escape") { + if (props.onClose) { + props.onClose(); + } + pop(); + } + }); + + const t = theme(); + + return ( + + + {props.children} + + + ); +} diff --git a/src/ui/DialogAlert.tsx b/src/ui/DialogAlert.tsx new file mode 100644 index 0000000..b338fde --- /dev/null +++ b/src/ui/DialogAlert.tsx @@ -0,0 +1,99 @@ +import { useKeyboard } from "@opentui/solid"; +import { TextAttributes } from "@opentui/core"; +import type { KeyEvent } from "@opentui/core"; +import { Dialog } from "./Dialog"; +import { useDialog } from "../context/DialogContext"; +import { useTheme } from "../context/ThemeContext"; + +export type DialogAlertProps = { + /** Dialog title displayed at the top */ + title?: string; + /** Message to display */ + message: string; + /** Callback when user dismisses (Enter, Escape, or button) */ + onDismiss?: () => void; + /** Optional custom border color */ + borderColor?: string; + /** Optional variant for styling (info, success, warning, error) */ + variant?: "info" | "success" | "warning" | "error"; +}; + +/** + * Alert dialog for displaying messages to the user. + * Displays a message and Dismiss button. + * Enter or Escape key dismisses the dialog. + */ +export function DialogAlert(props: DialogAlertProps) { + const { pop } = useDialog(); + const { theme } = useTheme(); + + // Get variant-specific colors + const getVariantColor = () => { + const t = theme(); + switch (props.variant) { + case "success": + return t.success; + case "warning": + return t.warning; + case "error": + return t.error; + case "info": + default: + return t.info; + } + }; + + const handleDismiss = () => { + props.onDismiss?.(); + pop(); + }; + + // Handle Enter/Escape keyboard shortcuts + useKeyboard((e: KeyEvent) => { + // Enter key to dismiss + if (e.name === "return" || e.name === "enter" || e.name === "Enter") { + handleDismiss(); + return; + } + // Escape is also handled by base Dialog, but we handle it here + // to ensure onDismiss is called + if (e.name === "escape" || e.name === "Escape") { + handleDismiss(); + return; + } + }); + + const variantColor = getVariantColor(); + const t = theme(); + + return ( + + {/* Title (optional) */} + {props.title && ( + + + {props.title} + + + )} + + {/* Message */} + + {props.message} + + + {/* Button row */} + + + [ + Enter + ] Dismiss + + + + ); +} diff --git a/src/ui/DialogConfirm.tsx b/src/ui/DialogConfirm.tsx new file mode 100644 index 0000000..5163743 --- /dev/null +++ b/src/ui/DialogConfirm.tsx @@ -0,0 +1,88 @@ +import { useKeyboard } from "@opentui/solid"; +import { TextAttributes } from "@opentui/core"; +import type { KeyEvent } from "@opentui/core"; +import { Dialog } from "./Dialog"; +import { useDialog } from "../context/DialogContext"; +import { useTheme } from "../context/ThemeContext"; + +export type DialogConfirmProps = { + /** Dialog title displayed at the top */ + title: string; + /** Message/question to display */ + message: string; + /** Callback when user confirms (Y key or Confirm button) */ + onConfirm: () => void; + /** Callback when user cancels (N key, Cancel button, or Escape) */ + onCancel: () => void; + /** Optional custom border color */ + borderColor?: string; +}; + +/** + * Confirmation dialog with Y/N keyboard shortcuts. + * Displays a title, message, and Confirm/Cancel buttons. + */ +export function DialogConfirm(props: DialogConfirmProps) { + const { pop } = useDialog(); + const { theme } = useTheme(); + + const handleConfirm = () => { + props.onConfirm(); + pop(); + }; + + const handleCancel = () => { + props.onCancel(); + pop(); + }; + + // Handle Y/N keyboard shortcuts + useKeyboard((e: KeyEvent) => { + // Y key for confirm + if ((e.name === "y" || e.name === "Y") && !e.ctrl && !e.meta) { + handleConfirm(); + return; + } + // N key for cancel + if ((e.name === "n" || e.name === "N") && !e.ctrl && !e.meta) { + handleCancel(); + return; + } + }); + + const t = theme(); + + return ( + + {/* Title */} + + + {props.title} + + + + {/* Message */} + + {props.message} + + + {/* Buttons row */} + + + [ + Y + ] Confirm + + + [ + N + ] Cancel + + + + ); +} diff --git a/src/ui/DialogPrompt.tsx b/src/ui/DialogPrompt.tsx new file mode 100644 index 0000000..a0592e1 --- /dev/null +++ b/src/ui/DialogPrompt.tsx @@ -0,0 +1,115 @@ +import { useKeyboard } from "@opentui/solid"; +import { TextAttributes } from "@opentui/core"; +import type { KeyEvent } from "@opentui/core"; +import { createSignal } from "solid-js"; +import { Dialog } from "./Dialog"; +import { useDialog } from "../context/DialogContext"; +import { useTheme } from "../context/ThemeContext"; + +export type DialogPromptProps = { + /** Dialog title displayed at the top */ + title: string; + /** Placeholder text shown when input is empty */ + placeholder?: string; + /** Initial value for the input field */ + initialValue?: string; + /** Callback when user submits (Enter key or Submit button) */ + onSubmit: (value: string) => void; + /** Callback when user cancels (Escape key or Cancel button) */ + onCancel: () => void; + /** Optional custom border color */ + borderColor?: string; +}; + +/** + * Prompt dialog with text input field. + * Displays a title, text input with placeholder, and Submit/Cancel buttons. + * Enter key submits, Escape key cancels. + */ +export function DialogPrompt(props: DialogPromptProps) { + const { pop } = useDialog(); + const { theme } = useTheme(); + const [input, setInput] = createSignal(props.initialValue || ""); + + const handleSubmit = () => { + const value = input().trim(); + props.onSubmit(value); + pop(); + }; + + const handleCancel = () => { + props.onCancel(); + pop(); + }; + + // Handle keyboard input + useKeyboard((e: KeyEvent) => { + // Enter: submit + if (e.name === "return" || e.name === "enter" || e.name === "Enter") { + handleSubmit(); + return; + } + + // Escape is handled by the base Dialog component, but we also handle it + // here to ensure onCancel is called + if (e.name === "escape" || e.name === "Escape") { + handleCancel(); + return; + } + + // Backspace: delete last character + if (e.name === "backspace" || e.name === "Backspace") { + setInput((prev) => prev.slice(0, -1)); + return; + } + + // Regular character input (single printable characters) + if (e.raw && e.raw.length === 1 && !e.ctrl && !e.meta) { + setInput((prev) => prev + e.raw); + } + }); + + const t = theme(); + + return ( + + {/* Title */} + + + {props.title} + + + + {/* Input box */} + + + {input() || props.placeholder || "Enter text..."} + + + + {/* Buttons row */} + + + [ + Enter + ] Submit + + + [ + Esc + ] Cancel + + + + ); +} diff --git a/src/ui/DialogSelect.tsx b/src/ui/DialogSelect.tsx new file mode 100644 index 0000000..4472106 --- /dev/null +++ b/src/ui/DialogSelect.tsx @@ -0,0 +1,357 @@ +import { useKeyboard } from "@opentui/solid"; +import { TextAttributes } from "@opentui/core"; +import type { KeyEvent } from "@opentui/core"; +import { createSignal, createMemo, For, Show } from "solid-js"; +import fuzzysort from "fuzzysort"; +import { Dialog } from "./Dialog"; +import { useDialog } from "../context/DialogContext"; +import { useTheme } from "../context/ThemeContext"; + +export interface SelectOption { + /** Display title for the option */ + title: string; + /** Unique value identifier */ + value: string; + /** Optional description shown below the title */ + description?: string; + /** Optional keybind hint (e.g., "Ctrl+P") */ + keybind?: string; + /** Whether the option is currently disabled */ + disabled?: boolean; +} + +export type DialogSelectProps = { + /** Dialog title displayed at the top */ + title?: string; + /** Placeholder text for the search input */ + placeholder?: string; + /** Options to display and filter */ + options: SelectOption[]; + /** Callback when an option is selected */ + onSelect: (option: SelectOption) => void; + /** Callback when dialog is cancelled */ + onCancel: () => void; + /** Optional custom border color */ + borderColor?: string; + /** Maximum number of visible results (defaults to 8) */ + maxVisible?: number; +}; + +interface HighlightedPart { + text: string; + highlighted: boolean; +} + +/** + * Fuzzy search select dialog with keyboard navigation. + * Features search input, filtered results list, and highlighted matches. + */ +export function DialogSelect(props: DialogSelectProps) { + const { pop } = useDialog(); + const { theme } = useTheme(); + const [query, setQuery] = createSignal(""); + const [selectedIndex, setSelectedIndex] = createSignal(0); + + const maxVisible = () => props.maxVisible ?? 8; + + // Fuzzy filter options based on query + const filteredOptions = createMemo(() => { + const q = query(); + if (!q) { + // Return all options when no query, preserving original order + return props.options + .filter((opt) => !opt.disabled) + .map((opt) => ({ + option: opt, + highlighted: null as HighlightedPart[] | null, + })); + } + + // Use fuzzysort to filter and score options + const results = fuzzysort.go(q, props.options, { + key: "title", + threshold: 0.2, + }); + + return results.map((result) => { + // Generate highlighted parts using the callback API + const parts: HighlightedPart[] = []; + result.highlight((match, _index) => { + parts.push({ text: match, highlighted: true }); + return match; + }); + + // Build full highlighted parts by reconstructing from indexes + const highlighted = buildHighlightedParts(result.target, result.indexes); + + return { + option: result.obj, + highlighted, + }; + }); + }); + + // Keep selected index in bounds + const clampedIndex = createMemo(() => { + const len = filteredOptions().length; + if (len === 0) return 0; + const idx = selectedIndex(); + return Math.max(0, Math.min(idx, len - 1)); + }); + + // Calculate scroll offset for visible window + const scrollOffset = createMemo(() => { + const idx = clampedIndex(); + const max = maxVisible(); + // Keep selected item visible by scrolling + return Math.max(0, idx - max + 1); + }); + + // Get visible slice of options + const visibleOptions = createMemo(() => { + const offset = scrollOffset(); + return filteredOptions().slice(offset, offset + maxVisible()); + }); + + const handleSelect = () => { + const options = filteredOptions(); + const idx = clampedIndex(); + if (options.length > 0 && options[idx]) { + const selected = options[idx].option; + if (!selected.disabled) { + props.onSelect(selected); + pop(); + } + } + }; + + const handleCancel = () => { + props.onCancel(); + pop(); + }; + + useKeyboard((e: KeyEvent) => { + // Enter: select current option + if (e.name === "return" || e.name === "enter" || e.name === "Enter") { + handleSelect(); + return; + } + + // Escape: cancel (Dialog handles this too, but we call onCancel) + if (e.name === "escape" || e.name === "Escape") { + handleCancel(); + return; + } + + // Up arrow: move selection up + if (e.name === "up" || e.name === "Up") { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + return; + } + + // Down arrow: move selection down + if (e.name === "down" || e.name === "Down") { + setSelectedIndex((prev) => + Math.min(filteredOptions().length - 1, prev + 1) + ); + return; + } + + // Tab: move down (common in command palettes) + if (e.name === "tab" && !e.shift) { + setSelectedIndex((prev) => + Math.min(filteredOptions().length - 1, prev + 1) + ); + return; + } + + // Shift+Tab: move up + if (e.name === "tab" && e.shift) { + setSelectedIndex((prev) => Math.max(0, prev - 1)); + return; + } + + // Backspace: delete last character + if (e.name === "backspace" || e.name === "Backspace") { + setQuery((prev) => prev.slice(0, -1)); + setSelectedIndex(0); // Reset selection on query change + return; + } + + // Regular character input + if (e.raw && e.raw.length === 1 && !e.ctrl && !e.meta) { + setQuery((prev) => prev + e.raw); + setSelectedIndex(0); // Reset selection on query change + } + }); + + const t = theme(); + + return ( + + {/* Title */} + + + + {props.title} + + + + + {/* Search input */} + + + + {query() || props.placeholder || "Type to search..."} + + + + {/* Results list */} + + 0} + fallback={ + + No matching results + + } + > + + {(item, index) => { + const actualIndex = () => scrollOffset() + index(); + const isSelected = () => actualIndex() === clampedIndex(); + + return ( + + {/* Selection indicator */} + + {isSelected() ? "❯ " : " "} + + + {/* Title with optional highlighting */} + + + + {item.option.title} + + } + > + + {(part) => ( + + {part.text} + + )} + + + + + {/* Description if present */} + + {item.option.description} + + + + {/* Keybind hint if present */} + + + [{item.option.keybind}] + + + + ); + }} + + + + + {/* Footer hints */} + + + [ + ↑↓ + ] Navigate + + + [ + Enter + ] Select + + + [ + Esc + ] Cancel + + + + ); +} + +/** + * Build highlighted parts from match indexes. + * Returns an array of text parts with highlight flags. + */ +function buildHighlightedParts( + target: string, + indexes: ReadonlyArray +): HighlightedPart[] { + if (indexes.length === 0) { + return [{ text: target, highlighted: false }]; + } + + const parts: HighlightedPart[] = []; + const indexSet = new Set(indexes); + let currentPart = ""; + let currentHighlighted = indexSet.has(0); + + for (let i = 0; i < target.length; i++) { + const isHighlighted = indexSet.has(i); + if (isHighlighted !== currentHighlighted) { + if (currentPart) { + parts.push({ text: currentPart, highlighted: currentHighlighted }); + } + currentPart = target[i]; + currentHighlighted = isHighlighted; + } else { + currentPart += target[i]; + } + } + + if (currentPart) { + parts.push({ text: currentPart, highlighted: currentHighlighted }); + } + + return parts; +} diff --git a/tests/unit/toast.test.ts b/tests/unit/toast.test.ts new file mode 100644 index 0000000..c18247a --- /dev/null +++ b/tests/unit/toast.test.ts @@ -0,0 +1,178 @@ +import { describe, it, expect, beforeEach, afterEach, mock } from "bun:test"; + +/** + * Tests for toast notification system. + * + * The toast system in ToastContext.tsx uses SolidJS signals and context, + * making it difficult to test in isolation. These tests verify the + * core logic patterns used by the toast system. + */ + +describe("Toast System", () => { + describe("Toast ID generation", () => { + it("should generate unique IDs with incrementing counter", () => { + let counter = 0; + const generateToastId = () => `toast-${++counter}-${Date.now()}`; + + const id1 = generateToastId(); + const id2 = generateToastId(); + + expect(id1).not.toBe(id2); + expect(id1).toMatch(/^toast-1-\d+$/); + expect(id2).toMatch(/^toast-2-\d+$/); + }); + }); + + describe("Toast queue management", () => { + it("should limit visible toasts to maxVisible", () => { + const maxVisible = 3; + let toasts: { id: string; message: string }[] = []; + + const addToast = (message: string) => { + const newToast = { id: `toast-${toasts.length + 1}`, message }; + if (toasts.length >= maxVisible) { + // Remove oldest toast + toasts = [...toasts.slice(1), newToast]; + } else { + toasts = [...toasts, newToast]; + } + }; + + addToast("First"); + addToast("Second"); + addToast("Third"); + expect(toasts.length).toBe(3); + + addToast("Fourth"); // Should remove "First" + expect(toasts.length).toBe(3); + expect(toasts[0]?.message).toBe("Second"); + expect(toasts[2]?.message).toBe("Fourth"); + + addToast("Fifth"); // Should remove "Second" + expect(toasts.length).toBe(3); + expect(toasts[0]?.message).toBe("Third"); + expect(toasts[2]?.message).toBe("Fifth"); + }); + + it("should maintain correct order with newest at end", () => { + let toasts: { id: string; message: string }[] = []; + + const addToast = (message: string) => { + toasts = [...toasts, { id: `toast-${toasts.length + 1}`, message }]; + }; + + addToast("First"); + addToast("Second"); + addToast("Third"); + + expect(toasts[0]?.message).toBe("First"); + expect(toasts[1]?.message).toBe("Second"); + expect(toasts[2]?.message).toBe("Third"); + }); + }); + + describe("Toast dismiss logic", () => { + it("should remove toast by ID", () => { + let toasts = [ + { id: "toast-1", message: "First" }, + { id: "toast-2", message: "Second" }, + { id: "toast-3", message: "Third" }, + ]; + + const removeToast = (id: string) => { + toasts = toasts.filter((t) => t.id !== id); + }; + + removeToast("toast-2"); + + expect(toasts.length).toBe(2); + expect(toasts.map((t) => t.message)).toEqual(["First", "Third"]); + }); + + it("should mark toast as fading before removal", () => { + let toasts = [ + { id: "toast-1", message: "First", fading: false }, + { id: "toast-2", message: "Second", fading: false }, + ]; + + const markFading = (id: string) => { + toasts = toasts.map((t) => + t.id === id ? { ...t, fading: true } : t + ); + }; + + markFading("toast-1"); + + expect(toasts[0]?.fading).toBe(true); + expect(toasts[1]?.fading).toBe(false); + }); + }); + + describe("Toast variants", () => { + it("should support all variant types", () => { + type ToastVariant = "success" | "error" | "info" | "warning"; + + const variants: ToastVariant[] = ["success", "error", "info", "warning"]; + + expect(variants).toContain("success"); + expect(variants).toContain("error"); + expect(variants).toContain("info"); + expect(variants).toContain("warning"); + }); + + it("should map variants to correct icons", () => { + const getVariantIcon = (variant: string): string => { + switch (variant) { + case "success": return "✓"; + case "error": return "✗"; + case "warning": return "⚠"; + case "info": + default: return "ℹ"; + } + }; + + expect(getVariantIcon("success")).toBe("✓"); + expect(getVariantIcon("error")).toBe("✗"); + expect(getVariantIcon("warning")).toBe("⚠"); + expect(getVariantIcon("info")).toBe("ℹ"); + expect(getVariantIcon("unknown")).toBe("ℹ"); // defaults to info + }); + }); + + describe("Auto-dismiss timing", () => { + it("should use default duration of 3000ms", () => { + const DEFAULT_DURATION = 3000; + const duration = undefined; + + const actualDuration = duration ?? DEFAULT_DURATION; + + expect(actualDuration).toBe(3000); + }); + + it("should allow custom duration", () => { + const DEFAULT_DURATION = 3000; + const customDuration = 5000; + + const actualDuration = customDuration ?? DEFAULT_DURATION; + + expect(actualDuration).toBe(5000); + }); + }); + + describe("Clear all toasts", () => { + it("should empty the toasts array", () => { + let toasts = [ + { id: "toast-1", message: "First" }, + { id: "toast-2", message: "Second" }, + ]; + + const clear = () => { + toasts = []; + }; + + clear(); + + expect(toasts.length).toBe(0); + }); + }); +}); From 774f04b3d00d4495bb2e7557d7a63f39cd377680 Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 7 Jan 2026 20:07:17 -0800 Subject: [PATCH 3/7] feat: add command palette with fuzzy search Add VS Code-style command palette for quick action access: - CommandContext for registering and executing commands - Centralized keymap with keybind definitions - Fuzzy search via fuzzysort for filtering commands - Ctrl+P keyboard shortcut to open palette - Support for command categories and shortcuts display Commands can be registered dynamically and include actions like: - Pause/Resume loop execution - Copy attach command - Toggle tasks panel - Choose default terminal --- bun.lock | 3 + package.json | 3 +- src/context/CommandContext.tsx | 184 +++++++++++++++++++++++++++++ src/lib/keymap.ts | 102 ++++++++++++++++ tests/unit/command-palette.test.ts | 131 ++++++++++++++++++++ 5 files changed, 422 insertions(+), 1 deletion(-) create mode 100644 src/context/CommandContext.tsx create mode 100644 src/lib/keymap.ts create mode 100644 tests/unit/command-palette.test.ts diff --git a/bun.lock b/bun.lock index f4d55ce..955a95b 100644 --- a/bun.lock +++ b/bun.lock @@ -8,6 +8,7 @@ "@opencode-ai/sdk": "1.1.1", "@opentui/core": "0.1.68", "@opentui/solid": "0.1.68", + "fuzzysort": "^3.1.0", "solid-js": "^1.9.0", "yargs": "^18.0.0", }, @@ -253,6 +254,8 @@ "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], + "fuzzysort": ["fuzzysort@3.1.0", "", {}, "sha512-sR9BNCjBg6LNgwvxlBd0sBABvQitkLzoVY9MYYROQVX/FvfJ4Mai9LsGhDgd8qYdds0bY77VzYd5iuB+v5rwQQ=="], + "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], "get-caller-file": ["get-caller-file@2.0.5", "", {}, "sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg=="], diff --git a/package.json b/package.json index 71d4423..50ca43f 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hona/ralph-cli", - "version": "0.1.0", + "version": "1.0.5", "type": "module", "bin": { "ralph": "./bin/ralph" @@ -19,6 +19,7 @@ "@opencode-ai/sdk": "1.1.1", "@opentui/core": "0.1.68", "@opentui/solid": "0.1.68", + "fuzzysort": "^3.1.0", "solid-js": "^1.9.0", "yargs": "^18.0.0" }, diff --git a/src/context/CommandContext.tsx b/src/context/CommandContext.tsx new file mode 100644 index 0000000..0cbd114 --- /dev/null +++ b/src/context/CommandContext.tsx @@ -0,0 +1,184 @@ +import { + createContext, + useContext, + createSignal, + JSX, +} from "solid-js"; +import type { Accessor } from "solid-js"; + +/** + * Interface for a command option that can be displayed in the command palette. + */ +export interface CommandOption { + /** Display title for the command */ + title: string; + /** Unique value identifier for the command */ + value: string; + /** Optional description shown below the title */ + description?: string; + /** Optional category for grouping commands */ + category?: string; + /** Optional keybind hint (e.g., "Ctrl+P") */ + keybind?: string; + /** Whether the command is currently disabled */ + disabled?: boolean; + /** Callback executed when the command is selected */ + onSelect: () => void; +} + +/** + * Factory function type that returns an array of command options. + * Allows dynamic command generation based on current state. + */ +export type CommandFactory = () => CommandOption[]; + +/** + * Context value interface defining all command operations. + */ +export interface CommandContextValue { + /** Register a command factory that provides command options */ + register: (id: string, factory: CommandFactory) => () => void; + /** Open the command palette dialog */ + show: () => void; + /** Execute a command by its value */ + trigger: (value: string) => boolean; + /** Accessor for whether keybinds are currently suspended */ + suspended: Accessor; + /** Enable or disable global keybinds */ + keybinds: (enabled: boolean) => void; + /** Get all currently registered keybinds */ + getKeybinds: () => Map; + /** Get all available commands */ + getCommands: () => CommandOption[]; +} + +// Create the context with undefined default (must be used within provider) +const CommandContext = createContext(); + +/** + * Props for the CommandProvider component. + */ +export interface CommandProviderProps { + children: JSX.Element; + /** Callback to open the command palette UI */ + onShowPalette?: () => void; +} + +/** + * CommandProvider component that manages command registration and execution. + * Wraps children with command context. + */ +export function CommandProvider(props: CommandProviderProps) { + // Map of registered command factories keyed by ID + const [factories, setFactories] = createSignal>( + new Map() + ); + + // Whether global keybinds are currently suspended (e.g., when dialog open) + const [suspended, setSuspended] = createSignal(false); + + /** + * Register a command factory that provides command options. + * Returns an unregister function. + */ + const register = (id: string, factory: CommandFactory): (() => void) => { + setFactories((prev) => { + const next = new Map(prev); + next.set(id, factory); + return next; + }); + + // Return unregister function + return () => { + setFactories((prev) => { + const next = new Map(prev); + next.delete(id); + return next; + }); + }; + }; + + /** + * Get all available commands from all registered factories. + */ + const getCommands = (): CommandOption[] => { + const commands: CommandOption[] = []; + for (const factory of factories().values()) { + commands.push(...factory()); + } + return commands; + }; + + /** + * Get a map of keybinds to their command options. + * Only includes commands with defined keybinds. + */ + const getKeybinds = (): Map => { + const keybindMap = new Map(); + for (const command of getCommands()) { + if (command.keybind && !command.disabled) { + keybindMap.set(command.keybind, command); + } + } + return keybindMap; + }; + + /** + * Open the command palette dialog. + */ + const show = () => { + props.onShowPalette?.(); + }; + + /** + * Execute a command by its value. + * Returns true if command was found and executed, false otherwise. + */ + const trigger = (value: string): boolean => { + const commands = getCommands(); + const command = commands.find((c) => c.value === value && !c.disabled); + if (command) { + command.onSelect(); + return true; + } + return false; + }; + + /** + * Enable or disable global keybinds. + * When disabled (suspended=true), keybinds should not be processed. + */ + const keybinds = (enabled: boolean) => { + setSuspended(!enabled); + }; + + const commandValue: CommandContextValue = { + register, + show, + trigger, + suspended, + keybinds, + getKeybinds, + getCommands, + }; + + return ( + + {props.children} + + ); +} + +/** + * Hook to access the command context. + * Must be used within a CommandProvider. + * + * @throws Error if used outside of CommandProvider + */ +export function useCommand(): CommandContextValue { + const context = useContext(CommandContext); + if (!context) { + throw new Error("useCommand must be used within a CommandProvider"); + } + return context; +} diff --git a/src/lib/keymap.ts b/src/lib/keymap.ts new file mode 100644 index 0000000..e908006 --- /dev/null +++ b/src/lib/keymap.ts @@ -0,0 +1,102 @@ +/** + * Centralized keybind definitions for the application. + * This file serves as a single source of truth for all keyboard shortcuts. + */ + +/** + * Keybind definition with key combination and display label. + */ +export interface KeybindDef { + /** Key name (e.g., "p", "t", "c") */ + key: string; + /** Whether Ctrl modifier is required */ + ctrl?: boolean; + /** Whether Shift modifier is required */ + shift?: boolean; + /** Whether Meta/Cmd modifier is required */ + meta?: boolean; + /** Human-readable label for display (e.g., "Ctrl+P") */ + label: string; +} + +/** + * All application keybinds defined in one place. + * Add new keybinds here to ensure consistency across the app. + */ +export const keymap = { + /** Copy the attach command to clipboard */ + copyAttach: { + key: "c", + shift: true, + label: "Shift+C", + } as KeybindDef, + + /** Open terminal configuration dialog */ + terminalConfig: { + key: "t", + label: "T", + } as KeybindDef, + + /** Toggle the tasks panel visibility */ + toggleTasks: { + key: "t", + shift: true, + label: "Shift+T", + } as KeybindDef, + + /** Toggle pause/resume state */ + togglePause: { + key: "p", + label: "P", + } as KeybindDef, + + /** Quit the application */ + quit: { + key: "q", + label: "Q", + } as KeybindDef, + + /** Open steering mode for sending messages */ + steer: { + key: ":", + label: ":", + } as KeybindDef, + + /** Open command palette */ + commandPalette: { + key: "c", + label: "C", + } as KeybindDef, +} as const; + +/** + * Type for keymap keys. + */ +export type KeymapKey = keyof typeof keymap; + +/** + * Check if a key event matches a keybind definition. + * @param e - The key event to check (must have name, ctrl, shift, meta properties) + * @param keybind - The keybind definition to match against + * @returns true if the key event matches the keybind + */ +export function matchesKeybind( + e: { name: string; ctrl?: boolean; shift?: boolean; meta?: boolean }, + keybind: KeybindDef +): boolean { + const keyMatches = e.name.toLowerCase() === keybind.key.toLowerCase(); + const ctrlMatches = !!e.ctrl === !!keybind.ctrl; + const shiftMatches = !!e.shift === !!keybind.shift; + const metaMatches = !!e.meta === !!keybind.meta; + + return keyMatches && ctrlMatches && shiftMatches && metaMatches; +} + +/** + * Get a formatted keybind string for display purposes. + * @param keybind - The keybind definition + * @returns Formatted string like "Ctrl+Shift+P" + */ +export function formatKeybind(keybind: KeybindDef): string { + return keybind.label; +} diff --git a/tests/unit/command-palette.test.ts b/tests/unit/command-palette.test.ts new file mode 100644 index 0000000..75e3fe9 --- /dev/null +++ b/tests/unit/command-palette.test.ts @@ -0,0 +1,131 @@ +import { describe, test, expect } from "bun:test"; +import fuzzysort from "fuzzysort"; + +/** + * Unit tests for command palette fuzzy filtering behavior. + * Tests the core fuzzysort integration used by DialogSelect. + */ +describe("Command Palette", () => { + // Sample commands matching what's registered in app.tsx + const sampleCommands = [ + { title: "Pause", value: "togglePause" }, + { title: "Resume", value: "togglePause" }, + { title: "Copy attach command", value: "copyAttach" }, + { title: "Choose default terminal", value: "terminalConfig" }, + { title: "Toggle tasks panel", value: "toggleTasks" }, + ]; + + describe("fuzzy filtering", () => { + test("returns all commands when query is empty", () => { + const query = ""; + // Empty query = no filtering (return all) + expect(query).toBe(""); + // DialogSelect returns all non-disabled options when query is empty + }); + + test("filters commands by partial match", () => { + const results = fuzzysort.go("paus", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + expect(results.length).toBe(1); + expect(results[0].obj.title).toBe("Pause"); + }); + + test("filters by multiple character match", () => { + const results = fuzzysort.go("tog", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + // Should match "Toggle tasks panel" + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results.some(r => r.obj.title === "Toggle tasks panel")).toBe(true); + }); + + test("filters case-insensitively", () => { + const results = fuzzysort.go("COPY", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + expect(results.length).toBe(1); + expect(results[0].obj.title).toBe("Copy attach command"); + }); + + test("handles fuzzy matching with skipped characters", () => { + const results = fuzzysort.go("cpy", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + // "cpy" should match "Copy" (c-o-p-y with skipped o) + expect(results.length).toBeGreaterThanOrEqual(1); + expect(results[0].obj.title).toBe("Copy attach command"); + }); + + test("returns empty array for non-matching query", () => { + const results = fuzzysort.go("xyz123", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + expect(results.length).toBe(0); + }); + + test("scores exact prefix matches higher", () => { + const results = fuzzysort.go("Cop", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + // Should find "Copy attach command" + expect(results.length).toBe(1); + expect(results[0].obj.title).toBe("Copy attach command"); + }); + }); + + describe("highlight extraction", () => { + test("provides match indexes for highlighting", () => { + const results = fuzzysort.go("paus", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + expect(results.length).toBe(1); + // indexes should contain the positions of matched characters + expect(results[0].indexes).toBeDefined(); + expect(results[0].indexes.length).toBeGreaterThan(0); + }); + + test("highlight callback receives matched substrings", () => { + const results = fuzzysort.go("task", sampleCommands, { + key: "title", + threshold: 0.2, + }); + + expect(results.length).toBeGreaterThanOrEqual(1); + + const parts: string[] = []; + results[0].highlight((match) => { + parts.push(match); + return match; + }); + + // Should have highlighted parts + expect(parts.length).toBeGreaterThan(0); + }); + }); + + describe("command option structure", () => { + test("commands have required fields", () => { + sampleCommands.forEach(cmd => { + expect(typeof cmd.title).toBe("string"); + expect(typeof cmd.value).toBe("string"); + expect(cmd.title.length).toBeGreaterThan(0); + expect(cmd.value.length).toBeGreaterThan(0); + }); + }); + }); +}); From 98d75204e2f97d57a2dfa69a037aff0933ee215e Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 7 Jan 2026 20:07:28 -0800 Subject: [PATCH 4/7] feat: add terminal launcher and clipboard support Add cross-platform utilities for terminal and clipboard operations: Terminal Launcher: - Detect installed terminals on macOS, Linux, and Windows - Support for popular terminals: Kitty, Alacritty, WezTerm, iTerm2, etc. - Terminal selection dialog for choosing preferred terminal - Launch terminal with opencode attach command Clipboard: - Cross-platform clipboard detection (pbcopy, xclip, xsel, wl-copy) - Async copy function with fallback handling - Integration with toast notifications for feedback Config: - Persistent config module for user preferences - Terminal preference storage in .ralph-config.json --- src/lib/clipboard.ts | 198 +++++++++++++++ src/lib/config.ts | 129 ++++++++++ src/lib/terminal-launcher.ts | 246 ++++++++++++++++++ src/ui/DialogTerminalConfig.tsx | 143 +++++++++++ tests/integration/terminal-detection.test.ts | 253 +++++++++++++++++++ tests/unit/clipboard.test.ts | 172 +++++++++++++ tests/unit/terminal-launcher.test.ts | 249 ++++++++++++++++++ 7 files changed, 1390 insertions(+) create mode 100644 src/lib/clipboard.ts create mode 100644 src/lib/config.ts create mode 100644 src/lib/terminal-launcher.ts create mode 100644 src/ui/DialogTerminalConfig.tsx create mode 100644 tests/integration/terminal-detection.test.ts create mode 100644 tests/unit/clipboard.test.ts create mode 100644 tests/unit/terminal-launcher.test.ts diff --git a/src/lib/clipboard.ts b/src/lib/clipboard.ts new file mode 100644 index 0000000..5b48132 --- /dev/null +++ b/src/lib/clipboard.ts @@ -0,0 +1,198 @@ +/** + * Clipboard module for cross-platform clipboard operations. + * Supports Linux (Wayland and X11), macOS, and Windows. + */ + +import { $ } from "bun"; + +/** + * Available clipboard tools. + */ +export type ClipboardTool = + | "wl-copy" // Linux Wayland + | "xclip" // Linux X11 + | "xsel" // Linux X11 fallback + | "pbcopy" // macOS + | "clip" // Windows + | null; // No tool available + +/** + * Result of a clipboard operation. + */ +export interface ClipboardResult { + success: boolean; + error?: string; +} + +/** + * Cache for detected clipboard tool. + */ +let detectedToolCache: ClipboardTool | undefined; + +/** + * Check if a command is available in PATH. + */ +async function commandExists(command: string): Promise { + try { + if (process.platform === "win32") { + const result = await $`where ${command}`.quiet(); + return result.exitCode === 0; + } else { + const result = await $`which ${command}`.quiet(); + return result.exitCode === 0; + } + } catch { + return false; + } +} + +/** + * Detect the available clipboard tool for the current platform. + * Results are cached after the first call. + */ +export async function detectClipboardTool(): Promise { + if (detectedToolCache !== undefined) { + return detectedToolCache; + } + + const platform = process.platform; + + if (platform === "darwin") { + // macOS always has pbcopy + detectedToolCache = "pbcopy"; + return detectedToolCache; + } + + if (platform === "win32") { + // Windows always has clip + detectedToolCache = "clip"; + return detectedToolCache; + } + + // Linux - check for Wayland first, then X11 tools + if (platform === "linux") { + // Check WAYLAND_DISPLAY for Wayland session + if (process.env.WAYLAND_DISPLAY) { + if (await commandExists("wl-copy")) { + detectedToolCache = "wl-copy"; + return detectedToolCache; + } + } + + // Fall back to X11 tools + if (await commandExists("xclip")) { + detectedToolCache = "xclip"; + return detectedToolCache; + } + + if (await commandExists("xsel")) { + detectedToolCache = "xsel"; + return detectedToolCache; + } + } + + // No tool available + detectedToolCache = null; + return detectedToolCache; +} + +/** + * Clear the detected clipboard tool cache. + * Useful for testing or after system changes. + */ +export function clearClipboardCache(): void { + detectedToolCache = undefined; +} + +/** + * Copy text to the system clipboard. + * + * @param text - The text to copy to clipboard + * @returns Result indicating success or failure + */ +export async function copyToClipboard(text: string): Promise { + const tool = await detectClipboardTool(); + + if (!tool) { + return { + success: false, + error: "No clipboard tool available. Install xclip, xsel, or wl-copy.", + }; + } + + try { + let proc: ReturnType; + + switch (tool) { + case "wl-copy": + proc = Bun.spawn(["wl-copy"], { + stdin: "pipe", + stdout: "ignore", + stderr: "pipe", + }); + break; + + case "xclip": + proc = Bun.spawn(["xclip", "-selection", "clipboard"], { + stdin: "pipe", + stdout: "ignore", + stderr: "pipe", + }); + break; + + case "xsel": + proc = Bun.spawn(["xsel", "--clipboard", "--input"], { + stdin: "pipe", + stdout: "ignore", + stderr: "pipe", + }); + break; + + case "pbcopy": + proc = Bun.spawn(["pbcopy"], { + stdin: "pipe", + stdout: "ignore", + stderr: "pipe", + }); + break; + + case "clip": + proc = Bun.spawn(["clip"], { + stdin: "pipe", + stdout: "ignore", + stderr: "pipe", + }); + break; + } + + // Write text to stdin + if (proc.stdin && typeof proc.stdin !== "number") { + proc.stdin.write(text); + proc.stdin.end(); + } + + // Wait for process to complete + const exitCode = await proc.exited; + + if (exitCode !== 0) { + if (proc.stderr && typeof proc.stderr !== "number") { + const stderr = await new Response(proc.stderr).text(); + return { + success: false, + error: stderr || `Clipboard tool exited with code ${exitCode}`, + }; + } + return { + success: false, + error: `Clipboard tool exited with code ${exitCode}`, + }; + } + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} diff --git a/src/lib/config.ts b/src/lib/config.ts new file mode 100644 index 0000000..89b7e76 --- /dev/null +++ b/src/lib/config.ts @@ -0,0 +1,129 @@ +/** + * Configuration persistence module for Ralph. + * Handles reading and writing user preferences to ~/.config/ralph/config.json + */ + +import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs"; +import { join, dirname } from "path"; +import { homedir } from "os"; + +/** + * Ralph configuration schema. + * This extends the RalphConfig type used for CLI defaults. + */ +export interface RalphConfig { + // CLI defaults (also used by index.ts loadGlobalConfig) + model?: string; + plan?: string; + prompt?: string; + promptFile?: string; + server?: string; + serverTimeout?: number; + agent?: string; + + // Terminal launcher preferences + /** Name of the preferred terminal (must match KnownTerminal.name) */ + preferredTerminal?: string; + /** Custom terminal command with {cmd} placeholder for attach command */ + customTerminalCommand?: string; +} + +/** + * Path to the Ralph configuration file. + */ +export const CONFIG_PATH = join(homedir(), ".config", "ralph", "config.json"); + +/** + * Load the Ralph configuration from disk. + * Returns an empty config object if the file doesn't exist or is invalid. + */ +export function loadConfig(): RalphConfig { + if (!existsSync(CONFIG_PATH)) { + return {}; + } + + try { + const content = readFileSync(CONFIG_PATH, "utf-8"); + return JSON.parse(content) as RalphConfig; + } catch { + // Silently ignore invalid config (malformed JSON, etc.) + return {}; + } +} + +/** + * Save the Ralph configuration to disk. + * Creates the config directory if it doesn't exist. + * + * @param config - The configuration object to save + */ +export function saveConfig(config: RalphConfig): void { + const configDir = dirname(CONFIG_PATH); + + // Ensure config directory exists + if (!existsSync(configDir)) { + mkdirSync(configDir, { recursive: true }); + } + + // Write config with pretty formatting + writeFileSync(CONFIG_PATH, JSON.stringify(config, null, 2), "utf-8"); +} + +/** + * Update specific fields in the config while preserving others. + * Convenience wrapper around loadConfig + saveConfig. + * + * @param updates - Partial config object with fields to update + */ +export function updateConfig(updates: Partial): void { + const existing = loadConfig(); + const merged = { ...existing, ...updates }; + saveConfig(merged); +} + +/** + * Get the preferred terminal name from config. + * Returns undefined if not set. + */ +export function getPreferredTerminal(): string | undefined { + const config = loadConfig(); + return config.preferredTerminal; +} + +/** + * Set the preferred terminal name in config. + * + * @param terminalName - Name of the terminal (must match KnownTerminal.name) + */ +export function setPreferredTerminal(terminalName: string): void { + updateConfig({ preferredTerminal: terminalName }); +} + +/** + * Get the custom terminal command from config. + * Returns undefined if not set. + */ +export function getCustomTerminalCommand(): string | undefined { + const config = loadConfig(); + return config.customTerminalCommand; +} + +/** + * Set a custom terminal command in config. + * The command should include {cmd} as a placeholder for the attach command. + * + * @param command - Custom terminal command with {cmd} placeholder + */ +export function setCustomTerminalCommand(command: string): void { + updateConfig({ customTerminalCommand: command }); +} + +/** + * Clear terminal preferences (both preferred terminal and custom command). + */ +export function clearTerminalPreferences(): void { + const config = loadConfig(); + delete config.preferredTerminal; + delete config.customTerminalCommand; + saveConfig(config); +} diff --git a/src/lib/terminal-launcher.ts b/src/lib/terminal-launcher.ts new file mode 100644 index 0000000..f092021 --- /dev/null +++ b/src/lib/terminal-launcher.ts @@ -0,0 +1,246 @@ +/** + * Terminal launcher module for launching external terminals with attach commands. + * Supports auto-detection of installed terminals across Linux, macOS, and Windows. + */ + +import { $ } from "bun"; + +/** + * Known terminal definition with launch command template. + */ +export interface KnownTerminal { + /** Display name of the terminal */ + name: string; + /** Command to execute (binary name) */ + command: string; + /** Arguments array with {cmd} placeholder for the attach command */ + args: string[]; + /** Platforms this terminal is available on */ + platforms: ("darwin" | "linux" | "win32")[]; +} + +/** + * All known terminals with their launch configurations. + * The {cmd} placeholder will be replaced with the actual attach command. + */ +export const knownTerminals: KnownTerminal[] = [ + // Cross-platform terminals + { + name: "Alacritty", + command: "alacritty", + args: ["-e", "sh", "-c", "{cmd}"], + platforms: ["darwin", "linux", "win32"], + }, + { + name: "Kitty", + command: "kitty", + args: ["sh", "-c", "{cmd}"], + platforms: ["darwin", "linux"], + }, + { + name: "WezTerm", + command: "wezterm", + args: ["start", "--", "sh", "-c", "{cmd}"], + platforms: ["darwin", "linux", "win32"], + }, + + // Linux terminals + { + name: "GNOME Terminal", + command: "gnome-terminal", + args: ["--", "sh", "-c", "{cmd}"], + platforms: ["linux"], + }, + { + name: "Konsole", + command: "konsole", + args: ["-e", "sh", "-c", "{cmd}"], + platforms: ["linux"], + }, + { + name: "xfce4-terminal", + command: "xfce4-terminal", + args: ["-e", "sh -c '{cmd}'"], + platforms: ["linux"], + }, + { + name: "Foot", + command: "foot", + args: ["sh", "-c", "{cmd}"], + platforms: ["linux"], + }, + { + name: "Tilix", + command: "tilix", + args: ["-e", "sh -c '{cmd}'"], + platforms: ["linux"], + }, + { + name: "Terminator", + command: "terminator", + args: ["-e", "sh -c '{cmd}'"], + platforms: ["linux"], + }, + { + name: "xterm", + command: "xterm", + args: ["-e", "sh", "-c", "{cmd}"], + platforms: ["linux"], + }, + { + name: "urxvt", + command: "urxvt", + args: ["-e", "sh", "-c", "{cmd}"], + platforms: ["linux"], + }, + { + name: "x-terminal-emulator", + command: "x-terminal-emulator", + args: ["-e", "sh", "-c", "{cmd}"], + platforms: ["linux"], + }, + + // macOS terminals + { + name: "Terminal.app", + command: "open", + args: ["-a", "Terminal", "{cmd}"], + platforms: ["darwin"], + }, + { + name: "iTerm2", + command: "open", + args: ["-a", "iTerm", "{cmd}"], + platforms: ["darwin"], + }, + + // Windows terminals + { + name: "Windows Terminal", + command: "wt", + args: ["-d", ".", "cmd", "/c", "{cmd}"], + platforms: ["win32"], + }, + { + name: "Command Prompt", + command: "cmd", + args: ["/c", "start", "cmd", "/k", "{cmd}"], + platforms: ["win32"], + }, +]; + +/** + * Cache for detected terminals. + */ +let detectedTerminalsCache: KnownTerminal[] | null = null; + +/** + * Check if a command is available in PATH. + */ +async function commandExists(command: string): Promise { + try { + if (process.platform === "win32") { + const result = await $`where ${command}`.quiet(); + return result.exitCode === 0; + } else { + const result = await $`which ${command}`.quiet(); + return result.exitCode === 0; + } + } catch { + return false; + } +} + +/** + * Detect which terminals are installed on the current system. + * Results are cached after the first call. + */ +export async function detectInstalledTerminals(): Promise { + if (detectedTerminalsCache !== null) { + return detectedTerminalsCache; + } + + const currentPlatform = process.platform as "darwin" | "linux" | "win32"; + const platformTerminals = knownTerminals.filter((t) => + t.platforms.includes(currentPlatform) + ); + + const results = await Promise.all( + platformTerminals.map(async (terminal) => { + const exists = await commandExists(terminal.command); + return exists ? terminal : null; + }) + ); + + detectedTerminalsCache = results.filter( + (t): t is KnownTerminal => t !== null + ); + return detectedTerminalsCache; +} + +/** + * Clear the detected terminals cache. + * Useful for testing or after system changes. + */ +export function clearTerminalCache(): void { + detectedTerminalsCache = null; +} + +/** + * Result of a terminal launch attempt. + */ +export interface LaunchResult { + success: boolean; + error?: string; +} + +/** + * Launch a terminal with the given command. + * + * @param terminal - The terminal definition to use + * @param cmd - The command to run in the terminal + * @returns Result indicating success or failure + */ +export async function launchTerminal( + terminal: KnownTerminal, + cmd: string +): Promise { + try { + // Build args array with {cmd} placeholder replacement + const args = terminal.args.map((arg) => arg.replace("{cmd}", cmd)); + + // Spawn detached process + const proc = Bun.spawn([terminal.command, ...args], { + stdin: "ignore", + stdout: "ignore", + stderr: "ignore", + }); + + // Unref so parent process can exit + proc.unref(); + + return { success: true }; + } catch (error) { + return { + success: false, + error: error instanceof Error ? error.message : String(error), + }; + } +} + +/** + * Generate the attach command string for connecting to an OpenCode session. + * + * @param serverUrl - The OpenCode server URL + * @param sessionId - The session ID to attach to (optional) + * @returns The formatted attach command + */ +export function getAttachCommand( + serverUrl: string, + sessionId?: string +): string { + if (sessionId) { + return `opencode attach ${serverUrl} --session ${sessionId}`; + } + return `opencode attach ${serverUrl}`; +} diff --git a/src/ui/DialogTerminalConfig.tsx b/src/ui/DialogTerminalConfig.tsx new file mode 100644 index 0000000..1775d60 --- /dev/null +++ b/src/ui/DialogTerminalConfig.tsx @@ -0,0 +1,143 @@ +import { createSignal, onMount, Show } from "solid-js"; +import { DialogSelect, SelectOption } from "./DialogSelect"; +import { DialogPrompt } from "./DialogPrompt"; +import { useDialog } from "../context/DialogContext"; +import { useTheme } from "../context/ThemeContext"; +import { + detectInstalledTerminals, + type KnownTerminal, +} from "../lib/terminal-launcher"; +import { + setPreferredTerminal, + setCustomTerminalCommand, +} from "../lib/config"; + +export interface DialogTerminalConfigProps { + /** Callback when terminal is selected (terminal name or "custom" or "clipboard") */ + onSelect: (result: TerminalConfigResult) => void; + /** Callback when dialog is cancelled */ + onCancel: () => void; + /** The attach command to copy or launch with */ + attachCommand?: string; +} + +export type TerminalConfigResult = + | { type: "terminal"; terminal: KnownTerminal } + | { type: "custom"; command: string } + | { type: "clipboard" }; + +/** + * Terminal configuration dialog. + * Lists detected terminals, with options for custom command or clipboard copy. + * Saves selection to config for future use. + */ +export function DialogTerminalConfig(props: DialogTerminalConfigProps) { + const { replace } = useDialog(); + const { theme } = useTheme(); + const [terminals, setTerminals] = createSignal([]); + const [loading, setLoading] = createSignal(true); + + onMount(async () => { + const detected = await detectInstalledTerminals(); + setTerminals(detected); + setLoading(false); + }); + + // Build options from detected terminals + special options + const buildOptions = (): SelectOption[] => { + const options: SelectOption[] = []; + + // Add detected terminals + for (const terminal of terminals()) { + options.push({ + title: terminal.name, + value: `terminal:${terminal.name}`, + description: terminal.command, + }); + } + + // Add divider-like separator (disabled option) + if (terminals().length > 0) { + options.push({ + title: "───────────────────", + value: "__separator__", + disabled: true, + }); + } + + // Add special options + options.push({ + title: "Custom command...", + value: "custom", + description: "Enter a custom terminal command", + }); + + options.push({ + title: "Copy to clipboard", + value: "clipboard", + description: "Copy attach command to clipboard", + keybind: "c", + }); + + return options; + }; + + const handleSelect = (option: SelectOption) => { + if (option.value === "clipboard") { + props.onSelect({ type: "clipboard" }); + return; + } + + if (option.value === "custom") { + // Replace with custom command prompt dialog + replace(() => ( + { + setCustomTerminalCommand(command); + props.onSelect({ type: "custom", command }); + }} + onCancel={props.onCancel} + /> + )); + return; + } + + // Terminal selection + if (option.value.startsWith("terminal:")) { + const terminalName = option.value.replace("terminal:", ""); + const terminal = terminals().find((t) => t.name === terminalName); + if (terminal) { + setPreferredTerminal(terminal.name); + props.onSelect({ type: "terminal", terminal }); + } + } + }; + + const t = theme(); + + return ( + {}} + onCancel={props.onCancel} + borderColor={t.accent} + /> + } + > + + + ); +} diff --git a/tests/integration/terminal-detection.test.ts b/tests/integration/terminal-detection.test.ts new file mode 100644 index 0000000..846bcf9 --- /dev/null +++ b/tests/integration/terminal-detection.test.ts @@ -0,0 +1,253 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { + detectInstalledTerminals, + clearTerminalCache, + knownTerminals, + launchTerminal, + getAttachCommand, + type KnownTerminal, +} from "../../src/lib/terminal-launcher"; + +/** + * Integration tests for terminal detection across different systems. + * These tests verify that terminal detection works correctly on the + * actual system where the tests are running (linux/darwin/win32). + */ +describe("terminal detection integration", () => { + const currentPlatform = process.platform as "darwin" | "linux" | "win32"; + + beforeEach(() => { + // Clear cache before each test to ensure fresh detection + clearTerminalCache(); + }); + + describe(`platform: ${currentPlatform}`, () => { + it("should detect at least one terminal on this system", async () => { + // Most development machines have at least one terminal installed + // This may fail on minimal/headless systems without any terminal + const terminals = await detectInstalledTerminals(); + + // Log what was detected for debugging purposes + console.log( + `Detected terminals on ${currentPlatform}:`, + terminals.map((t) => t.name) + ); + + // On a typical development machine, we expect at least one terminal + // Comment: This test validates the real system environment + expect(terminals.length).toBeGreaterThanOrEqual(0); // Graceful - don't fail on headless systems + }); + + it("should only detect terminals available for current platform", async () => { + const terminals = await detectInstalledTerminals(); + + for (const terminal of terminals) { + expect(terminal.platforms).toContain(currentPlatform); + } + }); + + it("should detect terminals that actually exist in PATH", async () => { + const terminals = await detectInstalledTerminals(); + + // For each detected terminal, verify it exists using 'which' (unix) or 'where' (windows) + for (const terminal of terminals) { + const proc = Bun.spawn( + currentPlatform === "win32" + ? ["where", terminal.command] + : ["which", terminal.command], + { + stdout: "pipe", + stderr: "pipe", + } + ); + + // Wait for process to complete + const exitCode = await proc.exited; + + // Detected terminal should be findable + expect(exitCode).toBe(0); + } + }); + + it("should return consistent results on repeated calls (caching)", async () => { + // First detection + const firstResult = await detectInstalledTerminals(); + + // Second call should return cached result (same reference) + const secondResult = await detectInstalledTerminals(); + + expect(firstResult).toBe(secondResult); // Same array reference + + // After clearing cache, should get new array with same content + clearTerminalCache(); + const thirdResult = await detectInstalledTerminals(); + + expect(thirdResult).not.toBe(firstResult); // Different reference + expect(thirdResult).toEqual(firstResult); // Same content + }); + + // Platform-specific expected terminals + if (currentPlatform === "linux") { + describe("linux-specific detection", () => { + it("should potentially detect common linux terminals", async () => { + const terminals = await detectInstalledTerminals(); + const terminalNames = terminals.map((t) => t.name); + + // Log available terminals (informational - test doesn't fail if none match) + console.log("Linux terminals found:", terminalNames); + + // Common linux terminals - at least one should likely be present + const commonLinuxTerminals = [ + "GNOME Terminal", + "Konsole", + "xfce4-terminal", + "xterm", + "Alacritty", + "Kitty", + "WezTerm", + "Foot", + "Tilix", + "Terminator", + "urxvt", + "x-terminal-emulator", + ]; + + // This is informational - we don't assert which specific terminal exists + // because it depends on the system configuration + const hasCommonTerminal = commonLinuxTerminals.some((name) => + terminalNames.includes(name) + ); + console.log("Has common Linux terminal:", hasCommonTerminal); + }); + }); + } + + if (currentPlatform === "darwin") { + describe("darwin-specific detection", () => { + it("should detect Terminal.app on macOS", async () => { + // Terminal.app is always present on macOS + const terminals = await detectInstalledTerminals(); + const terminalNames = terminals.map((t) => t.name); + + console.log("macOS terminals found:", terminalNames); + + // Terminal.app should always exist on macOS + // Note: Detection uses 'open -a Terminal' which should always work + expect(terminalNames).toContain("Terminal.app"); + }); + }); + } + + if (currentPlatform === "win32") { + describe("win32-specific detection", () => { + it("should detect cmd.exe on Windows", async () => { + // cmd.exe is always present on Windows + const terminals = await detectInstalledTerminals(); + const terminalNames = terminals.map((t) => t.name); + + console.log("Windows terminals found:", terminalNames); + + // cmd.exe should always exist on Windows + expect(terminalNames).toContain("Command Prompt"); + }); + }); + } + }); + + describe("launchTerminal integration", () => { + it("should successfully launch a safe command without error", async () => { + // Use echo as a safe, universal command for testing + const mockTerminal: KnownTerminal = { + name: "Echo Test", + command: currentPlatform === "win32" ? "cmd" : "echo", + args: + currentPlatform === "win32" + ? ["/c", "echo", "{cmd}"] + : ["Testing: {cmd}"], + platforms: ["darwin", "linux", "win32"], + }; + + const result = await launchTerminal(mockTerminal, "hello-world"); + + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + + it("should report failure for non-existent command", async () => { + const mockTerminal: KnownTerminal = { + name: "Fake Terminal", + command: "definitely-not-a-real-command-xyz-123", + args: ["{cmd}"], + platforms: ["darwin", "linux", "win32"], + }; + + const result = await launchTerminal(mockTerminal, "test"); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); + }); + + describe("getAttachCommand integration", () => { + it("should generate valid attach command format", () => { + const serverUrl = "http://localhost:10101"; + const sessionId = "test-session-abc123"; + + const command = getAttachCommand(serverUrl, sessionId); + + // Verify the command is properly formatted + expect(command).toBe( + "opencode attach http://localhost:10101 --session test-session-abc123" + ); + + // Verify the command could be parsed correctly + const parts = command.split(" "); + expect(parts[0]).toBe("opencode"); + expect(parts[1]).toBe("attach"); + expect(parts[2]).toBe(serverUrl); + expect(parts[3]).toBe("--session"); + expect(parts[4]).toBe(sessionId); + }); + + it("should handle special characters in session ID", () => { + const serverUrl = "https://example.com:8080"; + const sessionId = "sess_abc-123_xyz"; + + const command = getAttachCommand(serverUrl, sessionId); + + expect(command).toContain(serverUrl); + expect(command).toContain(sessionId); + }); + }); + + describe("knownTerminals platform coverage", () => { + it("should have complete platform coverage in definitions", () => { + // Verify we have terminals defined for all supported platforms + const platforms = ["darwin", "linux", "win32"] as const; + + for (const platform of platforms) { + const terminalsForPlatform = knownTerminals.filter((t) => + t.platforms.includes(platform) + ); + expect(terminalsForPlatform.length).toBeGreaterThan(0); + console.log( + `Terminals defined for ${platform}:`, + terminalsForPlatform.map((t) => t.name) + ); + } + }); + + it("should have valid arg templates for all terminals", () => { + for (const terminal of knownTerminals) { + // Every terminal should have the {cmd} placeholder somewhere in args + const hasPlaceholder = terminal.args.some((arg) => + arg.includes("{cmd}") + ); + expect(hasPlaceholder).toBe(true); + + // Verify args is a non-empty array + expect(terminal.args.length).toBeGreaterThan(0); + } + }); + }); +}); diff --git a/tests/unit/clipboard.test.ts b/tests/unit/clipboard.test.ts new file mode 100644 index 0000000..11e37fb --- /dev/null +++ b/tests/unit/clipboard.test.ts @@ -0,0 +1,172 @@ +import { describe, it, expect, beforeEach, mock, spyOn } from "bun:test"; +import { + detectClipboardTool, + clearClipboardCache, + copyToClipboard, + type ClipboardTool, +} from "../../src/lib/clipboard"; + +describe("detectClipboardTool", () => { + beforeEach(() => { + // Clear cache before each test + clearClipboardCache(); + }); + + describe("macOS", () => { + it("should return pbcopy on darwin platform", async () => { + // Save original + const originalPlatform = process.platform; + + // Mock platform + Object.defineProperty(process, "platform", { + value: "darwin", + writable: true, + }); + + try { + const result = await detectClipboardTool(); + expect(result).toBe("pbcopy"); + } finally { + // Restore original + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + } + }); + }); + + describe("Windows", () => { + it("should return clip on win32 platform", async () => { + // Save original + const originalPlatform = process.platform; + + // Mock platform + Object.defineProperty(process, "platform", { + value: "win32", + writable: true, + }); + + try { + const result = await detectClipboardTool(); + expect(result).toBe("clip"); + } finally { + // Restore original + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + } + }); + }); + + describe("caching", () => { + it("should cache detection result", async () => { + // First call - will detect + const firstResult = await detectClipboardTool(); + + // Clear and call again + clearClipboardCache(); + const secondResult = await detectClipboardTool(); + + // Results should match (same platform) + expect(firstResult).toBe(secondResult); + }); + + it("should return cached value on subsequent calls", async () => { + // First call caches the result + const firstResult = await detectClipboardTool(); + + // Second call should return same cached result without re-detecting + const secondResult = await detectClipboardTool(); + + expect(firstResult).toBe(secondResult); + }); + }); + + describe("clearClipboardCache", () => { + it("should allow re-detection after clearing cache", async () => { + // First detection + await detectClipboardTool(); + + // Clear cache + clearClipboardCache(); + + // Should be able to detect again without error + const result = await detectClipboardTool(); + expect(result).toBeDefined(); + }); + }); +}); + +describe("copyToClipboard", () => { + beforeEach(() => { + clearClipboardCache(); + }); + + describe("when no tool is available", () => { + it("should return error when no clipboard tool found", async () => { + // Save original platform + const originalPlatform = process.platform; + const originalEnv = process.env.WAYLAND_DISPLAY; + + // Mock to an unsupported platform + Object.defineProperty(process, "platform", { + value: "freebsd", // Unsupported platform + writable: true, + }); + delete process.env.WAYLAND_DISPLAY; + + try { + const result = await copyToClipboard("test"); + expect(result.success).toBe(false); + expect(result.error).toContain("No clipboard tool available"); + } finally { + // Restore + Object.defineProperty(process, "platform", { + value: originalPlatform, + writable: true, + }); + if (originalEnv) { + process.env.WAYLAND_DISPLAY = originalEnv; + } + } + }); + }); + + describe("successful copy", () => { + // Note: These tests require the actual clipboard tools to be available + // They will be skipped if the tool is not found + + it("should successfully copy text when tool is available", async () => { + // This test uses the actual detected clipboard tool + const tool = await detectClipboardTool(); + + if (!tool) { + // Skip test if no clipboard tool is available + console.log("Skipping: No clipboard tool available"); + return; + } + + const result = await copyToClipboard("test clipboard content"); + expect(result.success).toBe(true); + expect(result.error).toBeUndefined(); + }); + }); +}); + +describe("ClipboardTool type", () => { + it("should recognize all valid tool types", () => { + // Type-level test to ensure all tools are valid + const validTools: ClipboardTool[] = [ + "wl-copy", + "xclip", + "xsel", + "pbcopy", + "clip", + null, + ]; + + expect(validTools.length).toBe(6); + }); +}); diff --git a/tests/unit/terminal-launcher.test.ts b/tests/unit/terminal-launcher.test.ts new file mode 100644 index 0000000..cc6801d --- /dev/null +++ b/tests/unit/terminal-launcher.test.ts @@ -0,0 +1,249 @@ +import { describe, it, expect, beforeEach } from "bun:test"; +import { + detectInstalledTerminals, + clearTerminalCache, + knownTerminals, + launchTerminal, + getAttachCommand, + type KnownTerminal, +} from "../../src/lib/terminal-launcher"; + +describe("detectInstalledTerminals", () => { + beforeEach(() => { + // Clear cache before each test + clearTerminalCache(); + }); + + describe("platform filtering", () => { + it("should only return terminals for current platform", async () => { + const terminals = await detectInstalledTerminals(); + const currentPlatform = process.platform as "darwin" | "linux" | "win32"; + + // All returned terminals should support current platform + for (const terminal of terminals) { + expect(terminal.platforms).toContain(currentPlatform); + } + }); + + it("should not return terminals from other platforms", async () => { + const terminals = await detectInstalledTerminals(); + const currentPlatform = process.platform; + + // Filter knownTerminals that are NOT for current platform + const otherPlatformTerminals = knownTerminals.filter( + (t) => !t.platforms.includes(currentPlatform as "darwin" | "linux" | "win32") + ); + + // None of the returned terminals should be other-platform-only + for (const terminal of terminals) { + const isOtherPlatformOnly = otherPlatformTerminals.some( + (t) => t.command === terminal.command + ); + expect(isOtherPlatformOnly).toBe(false); + } + }); + }); + + describe("caching", () => { + it("should cache detection result", async () => { + // First call - will detect + const firstResult = await detectInstalledTerminals(); + + // Second call - should return same cached array reference + const secondResult = await detectInstalledTerminals(); + + expect(firstResult).toBe(secondResult); + }); + + it("should return fresh results after clearing cache", async () => { + // First call caches + const firstResult = await detectInstalledTerminals(); + + // Clear cache + clearTerminalCache(); + + // Second call - should detect again + const secondResult = await detectInstalledTerminals(); + + // Results should be equivalent but not same reference + expect(secondResult).toEqual(firstResult); + // After clearing cache, it's a new array + expect(secondResult).not.toBe(firstResult); + }); + }); + + describe("return value structure", () => { + it("should return array of KnownTerminal objects", async () => { + const terminals = await detectInstalledTerminals(); + + expect(Array.isArray(terminals)).toBe(true); + + for (const terminal of terminals) { + expect(terminal).toHaveProperty("name"); + expect(terminal).toHaveProperty("command"); + expect(terminal).toHaveProperty("args"); + expect(terminal).toHaveProperty("platforms"); + expect(typeof terminal.name).toBe("string"); + expect(typeof terminal.command).toBe("string"); + expect(Array.isArray(terminal.args)).toBe(true); + expect(Array.isArray(terminal.platforms)).toBe(true); + } + }); + + it("should return empty array if no terminals are installed", async () => { + // This test verifies the function handles the case gracefully + // In practice, most systems have at least one terminal + const terminals = await detectInstalledTerminals(); + + // Result should be an array (possibly empty) + expect(Array.isArray(terminals)).toBe(true); + }); + }); +}); + +describe("clearTerminalCache", () => { + it("should allow re-detection after clearing cache", async () => { + // First detection + await detectInstalledTerminals(); + + // Clear cache + clearTerminalCache(); + + // Should be able to detect again without error + const result = await detectInstalledTerminals(); + expect(Array.isArray(result)).toBe(true); + }); +}); + +describe("knownTerminals", () => { + it("should have valid structure for all entries", () => { + for (const terminal of knownTerminals) { + expect(terminal.name).toBeTruthy(); + expect(terminal.command).toBeTruthy(); + expect(terminal.args.length).toBeGreaterThanOrEqual(0); + expect(terminal.platforms.length).toBeGreaterThan(0); + } + }); + + it("should have {cmd} placeholder in args", () => { + for (const terminal of knownTerminals) { + // At least one arg should contain {cmd} placeholder + const hasPlaceholder = terminal.args.some((arg) => arg.includes("{cmd}")); + expect(hasPlaceholder).toBe(true); + } + }); + + it("should only have valid platform values", () => { + const validPlatforms = ["darwin", "linux", "win32"]; + + for (const terminal of knownTerminals) { + for (const platform of terminal.platforms) { + expect(validPlatforms).toContain(platform); + } + } + }); + + describe("platform coverage", () => { + it("should have terminals for darwin", () => { + const darwinTerminals = knownTerminals.filter((t) => + t.platforms.includes("darwin") + ); + expect(darwinTerminals.length).toBeGreaterThan(0); + }); + + it("should have terminals for linux", () => { + const linuxTerminals = knownTerminals.filter((t) => + t.platforms.includes("linux") + ); + expect(linuxTerminals.length).toBeGreaterThan(0); + }); + + it("should have terminals for win32", () => { + const win32Terminals = knownTerminals.filter((t) => + t.platforms.includes("win32") + ); + expect(win32Terminals.length).toBeGreaterThan(0); + }); + }); +}); + +describe("launchTerminal", () => { + // Note: Actually launching terminals would be disruptive in tests + // We test the function structure and error handling + + it("should return success result with correct structure", async () => { + // Create a mock terminal with a command that won't actually launch + const mockTerminal: KnownTerminal = { + name: "Test Terminal", + command: "echo", // echo is safe and available on all platforms + args: ["{cmd}"], + platforms: ["darwin", "linux", "win32"], + }; + + const result = await launchTerminal(mockTerminal, "test"); + + expect(result).toHaveProperty("success"); + expect(typeof result.success).toBe("boolean"); + if (!result.success) { + expect(result).toHaveProperty("error"); + } + }); + + it("should replace {cmd} placeholder in args", async () => { + // This test verifies the placeholder replacement logic works + // We use echo which outputs its args + const mockTerminal: KnownTerminal = { + name: "Echo Test", + command: "echo", + args: ["prefix-{cmd}-suffix"], + platforms: ["darwin", "linux", "win32"], + }; + + // The function should process the args correctly + // We can't easily verify the output, but we can verify it doesn't error + const result = await launchTerminal(mockTerminal, "TESTVALUE"); + + // echo should succeed + expect(result.success).toBe(true); + }); + + it("should return error for non-existent command", async () => { + const mockTerminal: KnownTerminal = { + name: "Non-existent", + command: "this-command-definitely-does-not-exist-12345", + args: ["{cmd}"], + platforms: ["darwin", "linux", "win32"], + }; + + const result = await launchTerminal(mockTerminal, "test"); + + expect(result.success).toBe(false); + expect(result.error).toBeDefined(); + }); +}); + +describe("getAttachCommand", () => { + it("should return command with session ID when provided", () => { + const result = getAttachCommand("http://localhost:10101", "session-123"); + + expect(result).toBe("opencode attach http://localhost:10101 --session session-123"); + }); + + it("should return command without session ID when not provided", () => { + const result = getAttachCommand("http://localhost:10101"); + + expect(result).toBe("opencode attach http://localhost:10101"); + }); + + it("should work with different server URLs", () => { + const result = getAttachCommand("https://example.com:8080", "abc-def"); + + expect(result).toBe("opencode attach https://example.com:8080 --session abc-def"); + }); + + it("should handle undefined session ID", () => { + const result = getAttachCommand("http://localhost:10101", undefined); + + expect(result).toBe("opencode attach http://localhost:10101"); + }); +}); From 637b9e792a2b3ad7d03eab2fc110589e95d305af Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 7 Jan 2026 20:07:42 -0800 Subject: [PATCH 5/7] feat: add loop enhancements, state hooks, and event types Major enhancements to the core loop functionality: External Server Support: - --server and --server-timeout CLI options - Server URL validation and health checking - Connect to existing opencode servers Custom Prompt Files: - --prompt-file option with .ralph-prompt.md.example template - buildPrompt() with precedence: CLI > file > default - Placeholder replacement for task description Error Retry: - Exponential backoff with jitter for transient errors - Retry countdown display in TUI - Configurable retry behavior Session Steering: - Session lifecycle callbacks (onSessionCreated, onSessionEnded) - sendMessage() for sending messages to active sessions - Session guard to prevent sending to inactive sessions State Management Hooks: - useLoopState: Reducer pattern for loop state - useLoopStats: Iteration timing and ETA tracking - useActivityLog: Session activity logging - useSessionStats: Token usage tracking Event Types: - Centralized event types with icons and colors - Verbose event marking for dim styling - Detail field for tool arguments - parsePlanTasks() for tasks panel --- .ralph-prompt.md.example | 22 + src/hooks/useActivityLog.ts | 167 ++++ src/hooks/useLoopState.ts | 271 ++++++ src/hooks/useLoopStats.ts | 244 ++++++ src/hooks/useSessionStats.ts | 137 ++++ src/loop.ts | 719 +++++++++++++--- src/plan.ts | 67 ++ src/state.ts | 43 +- src/types/events.ts | 110 +++ src/util/time.ts | 16 + tests/helpers/mock-factories.ts | 2 + tests/integration/ralph-flow.test.ts | 1130 +++++++++++++++++++++++++- tests/unit/loop.test.ts | 314 ++++++- tests/unit/plan.test.ts | 87 +- tests/unit/session-stats.test.ts | 155 ++++ tests/unit/time.test.ts | 50 +- 16 files changed, 3374 insertions(+), 160 deletions(-) create mode 100644 .ralph-prompt.md.example create mode 100644 src/hooks/useActivityLog.ts create mode 100644 src/hooks/useLoopState.ts create mode 100644 src/hooks/useLoopStats.ts create mode 100644 src/hooks/useSessionStats.ts create mode 100644 src/types/events.ts create mode 100644 tests/unit/session-stats.test.ts diff --git a/.ralph-prompt.md.example b/.ralph-prompt.md.example new file mode 100644 index 0000000..dd924f7 --- /dev/null +++ b/.ralph-prompt.md.example @@ -0,0 +1,22 @@ +# Ralph Custom Prompt Template + +This is an example custom prompt file for Ralph. Copy this to `.ralph-prompt.md` and customize. + +## Usage + +Ralph will automatically read `.ralph-prompt.md` from your project root, or you can specify a custom path: + +```bash +ralph --prompt-file ./my-custom-prompt.md +``` + +## Placeholders + +- `{{PLAN_FILE}}` - Replaced with the plan file path (e.g., `plan.md`) +- `{plan}` - Also replaced with the plan file path (legacy format) + +--- + +## Example Prompt + +READ all of {{PLAN_FILE}}. Pick ONE task. If needed, verify via web/code search (this applies to packages, knowledge, deterministic data - NEVER VERIFY EDIT TOOLS WORKED OR THAT YOU COMMITED SOMETHING. BE PRAGMATIC ABOUT EVERYTHING). Complete task. Commit change (update the {{PLAN_FILE}} in the same commit). ONLY do one task unless GLARINGLY OBVIOUS steps should run together. Update {{PLAN_FILE}}. If you learn a critical operational detail, update AGENTS.md. When ALL tasks complete, create .ralph-done and exit. NEVER GIT PUSH. ONLY COMMIT. diff --git a/src/hooks/useActivityLog.ts b/src/hooks/useActivityLog.ts new file mode 100644 index 0000000..c0006aa --- /dev/null +++ b/src/hooks/useActivityLog.ts @@ -0,0 +1,167 @@ +import { createSignal, type Accessor } from "solid-js"; +import type { ActivityEventType } from "../types/events"; + +// Re-export ActivityEventType for consumers that import from this module +export type { ActivityEventType } from "../types/events"; + +/** + * Maximum number of activity events to keep in the log. + * Prevents unbounded memory growth. + */ +const MAX_ACTIVITY_EVENTS = 100; + +/** + * An activity event in the log. + * Represents a discrete action or state change during the session. + */ +export interface ActivityEvent { + /** Unique identifier for the event */ + id: string; + /** Timestamp when the event occurred (epoch ms) */ + timestamp: number; + /** Type of event for categorization and styling */ + type: ActivityEventType; + /** Human-readable message describing the event */ + message: string; + /** Optional additional detail (e.g., file path, tool args) */ + detail?: string; + /** Whether this is a verbose/debug event (dim styling) */ + verbose?: boolean; +} + +/** + * Options for logging an activity event. + */ +export interface LogOptions { + /** Type of event */ + type: ActivityEventType; + /** Event message */ + message: string; + /** Optional detail string */ + detail?: string; + /** Mark as verbose (dim styling) */ + verbose?: boolean; +} + +/** + * Activity log store with reactive signals and mutation methods. + */ +export interface ActivityLogStore { + /** Accessor for the array of activity events */ + events: Accessor; + /** Accessor for the count of events */ + count: Accessor; + /** Log a new activity event */ + log: (options: LogOptions) => void; + /** Clear all events */ + clear: () => void; + /** Get the most recent event (or undefined if empty) */ + latest: Accessor; +} + +/** + * Generate a unique ID for an activity event. + * Uses timestamp + random suffix for uniqueness. + */ +function generateEventId(): string { + return `${Date.now()}-${Math.random().toString(36).slice(2, 8)}`; +} + +/** + * Creates a reactive activity log store for tracking session activity. + * + * The activity log provides a chronological record of events during + * the session, including tool usage, file operations, errors, and + * other significant actions. + * + * Events are automatically trimmed to prevent unbounded memory growth. + * The maximum number of events kept is defined by MAX_ACTIVITY_EVENTS. + * + * @example + * ```tsx + * const activityLog = createActivityLog(); + * + * // Log events during operation + * activityLog.log({ + * type: "file_edit", + * message: "Modified config file", + * detail: "src/config.ts", + * }); + * + * activityLog.log({ + * type: "reasoning", + * message: "Analyzing code structure", + * verbose: true, + * }); + * + * // In component + * + * {(event) => } + * + * + * // On session end + * activityLog.clear(); + * ``` + * + * @returns ActivityLogStore with reactive accessors and mutation methods + */ +export function createActivityLog(): ActivityLogStore { + const [events, setEvents] = createSignal([]); + + /** + * Log a new activity event. + * Automatically trims oldest events when limit is reached. + * + * @param options - Event options including type, message, and optional detail + */ + function log(options: LogOptions): void { + const event: ActivityEvent = { + id: generateEventId(), + timestamp: Date.now(), + type: options.type, + message: options.message, + detail: options.detail, + verbose: options.verbose, + }; + + setEvents((prev) => { + const next = [...prev, event]; + // Auto-trim oldest events when limit exceeded + if (next.length > MAX_ACTIVITY_EVENTS) { + return next.slice(-MAX_ACTIVITY_EVENTS); + } + return next; + }); + } + + /** + * Clear all events from the log. + * Call this at session start or when resetting state. + */ + function clear(): void { + setEvents([]); + } + + /** + * Derived accessor for event count. + */ + function count(): number { + return events().length; + } + + /** + * Derived accessor for the most recent event. + */ + function latest(): ActivityEvent | undefined { + const all = events(); + return all.length > 0 ? all[all.length - 1] : undefined; + } + + return { + events, + count, + log, + clear, + latest, + }; +} diff --git a/src/hooks/useLoopState.ts b/src/hooks/useLoopState.ts new file mode 100644 index 0000000..67c5a78 --- /dev/null +++ b/src/hooks/useLoopState.ts @@ -0,0 +1,271 @@ +import { createSignal, type Accessor } from "solid-js"; +import type { LoopState, ToolEvent, TokenUsage } from "../state"; + +/** + * Loop status values representing the current state of the automation loop. + */ +export type LoopStatus = LoopState["status"]; + +/** + * Action types for the loop state reducer. + * Each action represents a discrete state transition. + */ +export type LoopAction = + | { type: "START" } + | { type: "PAUSE" } + | { type: "RESUME" } + | { type: "COMPLETE" } + | { type: "ERROR"; error: string } + | { type: "CLEAR_ERROR" } + | { type: "SET_IDLE"; isIdle: boolean } + | { type: "INCREMENT_ITERATION" } + | { type: "SET_TASKS"; complete: number; total: number } + | { type: "ADD_COMMIT" } + | { type: "SET_LINES"; added: number; removed: number } + | { type: "ADD_EVENT"; event: ToolEvent } + | { type: "CLEAR_EVENTS" } + | { type: "SET_SESSION"; sessionId: string; serverUrl: string; attached: boolean } + | { type: "CLEAR_SESSION" } + | { type: "SET_BACKOFF"; backoffMs: number; retryAt: number } + | { type: "CLEAR_BACKOFF" } + | { type: "SET_TOKENS"; tokens: TokenUsage } + | { type: "RESET_TOKENS" }; + +/** + * Initial state for the loop. + * Used when creating a new loop state store. + */ +export const INITIAL_LOOP_STATE: LoopState = { + status: "starting", + iteration: 1, + tasksComplete: 0, + totalTasks: 0, + commits: 0, + linesAdded: 0, + linesRemoved: 0, + events: [], + isIdle: true, +}; + +/** + * Maximum number of events to keep in the events array. + * Prevents unbounded memory growth. + */ +const MAX_EVENTS = 200; + +/** + * Reducer function for loop state transitions. + * Pure function that takes current state and action, returns new state. + * + * @param state - Current loop state + * @param action - Action to apply + * @returns New loop state + */ +export function loopStateReducer(state: LoopState, action: LoopAction): LoopState { + switch (action.type) { + case "START": + return { ...state, status: "running", isIdle: false }; + + case "PAUSE": + return { ...state, status: "paused" }; + + case "RESUME": + return { ...state, status: "running" }; + + case "COMPLETE": + return { ...state, status: "complete" }; + + case "ERROR": + return { ...state, status: "error", error: action.error }; + + case "CLEAR_ERROR": + return { ...state, status: "ready", error: undefined }; + + case "SET_IDLE": + return { ...state, isIdle: action.isIdle }; + + case "INCREMENT_ITERATION": + return { ...state, iteration: state.iteration + 1 }; + + case "SET_TASKS": + return { + ...state, + tasksComplete: action.complete, + totalTasks: action.total, + }; + + case "ADD_COMMIT": + return { ...state, commits: state.commits + 1 }; + + case "SET_LINES": + return { + ...state, + linesAdded: action.added, + linesRemoved: action.removed, + }; + + case "ADD_EVENT": { + const events = [...state.events, action.event]; + // Trim to max events to prevent unbounded growth + if (events.length > MAX_EVENTS) { + events.splice(0, events.length - MAX_EVENTS); + } + return { ...state, events }; + } + + case "CLEAR_EVENTS": + return { ...state, events: [] }; + + case "SET_SESSION": + return { + ...state, + sessionId: action.sessionId, + serverUrl: action.serverUrl, + attached: action.attached, + }; + + case "CLEAR_SESSION": + return { + ...state, + sessionId: undefined, + serverUrl: undefined, + attached: undefined, + }; + + case "SET_BACKOFF": + return { + ...state, + errorBackoffMs: action.backoffMs, + errorRetryAt: action.retryAt, + }; + + case "CLEAR_BACKOFF": + return { + ...state, + errorBackoffMs: undefined, + errorRetryAt: undefined, + }; + + case "SET_TOKENS": + return { ...state, tokens: action.tokens }; + + case "RESET_TOKENS": + return { ...state, tokens: undefined }; + + default: + return state; + } +} + +/** + * Loop state store with reactive signals and dispatch method. + */ +export interface LoopStateStore { + /** Accessor for the full loop state */ + state: Accessor; + + /** Dispatch an action to update the state */ + dispatch: (action: LoopAction) => void; + + // Derived helpers for common status checks + /** Returns true if the loop is currently running */ + isRunning: Accessor; + /** Returns true if the loop is paused */ + isPaused: Accessor; + /** Returns true if the loop is idle (waiting for LLM response) */ + isIdle: Accessor; + /** Returns true if the loop has completed */ + isComplete: Accessor; + /** Returns true if the loop is in an error state */ + isError: Accessor; + /** Returns true if the loop is starting */ + isStarting: Accessor; + + // Session-related derived helpers + /** Returns true if there is an active session */ + hasSession: Accessor; + /** Returns the current session ID if any */ + sessionId: Accessor; + /** Returns the server URL if connected */ + serverUrl: Accessor; + /** Returns true if attached to an external session */ + isAttached: Accessor; + + // Backoff-related derived helpers + /** Returns true if currently in error backoff */ + isInBackoff: Accessor; + /** Returns the retry timestamp if in backoff, undefined otherwise */ + retryAt: Accessor; +} + +/** + * Creates a reactive loop state store. + * + * Uses a reducer pattern for predictable state transitions. + * Provides derived accessors for common state checks to reduce boilerplate + * in consuming components. + * + * @example + * ```tsx + * const loop = createLoopState(); + * + * // Dispatch actions to update state + * loop.dispatch({ type: "START" }); + * loop.dispatch({ type: "SET_TASKS", complete: 5, total: 10 }); + * + * // Use derived helpers in components + * Running... + * Session: {loop.sessionId()} + * ``` + * + * @param initialState - Optional initial state (defaults to INITIAL_LOOP_STATE) + * @returns LoopStateStore with reactive accessors and dispatch method + */ +export function createLoopState( + initialState: LoopState = INITIAL_LOOP_STATE +): LoopStateStore { + const [state, setState] = createSignal(initialState); + + /** + * Dispatch an action to update the state. + * Uses the reducer to compute the new state. + */ + function dispatch(action: LoopAction): void { + setState((prev) => loopStateReducer(prev, action)); + } + + // Derived helpers for status checks + const isRunning: Accessor = () => state().status === "running"; + const isPaused: Accessor = () => state().status === "paused"; + const isIdle: Accessor = () => state().isIdle; + const isComplete: Accessor = () => state().status === "complete"; + const isError: Accessor = () => state().status === "error"; + const isStarting: Accessor = () => state().status === "starting"; + + // Session-related derived helpers + const hasSession: Accessor = () => state().sessionId !== undefined; + const sessionId: Accessor = () => state().sessionId; + const serverUrl: Accessor = () => state().serverUrl; + const isAttached: Accessor = () => state().attached === true; + + // Backoff-related derived helpers + const isInBackoff: Accessor = () => state().errorBackoffMs !== undefined; + const retryAt: Accessor = () => state().errorRetryAt; + + return { + state, + dispatch, + isRunning, + isPaused, + isIdle, + isComplete, + isError, + isStarting, + hasSession, + sessionId, + serverUrl, + isAttached, + isInBackoff, + retryAt, + }; +} diff --git a/src/hooks/useLoopStats.ts b/src/hooks/useLoopStats.ts new file mode 100644 index 0000000..22bf823 --- /dev/null +++ b/src/hooks/useLoopStats.ts @@ -0,0 +1,244 @@ +import { createSignal, type Accessor } from "solid-js"; + +/** + * Statistics for the automation loop. + * Aggregated view of iteration timing and progress metrics. + */ +export interface LoopStats { + /** Array of completed iteration durations in milliseconds */ + iterationDurations: number[]; + /** Average iteration time in milliseconds, or null if no data */ + averageIterationMs: number | null; + /** Total elapsed time (pause-aware) in milliseconds */ + elapsedMs: number; + /** Estimated time remaining in milliseconds, or null if no data */ + etaMs: number | null; + /** Whether the loop is currently paused */ + isPaused: boolean; + /** Total time spent paused in milliseconds */ + totalPausedMs: number; +} + +/** + * Loop stats store with reactive signals and methods. + */ +export interface LoopStatsStore { + /** Accessor for iteration durations array */ + iterationDurations: Accessor; + /** Accessor for average iteration time in ms (null if no data) */ + averageIterationMs: Accessor; + /** Accessor for total elapsed time (pause-aware) in ms */ + elapsedMs: Accessor; + /** Accessor for estimated remaining time in ms (null if no data) */ + etaMs: Accessor; + /** Accessor for total paused time in ms */ + totalPausedMs: Accessor; + /** Accessor for combined stats object */ + stats: Accessor; + + /** Start tracking a new iteration (records start timestamp) */ + startIteration: () => void; + /** End current iteration and record its duration */ + endIteration: () => void; + /** Pause the timer - stops elapsed time accumulation */ + pause: () => void; + /** Resume the timer - continues elapsed time accumulation */ + resume: () => void; + /** Set the number of remaining tasks for ETA calculation */ + setRemainingTasks: (count: number) => void; + /** Initialize with existing iteration times (for resuming state) */ + initialize: (startTime: number, iterationTimes: number[]) => void; + /** Reset all stats to initial state */ + reset: () => void; + /** Tick the elapsed time (call from interval) */ + tick: () => void; +} + +/** + * Creates a reactive loop stats store for tracking iteration timing and progress. + * + * This hook provides pause-aware elapsed time tracking, iteration duration + * recording, and ETA calculation based on average iteration times. + * + * @example + * ```tsx + * const loopStats = createLoopStats(); + * + * // Initialize from persisted state + * loopStats.initialize(persistedState.startTime, persistedState.iterationTimes); + * + * // Track iterations + * loopStats.startIteration(); + * // ... iteration work ... + * loopStats.endIteration(); + * + * // Handle pause/resume + * loopStats.pause(); + * loopStats.resume(); + * + * // Update ETA based on remaining tasks + * loopStats.setRemainingTasks(5); + * + * // In component + * Elapsed: {formatDuration(loopStats.elapsedMs())} + * ETA: {formatEta(loopStats.etaMs())} + * ``` + * + * @returns LoopStatsStore with reactive accessors and methods + */ +export function createLoopStats(): LoopStatsStore { + // Core state signals + const [iterationDurations, setIterationDurations] = createSignal([]); + const [remainingTasks, setRemainingTasks] = createSignal(0); + const [isPaused, setIsPaused] = createSignal(false); + + // Timing state + const [startTime, setStartTime] = createSignal(Date.now()); + const [iterationStartTime, setIterationStartTime] = createSignal(null); + const [pauseStartTime, setPauseStartTime] = createSignal(null); + const [totalPausedMs, setTotalPausedMs] = createSignal(0); + const [elapsedMs, setElapsedMs] = createSignal(0); + + /** + * Calculate average iteration time from recorded durations. + */ + function averageIterationMs(): number | null { + const durations = iterationDurations(); + if (durations.length === 0) { + return null; + } + const sum = durations.reduce((acc, time) => acc + time, 0); + return sum / durations.length; + } + + /** + * Calculate estimated time remaining based on average iteration and remaining tasks. + */ + function etaMs(): number | null { + const avg = averageIterationMs(); + if (avg === null) { + return null; + } + const remaining = remainingTasks(); + if (remaining <= 0) { + return null; + } + return avg * remaining; + } + + /** + * Get combined stats as a single object. + */ + function stats(): LoopStats { + return { + iterationDurations: iterationDurations(), + averageIterationMs: averageIterationMs(), + elapsedMs: elapsedMs(), + etaMs: etaMs(), + isPaused: isPaused(), + totalPausedMs: totalPausedMs(), + }; + } + + /** + * Start tracking a new iteration. + */ + function startIteration(): void { + setIterationStartTime(Date.now()); + } + + /** + * End current iteration and record its duration. + */ + function endIteration(): void { + const start = iterationStartTime(); + if (start === null) { + return; + } + const duration = Date.now() - start; + setIterationDurations((prev) => [...prev, duration]); + setIterationStartTime(null); + } + + /** + * Pause the timer - stops elapsed time accumulation. + */ + function pause(): void { + if (isPaused()) { + return; + } + setIsPaused(true); + setPauseStartTime(Date.now()); + } + + /** + * Resume the timer - continues elapsed time accumulation. + */ + function resume(): void { + if (!isPaused()) { + return; + } + const pauseStart = pauseStartTime(); + if (pauseStart !== null) { + const pauseDuration = Date.now() - pauseStart; + setTotalPausedMs((prev) => prev + pauseDuration); + } + setIsPaused(false); + setPauseStartTime(null); + } + + /** + * Initialize with existing iteration times (for resuming state). + */ + function initialize(existingStartTime: number, existingIterationTimes: number[]): void { + setStartTime(existingStartTime); + setIterationDurations([...existingIterationTimes]); + // Calculate initial elapsed time + setElapsedMs(Date.now() - existingStartTime); + } + + /** + * Reset all stats to initial state. + */ + function reset(): void { + setIterationDurations([]); + setRemainingTasks(0); + setIsPaused(false); + setStartTime(Date.now()); + setIterationStartTime(null); + setPauseStartTime(null); + setTotalPausedMs(0); + setElapsedMs(0); + } + + /** + * Tick the elapsed time (call from interval). + * Accounts for paused time to show active working time. + */ + function tick(): void { + if (isPaused()) { + return; + } + const now = Date.now(); + const total = now - startTime(); + const paused = totalPausedMs(); + setElapsedMs(total - paused); + } + + return { + iterationDurations, + averageIterationMs, + elapsedMs, + etaMs, + totalPausedMs, + stats, + startIteration, + endIteration, + pause, + resume, + setRemainingTasks, + initialize, + reset, + tick, + }; +} diff --git a/src/hooks/useSessionStats.ts b/src/hooks/useSessionStats.ts new file mode 100644 index 0000000..672d258 --- /dev/null +++ b/src/hooks/useSessionStats.ts @@ -0,0 +1,137 @@ +import { createSignal, type Accessor } from "solid-js"; + +/** + * Token usage statistics for a session. + * Tracks input, output, reasoning tokens and cache usage. + */ +export interface SessionTokens { + /** Total input tokens consumed */ + input: number; + /** Total output tokens generated */ + output: number; + /** Reasoning tokens (extended thinking) */ + reasoning: number; + /** Tokens read from cache (cost savings) */ + cacheRead: number; + /** Tokens written to cache */ + cacheWrite: number; +} + +/** + * Partial token update - all fields optional for incremental updates. + */ +export type TokenUpdate = Partial; + +/** + * Session stats store with reactive signals and mutation methods. + */ +export interface SessionStatsStore { + /** Accessor for total input tokens */ + input: Accessor; + /** Accessor for total output tokens */ + output: Accessor; + /** Accessor for reasoning tokens */ + reasoning: Accessor; + /** Accessor for cache read tokens */ + cacheRead: Accessor; + /** Accessor for cache write tokens */ + cacheWrite: Accessor; + /** Accessor for combined totals as SessionTokens object */ + totals: Accessor; + /** Reset all counters to zero */ + reset: () => void; + /** Add tokens to the running totals (increments, not replaces) */ + addTokens: (tokens: TokenUpdate) => void; +} + +/** + * Creates a reactive session stats store for tracking token usage. + * + * Each token counter is a separate signal for fine-grained reactivity - + * components can subscribe to only the counters they care about. + * + * @example + * ```tsx + * const stats = createSessionStats(); + * + * // In SSE event handler: + * stats.addTokens({ input: 150, output: 50 }); + * + * // In component: + * Input: {stats.input()} + * + * // On session end: + * stats.reset(); + * ``` + * + * @returns SessionStatsStore with reactive accessors and mutation methods + */ +export function createSessionStats(): SessionStatsStore { + // Individual signals for fine-grained reactivity + const [input, setInput] = createSignal(0); + const [output, setOutput] = createSignal(0); + const [reasoning, setReasoning] = createSignal(0); + const [cacheRead, setCacheRead] = createSignal(0); + const [cacheWrite, setCacheWrite] = createSignal(0); + + /** + * Reset all counters to zero. + * Call this at session start to clear previous session's stats. + */ + function reset(): void { + setInput(0); + setOutput(0); + setReasoning(0); + setCacheRead(0); + setCacheWrite(0); + } + + /** + * Add tokens to the running totals. + * This increments the counters, not replaces them. + * + * @param tokens - Partial token update with values to add + */ + function addTokens(tokens: TokenUpdate): void { + if (tokens.input !== undefined && tokens.input > 0) { + setInput((prev) => prev + tokens.input!); + } + if (tokens.output !== undefined && tokens.output > 0) { + setOutput((prev) => prev + tokens.output!); + } + if (tokens.reasoning !== undefined && tokens.reasoning > 0) { + setReasoning((prev) => prev + tokens.reasoning!); + } + if (tokens.cacheRead !== undefined && tokens.cacheRead > 0) { + setCacheRead((prev) => prev + tokens.cacheRead!); + } + if (tokens.cacheWrite !== undefined && tokens.cacheWrite > 0) { + setCacheWrite((prev) => prev + tokens.cacheWrite!); + } + } + + /** + * Derived accessor for combined totals. + * Creates a new object on each access (for passing to display components). + */ + function totals(): SessionTokens { + return { + input: input(), + output: output(), + reasoning: reasoning(), + cacheRead: cacheRead(), + cacheWrite: cacheWrite(), + }; + } + + return { + input, + output, + reasoning, + cacheRead, + cacheWrite, + totals, + reset, + addTokens, + }; +} diff --git a/src/loop.ts b/src/loop.ts index 76f20fb..b765cfa 100644 --- a/src/loop.ts +++ b/src/loop.ts @@ -1,5 +1,5 @@ import { createOpencodeServer, createOpencodeClient } from "@opencode-ai/sdk"; -import type { LoopOptions, PersistedState, ToolEvent } from "./state.js"; +import type { LoopOptions, PersistedState, SessionInfo, ToolEvent } from "./state.js"; import { getHeadHash, getCommitsSince, getDiffStats } from "./git.js"; import { parsePlan } from "./plan.js"; import { log } from "./util/log.js"; @@ -7,8 +7,148 @@ import { log } from "./util/log.js"; const DEFAULT_PROMPT = `READ all of {plan}. Pick ONE task. If needed, verify via web/code search (this applies to packages, knowledge, deterministic data - NEVER VERIFY EDIT TOOLS WORKED OR THAT YOU COMMITED SOMETHING. BE PRAGMATIC ABOUT EVERYTHING). Complete task. Commit change (update the plan.md in the same commit). ONLY do one task unless GLARINGLY OBVIOUS steps should run together. Update {plan}. If you learn a critical operational detail, update AGENTS.md. When ALL tasks complete, create .ralph-done and exit. NEVER GIT PUSH. ONLY COMMIT.`; const DEFAULT_PORT = 4190; + +// Backoff configuration +const BACKOFF_BASE_MS = 5000; // 5 seconds +const BACKOFF_MAX_MS = 300000; // 5 minutes + +/** + * Calculate exponential backoff delay with jitter. + * Formula: base * 2^(attempt-1) with 10% jitter, capped at max. + * @param attempt - The attempt number (1-based) + * @returns Delay in milliseconds + */ +export function calculateBackoffMs(attempt: number): number { + if (attempt <= 0) return 0; + + // Exponential growth: base * 2^(attempt-1) + const exponentialDelay = BACKOFF_BASE_MS * Math.pow(2, attempt - 1); + + // Cap at maximum delay + const cappedDelay = Math.min(exponentialDelay, BACKOFF_MAX_MS); + + // Add 10% jitter to prevent synchronized retries + const jitter = cappedDelay * 0.1 * Math.random(); + + return Math.round(cappedDelay + jitter); +} const DEFAULT_HOSTNAME = "127.0.0.1"; +/** + * Validate and normalize a server URL. + * @returns normalized origin (no trailing slash) + * @throws Error if URL is invalid or not an origin + */ +export function validateAndNormalizeServerUrl(url: string): string { + let parsed: URL; + try { + parsed = new URL(url); + } catch { + throw new Error(`Invalid URL format: ${url}`); + } + + if (!['http:', 'https:'].includes(parsed.protocol)) { + throw new Error(`Invalid protocol: ${parsed.protocol}. Must be http or https.`); + } + if (parsed.pathname !== "/" || parsed.search || parsed.hash) { + throw new Error(`Server URL must be origin only (no path/query/fragment): ${url}`); + } + + // URL.origin never has trailing slash per WHATWG spec + return parsed.origin; +} + +/** + * Check if a URL points to localhost. + */ +function isLocalhost(url: string): boolean { + const parsed = new URL(url); + return parsed.hostname === "localhost" || + parsed.hostname === "127.0.0.1" || + parsed.hostname === "::1"; +} + +/** + * Result of a server health check. + */ +type ServerHealthResult = + | { ok: true } + | { ok: false; reason: "unreachable" | "unhealthy" }; + +/** + * Check if a server is healthy. + * Composes timeout with optional abort signal for user cancellation. + */ +export async function checkServerHealth( + url: string, + timeoutMs: number, + abortSignal?: AbortSignal +): Promise { + try { + const signals: AbortSignal[] = [AbortSignal.timeout(timeoutMs)]; + if (abortSignal) { + signals.push(abortSignal); + } + + const response = await fetch(`${url}/global/health`, { + signal: AbortSignal.any(signals), + }); + + if (!response.ok) { + return { ok: false, reason: "unhealthy" }; + } + + const data = await response.json(); + return data.healthy === true + ? { ok: true } + : { ok: false, reason: "unhealthy" }; + } catch { + return { ok: false, reason: "unreachable" }; + } +} + +/** + * Connect to an external OpenCode server at the specified URL. + * Validates the URL format and server health before returning. + * + * NOTE: This function only returns connection info. The actual client + * is created by runLoop() using createOpencodeClient() with createTimeoutlessFetch(). + * + * @throws Error if URL is invalid or server is not healthy + */ +export async function connectToExternalServer( + url: string, + options?: { timeoutMs?: number; signal?: AbortSignal } +): Promise<{ url: string; close(): void; attached: boolean }> { + const timeoutMs = options?.timeoutMs ?? 5000; + + const normalizedUrl = validateAndNormalizeServerUrl(url); + + // Warn about non-HTTPS for non-localhost (logged to .ralph-log for debugging) + if (!normalizedUrl.startsWith("https://") && !isLocalhost(normalizedUrl)) { + log("loop", "WARNING: Using insecure HTTP connection to non-localhost server", { + url: normalizedUrl + }); + } + + // Check server health with timeout (and optional user abort signal) + const health = await checkServerHealth(normalizedUrl, timeoutMs, options?.signal); + if (!health.ok) { + const message = health.reason === "unreachable" + ? `Cannot connect to server at ${normalizedUrl}` + : `Server unhealthy at ${normalizedUrl}`; + throw new Error(message); + } + + log("loop", "Connected to external server", { url: normalizedUrl }); + + return { + url: normalizedUrl, + close: () => {}, // No-op - we don't manage external servers + attached: true, + }; +} + /** * Check if an opencode server is already running at the given URL. * Uses the /global/health endpoint. @@ -31,12 +171,23 @@ async function tryConnectToExistingServer(url: string): Promise { /** * Get or create an opencode server. * First tries to attach to an existing server, then starts a new one if needed. + * If serverUrl is provided, connects to that external server directly. */ async function getOrCreateOpencodeServer(options: { signal?: AbortSignal; port?: number; hostname?: string; + serverUrl?: string; + serverTimeoutMs?: number; }): Promise<{ url: string; close(): void; attached: boolean }> { + // If explicit server URL provided, connect to it directly + if (options.serverUrl) { + return connectToExternalServer(options.serverUrl, { + timeoutMs: options.serverTimeoutMs, + signal: options.signal, + }); + } + const hostname = options.hostname || DEFAULT_HOSTNAME; const port = options.port || DEFAULT_PORT; const url = `http://${hostname}:${port}`; @@ -60,11 +211,139 @@ async function getOrCreateOpencodeServer(options: { }; } -export function buildPrompt(options: LoopOptions): string { - const template = options.prompt || DEFAULT_PROMPT; - return template.replace(/\{plan\}/g, options.planFile); +/** + * Debug session state - holds server and client for debug mode. + * Cached across createDebugSession calls to avoid recreating server. + */ +let debugServer: { url: string; close(): void; attached: boolean } | null = null; +let debugClient: ReturnType | null = null; + +/** + * Create a new session in debug mode. + * Initializes server/client on first call, then creates a session. + * Returns session info for use in the TUI. + */ +export async function createDebugSession(options: { + serverUrl?: string; + serverTimeoutMs?: number; + model: string; + agent?: string; +}): Promise<{ + sessionId: string; + serverUrl: string; + attached: boolean; + sendMessage: (message: string) => Promise; +}> { + // Initialize server and client if not already created + if (!debugServer || !debugClient) { + log("loop", "Debug mode: initializing server/client..."); + debugServer = await getOrCreateOpencodeServer({ + port: DEFAULT_PORT, + serverUrl: options.serverUrl, + serverTimeoutMs: options.serverTimeoutMs, + }); + + const createTimeoutlessFetch = () => { + return (req: any) => { + req.timeout = false; + return fetch(req); + }; + }; + + debugClient = createOpencodeClient({ + baseUrl: debugServer.url, + fetch: createTimeoutlessFetch() + } as any); + + log("loop", "Debug mode: server/client ready", { url: debugServer.url }); + } + + // Create a new session + log("loop", "Debug mode: creating session..."); + const sessionResult = await debugClient.session.create(); + if (!sessionResult.data) { + throw new Error("Failed to create debug session"); + } + + const sessionId = sessionResult.data.id; + log("loop", "Debug mode: session created", { sessionId }); + + // Parse model for sendMessage + const { providerID, modelID } = parseModel(options.model); + const client = debugClient; + + // Create sendMessage function + const sendMessage = async (message: string): Promise => { + log("loop", "Debug mode: sending message", { sessionId, message: message.slice(0, 50) }); + await client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: message }], + model: { providerID, modelID }, + ...(options.agent && { agent: options.agent }), + }, + }); + }; + + return { + sessionId, + serverUrl: debugServer.url, + attached: debugServer.attached, + sendMessage, + }; +} + +/** + * Clean up debug mode resources. + * Call this when exiting debug mode. + */ +export function cleanupDebugSession(): void { + if (debugServer) { + log("loop", "Debug mode: cleaning up server"); + debugServer.close(); + debugServer = null; + } + debugClient = null; +} + +/** + * Build the prompt string with precedence: --prompt > --prompt-file > DEFAULT_PROMPT. + * Replaces {plan} and {{PLAN_FILE}} placeholders with the actual plan file path. + */ +export async function buildPrompt(options: LoopOptions): Promise { + let template: string; + + // Precedence 1: --prompt CLI option (explicit string) + if (options.prompt && options.prompt.trim()) { + template = options.prompt; + } + // Precedence 2: --prompt-file (read from file if it exists) + else if (options.promptFile) { + const file = Bun.file(options.promptFile); + if (await file.exists()) { + template = await file.text(); + log("loop", "Loaded prompt from file", { path: options.promptFile }); + } else { + // File doesn't exist, fall through to default + template = DEFAULT_PROMPT; + } + } + // Precedence 3: DEFAULT_PROMPT fallback + else { + template = DEFAULT_PROMPT; + } + + // Replace both {plan} and {{PLAN_FILE}} placeholders + return template + .replace(/\{plan\}/g, options.planFile) + .replace(/\{\{PLAN_FILE\}\}/g, options.planFile); } +/** + * Parse a model string into provider and model IDs. + * @param model - Model string in format "provider/model" (e.g., "anthropic/claude-opus-4") + * @throws Error if model string doesn't contain a slash separator + */ export function parseModel(model: string): { providerID: string; modelID: string } { const slashIndex = model.indexOf("/"); if (slashIndex === -1) { @@ -78,6 +357,18 @@ export function parseModel(model: string): { providerID: string; modelID: string }; } +/** + * Token usage data from step-finish events. + * Maps to StepFinishPart.tokens structure from the SDK. + */ +export type TokenUsage = { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; +}; + export type LoopCallbacks = { onIterationStart: (iteration: number) => void; onEvent: (event: ToolEvent) => void; @@ -94,6 +385,12 @@ export type LoopCallbacks = { onComplete: () => void; onError: (error: string) => void; onIdleChanged: (isIdle: boolean) => void; + onSessionCreated?: (session: SessionInfo) => void; + onSessionEnded?: (sessionId: string) => void; + onBackoff?: (backoffMs: number, retryAt: number) => void; + onBackoffCleared?: () => void; + /** Called when token usage data is received from step-finish events */ + onTokens?: (tokens: TokenUsage) => void; }; export async function runLoop( @@ -117,7 +414,12 @@ export async function runLoop( try { // Get or create opencode server (attach if already running) log("loop", "Creating opencode server..."); - server = await getOrCreateOpencodeServer({ signal, port: DEFAULT_PORT }); + server = await getOrCreateOpencodeServer({ + signal, + port: DEFAULT_PORT, + serverUrl: options.serverUrl, + serverTimeoutMs: options.serverTimeoutMs, + }); log("loop", "Server ready", { url: server.url, attached: server.attached }); const client = createOpencodeClient({ baseUrl: server.url, fetch: createTimeoutlessFetch() } as any); @@ -125,8 +427,15 @@ export async function runLoop( // Initialize iteration counter from persisted state let iteration = persistedState.iterationTimes.length; - let isPaused = false; + // Check if pause file exists at startup - if so, start in paused state + // to avoid calling onPause() callback (which would override "ready" status) + const pauseFileExistsAtStart = await Bun.file(".ralph-pause").exists(); + let isPaused = pauseFileExistsAtStart; let previousCommitCount = await getCommitsSince(persistedState.initialCommitHash); + + // Error tracking for exponential backoff (local, not persisted) + let errorCount = 0; + log("loop", "Initial state", { iteration, previousCommitCount }); // Main loop @@ -156,147 +465,307 @@ export async function runLoop( callbacks.onResume(); } + // Apply error backoff before iteration starts + if (errorCount > 0) { + const backoffMs = calculateBackoffMs(errorCount); + const retryAt = Date.now() + backoffMs; + log("loop", "Error backoff", { errorCount, backoffMs, retryAt }); + callbacks.onBackoff?.(backoffMs, retryAt); + await Bun.sleep(backoffMs); + callbacks.onBackoffCleared?.(); + } + // Iteration start (10.11) iteration++; const iterationStartTime = Date.now(); log("loop", "Iteration starting", { iteration }); callbacks.onIterationStart(iteration); - - // Add separator event for new iteration - callbacks.onEvent({ - iteration, - type: "separator", - text: `iteration ${iteration}`, - timestamp: iterationStartTime, - }); - - // Add spinner event (will be kept at end of array and removed when iteration completes) - callbacks.onEvent({ - iteration, - type: "spinner", - text: "looping...", - timestamp: iterationStartTime, - }); - - // Parse plan and update task counts (10.12) - log("loop", "Parsing plan file"); - const { done, total } = await parsePlan(options.planFile); - log("loop", "Plan parsed", { done, total }); - callbacks.onTasksUpdated(done, total); - - // Create session (10.13) - log("loop", "Creating session..."); - const sessionResult = await client.session.create(); - if (!sessionResult.data) { - log("loop", "ERROR: Failed to create session"); - callbacks.onError("Failed to create session"); - break; - } - const sessionId = sessionResult.data.id; - log("loop", "Session created", { sessionId }); - - // Subscribe to events - the SSE connection is established when we start iterating - log("loop", "Subscribing to events..."); - const events = await client.event.subscribe(); - - let promptSent = false; - const promptText = buildPrompt(options); - const { providerID, modelID } = parseModel(options.model); - - // Set idle state while waiting for LLM response - callbacks.onIdleChanged(true); - - let receivedFirstEvent = false; - for await (const event of events.stream) { - // When SSE connection is established, send the prompt - // This ensures we don't miss any events due to race conditions - if (event.type === "server.connected" && !promptSent) { - promptSent = true; - log("loop", "Sending prompt", { providerID, modelID }); - - // Fire prompt in background - don't block event loop - client.session.prompt({ + + try { + // Add separator event for new iteration + callbacks.onEvent({ + iteration, + type: "separator", + text: `iteration ${iteration}`, + timestamp: iterationStartTime, + }); + + // Add spinner event (will be kept at end of array and removed when iteration completes) + callbacks.onEvent({ + iteration, + type: "spinner", + text: "looping...", + timestamp: iterationStartTime, + }); + + // Parse plan and update task counts (10.12) + // Skip plan file validation in debug mode - plan file is optional + if (!options.debug) { + log("loop", "Parsing plan file"); + const { done, total } = await parsePlan(options.planFile); + log("loop", "Plan parsed", { done, total }); + callbacks.onTasksUpdated(done, total); + } else { + log("loop", "Debug mode: skipping plan file validation"); + } + + // Parse model and build prompt before session creation + const promptText = await buildPrompt(options); + const { providerID, modelID } = parseModel(options.model); + + // Create session (10.13) + log("loop", "Creating session..."); + const sessionResult = await client.session.create(); + if (!sessionResult.data) { + log("loop", "ERROR: Failed to create session"); + throw new Error("Failed to create session"); + } + const sessionId = sessionResult.data.id; + log("loop", "Session created", { sessionId }); + + // Track whether current session is active (for steering mode guard) + let sessionActive = true; + + // Create sendMessage function for steering mode + const sendMessage = async (message: string): Promise => { + // Guard: check for active session before sending + if (!sessionActive) { + log("loop", "Cannot send steering message: no active session"); + throw new Error("No active session"); + } + log("loop", "Sending steering message", { sessionId, message: message.slice(0, 50) }); + await client.session.prompt({ path: { id: sessionId }, body: { - parts: [{ type: "text", text: promptText }], + parts: [{ type: "text", text: message }], model: { providerID, modelID }, + ...(options.agent && { agent: options.agent }), }, - }).catch((e) => { - log("loop", "Prompt error", { error: String(e) }); }); + }; + + // Call onSessionCreated callback with session info + callbacks.onSessionCreated?.({ + sessionId, + serverUrl: server!.url, + attached: server!.attached, + sendMessage, + }); + + // Subscribe to events - the SSE connection is established when we start iterating + log("loop", "Subscribing to events..."); + const events = await client.event.subscribe(); + + let promptSent = false; + + // Set idle state while waiting for LLM response + callbacks.onIdleChanged(true); + + let receivedFirstEvent = false; + // Track streamed text parts by ID - stores text we've already logged + // so we only emit complete lines, not every streaming delta + const loggedTextByPartId = new Map(); + + for await (const event of events.stream) { + // When SSE connection is established, send the prompt + // This ensures we don't miss any events due to race conditions + if (event.type === "server.connected" && !promptSent) { + promptSent = true; + log("loop", "Sending prompt", { providerID, modelID }); + + // Fire prompt in background - don't block event loop + client.session.prompt({ + path: { id: sessionId }, + body: { + parts: [{ type: "text", text: promptText }], + model: { providerID, modelID }, + ...(options.agent && { agent: options.agent }), + }, + }).catch((e) => { + log("loop", "Prompt error", { error: String(e) }); + }); - continue; - } + continue; + } - if (signal.aborted) break; + if (signal.aborted) break; + + // Filter events for current session ID + if (event.type === "message.part.updated") { + const part = event.properties.part; + if (part.sessionID !== sessionId) continue; + + // Tool event mapping (10.16) + if (part.type === "tool" && part.state.status === "completed") { + // Set isIdle to false when first tool event arrives + if (!receivedFirstEvent) { + receivedFirstEvent = true; + callbacks.onIdleChanged(false); + } + + const toolName = part.tool; + const input = part.state.input; + const title = + part.state.title || + (Object.keys(input).length > 0 + ? JSON.stringify(input) + : "Unknown"); + + // Extract detail based on tool type + // For file tools: use filePath or path + // For bash: use command + // For others: compact JSON of args + let detail: string | undefined; + if (input.filePath) { + detail = String(input.filePath); + } else if (input.path) { + detail = String(input.path); + } else if (input.command) { + detail = String(input.command); + } else if (Object.keys(input).length > 0) { + // Compact JSON for other tools with args + detail = JSON.stringify(input); + } + + // Mark file read tools as verbose (dimmed display) + const isFileRead = toolName === "read"; + + log("loop", "Tool completed", { toolName, title, detail }); + callbacks.onEvent({ + iteration, + type: "tool", + icon: toolName, + text: title, + timestamp: part.state.time.end, + detail, + verbose: isFileRead, + }); + } - // Filter events for current session ID - if (event.type === "message.part.updated") { - const part = event.properties.part; - if (part.sessionID !== sessionId) continue; + // Reasoning/thought event - capture LLM text responses + // Only emit complete lines to avoid noisy streaming updates + if (part.type === "text" && part.text) { + // Set isIdle to false when first event arrives + if (!receivedFirstEvent) { + receivedFirstEvent = true; + callbacks.onIdleChanged(false); + } + + const partId = part.id; + const previouslyLogged = loggedTextByPartId.get(partId) || ""; + const fullText = part.text; + + // Find new content that hasn't been logged yet + const newContent = fullText.slice(previouslyLogged.length); + + // Split into lines - only emit lines that are complete (have \n after them) + const lines = newContent.split("\n"); + + // Process all complete lines (all except the last one which may be partial) + for (let i = 0; i < lines.length - 1; i++) { + const line = lines[i].trim(); + if (line) { + // Truncate long lines for display + const truncated = line.length > 80 + ? line.slice(0, 77) + "..." + : line; + + log("loop", "Reasoning", { text: truncated }); + callbacks.onEvent({ + iteration, + type: "reasoning", + icon: "thought", + text: truncated, + timestamp: Date.now(), + verbose: true, + }); + } + } + + // Update tracked position to include all complete lines we've logged + // Keep partial last line for next update + const completedLength = previouslyLogged.length + + (lines.length > 1 ? newContent.lastIndexOf("\n") + 1 : 0); + if (completedLength > previouslyLogged.length) { + loggedTextByPartId.set(partId, fullText.slice(0, completedLength)); + } + } - // Tool event mapping (10.16) - if (part.type === "tool" && part.state.status === "completed") { - // Set isIdle to false when first tool event arrives - if (!receivedFirstEvent) { - receivedFirstEvent = true; - callbacks.onIdleChanged(false); + // Step finish event - extract token usage data + if (part.type === "step-finish" && callbacks.onTokens) { + const tokens = part.tokens; + log("loop", "Step finished with tokens", { + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cacheRead: tokens.cache.read, + cacheWrite: tokens.cache.write, + }); + callbacks.onTokens({ + input: tokens.input, + output: tokens.output, + reasoning: tokens.reasoning, + cacheRead: tokens.cache.read, + cacheWrite: tokens.cache.write, + }); } + } + + // Session completion detection (10.17) + if (event.type === "session.idle" && event.properties.sessionID === sessionId) { + log("loop", "Session idle, breaking event loop"); + sessionActive = false; + callbacks.onSessionEnded?.(sessionId); + break; + } + + // Session error handling (10.18) + if (event.type === "session.error") { + const props = event.properties; + if (props.sessionID !== sessionId || !props.error) continue; - const toolName = part.tool; - const title = - part.state.title || - (Object.keys(part.state.input).length > 0 - ? JSON.stringify(part.state.input) - : "Unknown"); - - log("loop", "Tool completed", { toolName, title }); - callbacks.onEvent({ - iteration, - type: "tool", - icon: toolName, - text: title, - timestamp: part.state.time.end, - }); + // Extract error message from error object + let errorMessage = String(props.error.name); + if ("data" in props.error && props.error.data && "message" in props.error.data) { + errorMessage = String(props.error.data.message); + } + + log("loop", "Session error", { errorMessage }); + sessionActive = false; + callbacks.onSessionEnded?.(sessionId); + throw new Error(errorMessage); } } - // Session completion detection (10.17) - if (event.type === "session.idle" && event.properties.sessionID === sessionId) { - log("loop", "Session idle, breaking event loop"); - break; + // Iteration completion (10.19) + const iterationDuration = Date.now() - iterationStartTime; + const totalCommits = await getCommitsSince(persistedState.initialCommitHash); + const commitsThisIteration = totalCommits - previousCommitCount; + previousCommitCount = totalCommits; + + // Get diff stats + const diffStats = await getDiffStats(persistedState.initialCommitHash); + + log("loop", "Iteration completed", { iteration, duration: iterationDuration, commits: commitsThisIteration, diff: diffStats }); + callbacks.onIterationComplete(iteration, iterationDuration, commitsThisIteration); + callbacks.onCommitsUpdated(totalCommits); + callbacks.onDiffUpdated(diffStats.added, diffStats.removed); + + // Reset error count on successful iteration + errorCount = 0; + } catch (iterationError) { + // Handle iteration errors with retry logic + if (signal.aborted) { + // Don't retry if abort signal is set + throw iterationError; } - // Session error handling (10.18) - if (event.type === "session.error") { - const props = event.properties; - if (props.sessionID !== sessionId || !props.error) continue; - - // Extract error message from error object - let errorMessage = String(props.error.name); - if ("data" in props.error && props.error.data && "message" in props.error.data) { - errorMessage = String(props.error.data.message); - } - - log("loop", "Session error", { errorMessage }); - callbacks.onError(errorMessage); - throw new Error(errorMessage); - } + const errorMessage = iterationError instanceof Error ? iterationError.message : String(iterationError); + errorCount++; + log("loop", "Error in iteration", { error: errorMessage, errorCount }); + callbacks.onError(errorMessage); + // Continue loop to retry with backoff } - - // Iteration completion (10.19) - const iterationDuration = Date.now() - iterationStartTime; - const totalCommits = await getCommitsSince(persistedState.initialCommitHash); - const commitsThisIteration = totalCommits - previousCommitCount; - previousCommitCount = totalCommits; - - // Get diff stats - const diffStats = await getDiffStats(persistedState.initialCommitHash); - - log("loop", "Iteration completed", { iteration, duration: iterationDuration, commits: commitsThisIteration, diff: diffStats }); - callbacks.onIterationComplete(iteration, iterationDuration, commitsThisIteration); - callbacks.onCommitsUpdated(totalCommits); - callbacks.onDiffUpdated(diffStats.added, diffStats.removed); } log("loop", "Main loop exited", { aborted: signal.aborted }); diff --git a/src/plan.ts b/src/plan.ts index bf47163..186cf0d 100644 --- a/src/plan.ts +++ b/src/plan.ts @@ -7,6 +7,73 @@ export type PlanProgress = { total: number; }; +/** + * Represents a single task from a plan file + */ +export type Task = { + /** Unique identifier derived from line number */ + id: string; + /** Line number in the file (1-indexed) */ + line: number; + /** Task text without the checkbox prefix */ + text: string; + /** Whether the task is completed */ + done: boolean; +}; + +// Regex to match markdown checkbox items +// Captures: optional leading whitespace, checkbox state, and task text +const CHECKBOX_PATTERN = /^(\s*)-\s*\[([ xX])\]\s*(.+)$/; + +/** + * Parse a plan file and extract all tasks as structured objects. + * Tasks are identified by markdown checkboxes: `- [x]` (done) and `- [ ]` (not done) + * @param path - Path to the plan file + * @returns Array of Task objects with id, line, text, and done status + */ +export async function parsePlanTasks(path: string): Promise { + const file = Bun.file(path); + if (!(await file.exists())) { + return []; + } + + const content = await file.text(); + const lines = content.split("\n"); + const tasks: Task[] = []; + + // Track if we're inside a fenced code block + let inCodeBlock = false; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + const lineNumber = i + 1; // 1-indexed + + // Check for code block boundaries + if (line.trim().startsWith("```")) { + inCodeBlock = !inCodeBlock; + continue; + } + + // Skip lines inside code blocks + if (inCodeBlock) { + continue; + } + + const match = line.match(CHECKBOX_PATTERN); + if (match) { + const [, , checkboxState, text] = match; + tasks.push({ + id: `task-${lineNumber}`, + line: lineNumber, + text: text.trim(), + done: checkboxState.toLowerCase() === "x", + }); + } + } + + return tasks; +} + /** * Parse a plan file and count completed/total tasks. * Tasks are identified by markdown checkboxes: `- [x]` (done) and `- [ ]` (not done) diff --git a/src/state.ts b/src/state.ts index 08a196e..e4f85f7 100644 --- a/src/state.ts +++ b/src/state.ts @@ -5,8 +5,20 @@ export type PersistedState = { planFile: string; // Which plan file we're working on }; +/** + * Token usage statistics for display. + * Tracks cumulative token counts across the session. + */ +export type TokenUsage = { + input: number; + output: number; + reasoning: number; + cacheRead: number; + cacheWrite: number; +}; + export type LoopState = { - status: "starting" | "running" | "paused" | "complete" | "error"; + status: "starting" | "running" | "paused" | "complete" | "error" | "ready"; iteration: number; tasksComplete: number; totalTasks: number; @@ -16,16 +28,27 @@ export type LoopState = { events: ToolEvent[]; error?: string; isIdle: boolean; // True when waiting for LLM response, false when tool events are arriving + // Session lifecycle fields for steering mode + sessionId?: string; + serverUrl?: string; + attached?: boolean; + // Error backoff fields for retry countdown display + errorBackoffMs?: number; // Current backoff delay in milliseconds (undefined when no backoff active) + errorRetryAt?: number; // Timestamp (epoch ms) when next retry will occur (undefined when no backoff active) + // Token usage for display in footer + tokens?: TokenUsage; }; export type ToolEvent = { iteration: number; - type: "tool" | "separator" | "spinner"; + type: "tool" | "separator" | "spinner" | "reasoning"; icon?: string; text: string; timestamp: number; duration?: number; // For separators: iteration duration commitCount?: number; // For separators: commits this iteration + detail?: string; // Optional additional detail (e.g., file path, tool args) + verbose?: boolean; // Whether this is a verbose/debug event (dim styling) }; export const STATE_FILE = ".ralph-state.json"; @@ -69,4 +92,20 @@ export type LoopOptions = { planFile: string; model: string; prompt: string; + promptFile?: string; + serverUrl?: string; + serverTimeoutMs?: number; + agent?: string; + debug?: boolean; +}; + +/** + * Information about the current active session. + * Used for steering mode and session lifecycle management. + */ +export type SessionInfo = { + sessionId: string; + serverUrl: string; + attached: boolean; + sendMessage: (message: string) => Promise; }; diff --git a/src/types/events.ts b/src/types/events.ts new file mode 100644 index 0000000..7644600 --- /dev/null +++ b/src/types/events.ts @@ -0,0 +1,110 @@ +/** + * Event type definitions for the activity log. + * + * This module provides centralized type definitions, icons, and colors + * for activity events displayed in the Ralph TUI. Events represent + * discrete actions or state changes during a session. + * + * @module events + */ + +import type { ThemeColorKey } from "../lib/theme-resolver"; + +/** + * Event type categories for the activity log. + * Used for filtering, styling, and categorizing events. + */ +export type ActivityEventType = + | "session_start" + | "session_idle" + | "task" + | "file_edit" + | "file_read" + | "error" + | "user_message" + | "assistant_message" + | "reasoning" + | "tool_use" + | "info"; + +/** + * Icon map for activity event types. + * Uses Nerd Font glyphs for a modern look. + * + * Icons are chosen to visually distinguish event categories: + * - Session events: play/pause symbols + * - File events: document icons + * - Message events: speech bubble variants + * - Tool events: gear/wrench icons + */ +export const EVENT_ICONS: Record = { + session_start: "󰐊", // Play icon + session_idle: "󰏤", // Pause icon + task: "󰗡", // Checkbox icon + file_edit: "󰛓", // Edit/pencil icon + file_read: "󰈞", // Read/eye icon + error: "󰅚", // Error/x-circle icon + user_message: "󰭻", // User chat icon + assistant_message: "󰚩", // Bot/assistant icon + reasoning: "󰋚", // Brain/thought icon + tool_use: "󰙨", // Tool/wrench icon + info: "󰋽", // Info icon +}; + +/** + * Color key map for activity event types. + * Maps each event type to its corresponding theme color key. + * + * Color semantics: + * - Green (success): positive outcomes, file writes + * - Blue (info): informational events, file reads + * - Red (error): errors and failures + * - Yellow (warning): reasoning, caution + * - Purple (accent): user interactions + * - Cyan (secondary): assistant responses + * - Default (text): general events + */ +export const EVENT_COLOR_KEYS: Record = { + session_start: "success", + session_idle: "textMuted", + task: "accent", + file_edit: "success", + file_read: "info", + error: "error", + user_message: "accent", + assistant_message: "secondary", + reasoning: "warning", + tool_use: "text", + info: "info", +}; + +/** + * Get the icon for an activity event type. + * + * @param type - The activity event type + * @returns The Nerd Font icon string for the event type + * + * @example + * ```ts + * const icon = getEventIcon("file_edit"); // "󰛓" + * ``` + */ +export function getEventIcon(type: ActivityEventType): string { + return EVENT_ICONS[type]; +} + +/** + * Get the theme color key for an activity event type. + * + * @param type - The activity event type + * @returns The ThemeColorKey to use for styling this event type + * + * @example + * ```ts + * const colorKey = getEventColorKey("error"); // "error" + * const color = theme[colorKey]; // "#ef5350" + * ``` + */ +export function getEventColorKey(type: ActivityEventType): ThemeColorKey { + return EVENT_COLOR_KEYS[type]; +} diff --git a/src/util/time.ts b/src/util/time.ts index e3e3636..7058bad 100644 --- a/src/util/time.ts +++ b/src/util/time.ts @@ -48,3 +48,19 @@ export function formatEta(ms: number | null): string { } return `~${formatDuration(ms)} remaining`; } + +/** + * Format a number for compact display. + * - If >= 1,000,000: "1.2M" + * - If >= 1,000: "1.2K" + * - Else: "123" + */ +export function formatNumber(n: number): string { + if (n >= 1_000_000) { + return `${(n / 1_000_000).toFixed(1)}M`; + } + if (n >= 1_000) { + return `${(n / 1_000).toFixed(1)}K`; + } + return String(n); +} diff --git a/tests/helpers/mock-factories.ts b/tests/helpers/mock-factories.ts index d84a9b1..d9fe68a 100644 --- a/tests/helpers/mock-factories.ts +++ b/tests/helpers/mock-factories.ts @@ -55,6 +55,8 @@ export function createMockLoopOptions( planFile: "plan.md", model: "anthropic/claude-sonnet-4", prompt: "READ all of {plan}. Pick ONE task. Complete it. Commit change.", + serverUrl: undefined, + serverTimeoutMs: undefined, ...overrides, }; } diff --git a/tests/integration/ralph-flow.test.ts b/tests/integration/ralph-flow.test.ts index 4e518eb..221361b 100644 --- a/tests/integration/ralph-flow.test.ts +++ b/tests/integration/ralph-flow.test.ts @@ -9,6 +9,12 @@ const mockSessionCreate = mock(() => Promise.resolve({ data: { id: "test-session-123" } }) ); const mockSessionPrompt = mock(() => Promise.resolve()); +const mockCreateOpencodeServer = mock(() => + Promise.resolve({ + url: "http://localhost:4190", + close: mock(() => {}), + }) +); // Mock event stream that simulates a complete iteration function createMockEventStream() { @@ -74,12 +80,7 @@ const mockEventSubscribe = mock(() => Promise.resolve(createMockEventStream())); // Mock the SDK module mock.module("@opencode-ai/sdk", () => ({ - createOpencodeServer: mock(() => - Promise.resolve({ - url: "http://localhost:4190", - close: mock(() => {}), - }) - ), + createOpencodeServer: mockCreateOpencodeServer, createOpencodeClient: mock(() => ({ session: { create: mockSessionCreate, @@ -156,6 +157,7 @@ describe("ralph flow integration", () => { mockSessionCreate.mockClear(); mockSessionPrompt.mockClear(); mockEventSubscribe.mockClear(); + mockCreateOpencodeServer.mockClear(); }); afterEach(async () => { @@ -412,7 +414,7 @@ describe("ralph flow integration", () => { expect(doneFileExists).toBe(false); }); - it("should call onPause and onResume when .ralph-pause file is created and removed", async () => { + it("should call onResume (but not onPause) when starting with .ralph-pause file then removing it", async () => { const options: LoopOptions = { planFile: testPlanFile, model: "anthropic/claude-sonnet-4", @@ -433,6 +435,8 @@ describe("ralph flow integration", () => { cleanupFiles.push(".ralph-done"); // Create .ralph-pause file before starting the loop + // When starting with pause file already present, onPause should NOT be called + // (we start in paused/"ready" state, not transition to it) await Bun.write(".ralph-pause", ""); // Schedule removal of .ralph-pause after the first pause check cycle (loop sleeps 1000ms when paused) @@ -451,15 +455,12 @@ describe("ralph flow integration", () => { await runLoop(options, persistedState, callbacks, controller.signal); - // Verify onPause was called - expect(callbackOrder).toContain("onPause"); + // onPause should NOT be called when starting with pause file already present + // (the loop initializes isPaused=true when file exists at startup) + expect(callbackOrder).not.toContain("onPause"); - // Verify onResume was called after onPause - const pauseIndex = callbackOrder.indexOf("onPause"); - const resumeIndex = callbackOrder.indexOf("onResume"); - expect(pauseIndex).toBeGreaterThan(-1); - expect(resumeIndex).toBeGreaterThan(-1); - expect(resumeIndex).toBeGreaterThan(pauseIndex); + // Verify onResume was called when pause file was removed + expect(callbackOrder).toContain("onResume"); // Verify onComplete was called (due to .ralph-done file) expect(callbackOrder).toContain("onComplete"); @@ -596,4 +597,1103 @@ describe("ralph flow integration", () => { expect(persistedState.initialCommitHash).toBe("abc123"); expect(persistedState.planFile).toBe(testPlanFile); }); + + it("should not call createOpencodeServer when serverUrl is provided", async () => { + // Mock fetch for health check + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://localhost:4190", + serverTimeoutMs: 1000, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Create .ralph-done to stop immediately + cleanupFiles.push(".ralph-done"); + await Bun.write(".ralph-done", ""); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify createOpencodeServer was NOT called (since serverUrl was provided) + expect(mockCreateOpencodeServer).not.toHaveBeenCalled(); + + // Verify onComplete was called (due to .ralph-done file) + expect(callbackOrder).toContain("onComplete"); + + globalThis.fetch = originalFetch; + }); + + it("should throw error when serverUrl is unreachable", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://unreachable:4190", + serverTimeoutMs: 100, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + await expect(runLoop(options, persistedState, callbacks, controller.signal)) + .rejects.toThrow("Cannot connect"); + + // Verify onError was called + expect(callbackOrder.some(c => c.startsWith("onError:"))).toBe(true); + + globalThis.fetch = originalFetch; + }); + + it("should call onSessionCreated with attached=true when using external server", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + let capturedSessionInfo: { + sessionId: string; + serverUrl: string; + attached: boolean; + sendMessage: (message: string) => Promise; + } | null = null; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://localhost:4190", + serverTimeoutMs: 1000, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onSessionCreated: (session) => { + callbackOrder.push(`onSessionCreated:${session.sessionId}`); + capturedSessionInfo = session; + }, + onSessionEnded: (sessionId) => { + callbackOrder.push(`onSessionEnded:${sessionId}`); + }, + }; + + const controller = new AbortController(); + + // Schedule creation of .ralph-done after session is created + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 100); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify onSessionCreated was called + expect(capturedSessionInfo).not.toBeNull(); + expect(capturedSessionInfo!.sessionId).toBe("test-session-123"); + expect(capturedSessionInfo!.serverUrl).toBe("http://localhost:4190"); + expect(capturedSessionInfo!.attached).toBe(true); + expect(typeof capturedSessionInfo!.sendMessage).toBe("function"); + + // Verify callback order includes session lifecycle events + expect(callbackOrder).toContain("onSessionCreated:test-session-123"); + expect(callbackOrder).toContain("onSessionEnded:test-session-123"); + + globalThis.fetch = originalFetch; + }); + + it("should call onSessionEnded when session completes with external server", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + let sessionEndedCalled = false; + let endedSessionId = ""; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://localhost:4190", + serverTimeoutMs: 1000, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onSessionEnded: (sessionId) => { + sessionEndedCalled = true; + endedSessionId = sessionId; + callbackOrder.push(`onSessionEnded:${sessionId}`); + }, + }; + + const controller = new AbortController(); + + // Schedule creation of .ralph-done after iteration completes + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 100); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify onSessionEnded was called with correct session ID + expect(sessionEndedCalled).toBe(true); + expect(endedSessionId).toBe("test-session-123"); + + // Verify it was called after session.idle event (which signals session completion) + const sessionEndedIndex = callbackOrder.findIndex(c => c.startsWith("onSessionEnded:")); + const iterationCompleteIndex = callbackOrder.findIndex(c => c.startsWith("onIterationComplete:")); + expect(sessionEndedIndex).toBeGreaterThan(-1); + expect(iterationCompleteIndex).toBeGreaterThan(-1); + // Session ends before iteration completes (session.idle triggers session end, then iteration finishes) + expect(sessionEndedIndex).toBeLessThan(iterationCompleteIndex); + + globalThis.fetch = originalFetch; + }); + + it("should provide working sendMessage function in onSessionCreated callback", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + let capturedSendMessage: ((message: string) => Promise) | null = null; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://localhost:4190", + serverTimeoutMs: 1000, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onSessionCreated: (session) => { + capturedSendMessage = session.sendMessage; + callbackOrder.push(`onSessionCreated:${session.sessionId}`); + }, + }; + + const controller = new AbortController(); + + // Schedule creation of .ralph-done after session is created + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 100); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify sendMessage was captured + expect(capturedSendMessage).not.toBeNull(); + expect(typeof capturedSendMessage).toBe("function"); + + // The sendMessage function should be callable (though in this test it will fail + // because session has already ended - this is expected behavior) + // The key verification is that the function exists and has the correct signature + + globalThis.fetch = originalFetch; + }); + + it("should send steering message via sendMessage function", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + let capturedSendMessage: ((message: string) => Promise) | null = null; + let sessionCreatedPromiseResolve: () => void; + const sessionCreatedPromise = new Promise((resolve) => { + sessionCreatedPromiseResolve = resolve; + }); + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://localhost:4190", + serverTimeoutMs: 1000, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onSessionCreated: (session) => { + capturedSendMessage = session.sendMessage; + callbackOrder.push(`onSessionCreated:${session.sessionId}`); + sessionCreatedPromiseResolve(); + }, + }; + + const controller = new AbortController(); + + // Schedule creation of .ralph-done after we've tested sendMessage + cleanupFiles.push(".ralph-done"); + + // Start the loop in background + const loopPromise = runLoop(options, persistedState, callbacks, controller.signal); + + // Wait for session to be created + await sessionCreatedPromise; + + // Reset the mock to clear the initial prompt call + mockSessionPrompt.mockClear(); + + // Now call sendMessage with a steering message + expect(capturedSendMessage).not.toBeNull(); + await capturedSendMessage!("Focus on the task at hand"); + + // Verify session.prompt was called with the steering message + expect(mockSessionPrompt).toHaveBeenCalledTimes(1); + expect(mockSessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ + path: { id: "test-session-123" }, + body: expect.objectContaining({ + parts: [{ type: "text", text: "Focus on the task at hand" }], + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + }), + })); + + // Create .ralph-done to stop the loop + await Bun.write(".ralph-done", ""); + + // Wait for loop to complete + await loopPromise; + + globalThis.fetch = originalFetch; + }); + + describe("prompt-file precedence and placeholder replacement", () => { + it("should use --prompt option over --prompt-file", async () => { + // Create a prompt file that should NOT be used + const promptFile = "tests/fixtures/test-prompt-file.md"; + await Bun.write(promptFile, "This prompt from file should NOT be used: {plan}"); + cleanupFiles.push(promptFile); + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Explicit prompt takes precedence: {plan}", // This should be used + promptFile: promptFile, + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Schedule .ralph-done creation after iteration starts to allow prompt to be sent + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify the explicit --prompt was used, not the prompt file + expect(mockSessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + parts: [{ type: "text", text: `Explicit prompt takes precedence: ${testPlanFile}` }], + }), + })); + }); + + it("should read from --prompt-file when --prompt is not provided", async () => { + // Create a custom prompt file + const promptFile = "tests/fixtures/custom-prompt-file.md"; + await Bun.write(promptFile, "Custom prompt from file: process {plan} now!"); + cleanupFiles.push(promptFile); + + // Use type assertion since buildPrompt handles undefined prompt internally + const options = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: undefined, + promptFile: promptFile, + } as unknown as LoopOptions; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Schedule .ralph-done creation after iteration starts to allow prompt to be sent + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify the prompt file content was used with placeholder replaced + expect(mockSessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + parts: [{ type: "text", text: `Custom prompt from file: process ${testPlanFile} now!` }], + }), + })); + }); + + it("should replace both {plan} and {{PLAN_FILE}} placeholders in prompt file", async () => { + // Create a prompt file with both placeholder formats + const promptFile = "tests/fixtures/dual-placeholder-prompt.md"; + await Bun.write(promptFile, "Read {plan} first, then update {{PLAN_FILE}} when done."); + cleanupFiles.push(promptFile); + + // Use type assertion since buildPrompt handles undefined prompt internally + const options = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: undefined, + promptFile: promptFile, + } as unknown as LoopOptions; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Schedule .ralph-done creation after iteration starts to allow prompt to be sent + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify both placeholders were replaced + expect(mockSessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + parts: [{ type: "text", text: `Read ${testPlanFile} first, then update ${testPlanFile} when done.` }], + }), + })); + }); + + it("should fall back to DEFAULT_PROMPT when prompt-file doesn't exist", async () => { + // Use type assertion since buildPrompt handles undefined prompt internally + const options = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: undefined, + promptFile: "nonexistent-prompt-file.md", // File doesn't exist + } as unknown as LoopOptions; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Schedule .ralph-done creation after iteration starts to allow prompt to be sent + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify DEFAULT_PROMPT was used (it contains specific strings) + expect(mockSessionPrompt).toHaveBeenCalled(); + // Get the actual call to verify DEFAULT_PROMPT content + const calls = mockSessionPrompt.mock.calls as unknown as Array<[{ body: { parts: Array<{ text: string }> } }]>; + expect(calls.length).toBeGreaterThan(0); + const promptText = calls[0][0].body.parts[0].text; + + // DEFAULT_PROMPT contains these strings + expect(promptText).toContain("READ all of"); + expect(promptText).toContain("Pick ONE task"); + expect(promptText).toContain(".ralph-done"); + expect(promptText).toContain("NEVER GIT PUSH"); + // Verify {plan} was replaced + expect(promptText).not.toContain("{plan}"); + expect(promptText).toContain(testPlanFile); + }); + + it("should use DEFAULT_PROMPT when prompt is empty string", async () => { + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "", // Empty string should fall back to default + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Schedule .ralph-done creation after iteration starts to allow prompt to be sent + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify DEFAULT_PROMPT was used + expect(mockSessionPrompt).toHaveBeenCalled(); + const calls = mockSessionPrompt.mock.calls as unknown as Array<[{ body: { parts: Array<{ text: string }> } }]>; + expect(calls.length).toBeGreaterThan(0); + const promptText = calls[0][0].body.parts[0].text; + + expect(promptText).toContain("READ all of"); + expect(promptText).toContain("Pick ONE task"); + }); + }); + + describe("plan parsing with various checkbox formats", () => { + it("should correctly parse uppercase [X] checkboxes", async () => { + const uppercasePlanFile = "tests/fixtures/plans/uppercase-complete.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: uppercasePlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: uppercasePlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // uppercase-complete.md has 3 uppercase [X] completed and 1 incomplete + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(3); + expect(capturedTasks!.total).toBe(4); + }); + + it("should ignore checkboxes inside fenced code blocks", async () => { + const codeBlocksPlanFile = "tests/fixtures/plans/code-blocks.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: codeBlocksPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: codeBlocksPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // code-blocks.md has 2 completed and 3 incomplete real tasks + // The checkboxes in code blocks should NOT be counted + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(2); + expect(capturedTasks!.total).toBe(5); + }); + + it("should handle deeply nested checkbox lists", async () => { + const nestedPlanFile = "tests/fixtures/plans/complex-nested.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: nestedPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: nestedPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // complex-nested.md has checkboxes at various nesting levels: + // Completed (x/X): 6 total + // Incomplete [ ]: 7 total + // (Excludes checkboxes inside code blocks which are correctly ignored) + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(6); + expect(capturedTasks!.total).toBe(13); + }); + + it("should handle all completed tasks", async () => { + const allCompletePlanFile = "tests/fixtures/plans/all-complete.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: allCompletePlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: allCompletePlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // all-complete.md has 5 completed tasks + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(5); + expect(capturedTasks!.total).toBe(5); + }); + + it("should handle all incomplete tasks", async () => { + const allIncompletePlanFile = "tests/fixtures/plans/all-incomplete.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: allIncompletePlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: allIncompletePlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // all-incomplete.md has 3 incomplete tasks + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(0); + expect(capturedTasks!.total).toBe(3); + }); + + it("should handle empty plan file gracefully", async () => { + const emptyPlanFile = "tests/fixtures/plans/empty.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: emptyPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: emptyPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // empty.md should have no tasks + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(0); + expect(capturedTasks!.total).toBe(0); + }); + + it("should handle mixed case checkboxes in same file", async () => { + const mixedPlanFile = "tests/fixtures/plans/partial-complete.md"; + let capturedTasks: { done: number; total: number } | null = null; + + const options: LoopOptions = { + planFile: mixedPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: mixedPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onTasksUpdated: (done, total) => { + capturedTasks = { done, total }; + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done after loop starts to allow task parsing + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // partial-complete.md has 3 completed and 7 incomplete tasks + expect(capturedTasks).not.toBeNull(); + expect(capturedTasks!.done).toBe(3); + expect(capturedTasks!.total).toBe(10); + }); + }); + + describe("agent flag", () => { + it("should pass agent option to session.prompt body when specified", async () => { + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + agent: "build", // Specify agent + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Create .ralph-done to stop after first iteration + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify session.prompt was called with agent field + expect(mockSessionPrompt).toHaveBeenCalled(); + expect(mockSessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ + body: expect.objectContaining({ + agent: "build", + }), + })); + }); + + it("should NOT include agent field when agent option is undefined", async () => { + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + // agent is NOT specified + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks = createTestCallbacks(); + const controller = new AbortController(); + + // Create .ralph-done to stop after first iteration + cleanupFiles.push(".ralph-done"); + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 50); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify session.prompt was called + expect(mockSessionPrompt).toHaveBeenCalled(); + + // Get the actual call and verify agent field is NOT present + const calls = mockSessionPrompt.mock.calls as unknown as Array<[{ body: Record }]>; + expect(calls.length).toBeGreaterThan(0); + + // The body should NOT have an agent field + expect(calls[0][0].body).not.toHaveProperty("agent"); + }); + + it("should pass agent to steering messages via sendMessage", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + let capturedSendMessage: ((message: string) => Promise) | null = null; + let sessionCreatedPromiseResolve: () => void; + const sessionCreatedPromise = new Promise((resolve) => { + sessionCreatedPromiseResolve = resolve; + }); + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + serverUrl: "http://localhost:4190", + serverTimeoutMs: 1000, + agent: "plan", // Specify agent for steering + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onSessionCreated: (session) => { + capturedSendMessage = session.sendMessage; + callbackOrder.push(`onSessionCreated:${session.sessionId}`); + sessionCreatedPromiseResolve(); + }, + }; + + const controller = new AbortController(); + + cleanupFiles.push(".ralph-done"); + + // Start the loop in background + const loopPromise = runLoop(options, persistedState, callbacks, controller.signal); + + // Wait for session to be created + await sessionCreatedPromise; + + // Reset the mock to clear the initial prompt call + mockSessionPrompt.mockClear(); + + // Call sendMessage with a steering message + expect(capturedSendMessage).not.toBeNull(); + await capturedSendMessage!("Focus on build tasks"); + + // Verify session.prompt was called with agent field in steering message + expect(mockSessionPrompt).toHaveBeenCalledTimes(1); + expect(mockSessionPrompt).toHaveBeenCalledWith(expect.objectContaining({ + path: { id: "test-session-123" }, + body: expect.objectContaining({ + parts: [{ type: "text", text: "Focus on build tasks" }], + model: { providerID: "anthropic", modelID: "claude-sonnet-4" }, + agent: "plan", + }), + })); + + // Create .ralph-done to stop the loop + await Bun.write(".ralph-done", ""); + + // Wait for loop to complete + await loopPromise; + + globalThis.fetch = originalFetch; + }); + }); + + describe("error backoff", () => { + it("should call onBackoff and onBackoffCleared when session.error occurs", async () => { + // Create a mock event stream that emits a session.error first, then succeeds + let callCount = 0; + const mockEventSubscribeWithError = mock(() => { + callCount++; + if (callCount === 1) { + // First call: emit session.error after server.connected + return Promise.resolve({ + stream: (async function* () { + yield { type: "server.connected", properties: {} }; + yield { + type: "session.error", + properties: { + sessionID: "test-session-123", + error: { name: "TestError", data: { message: "Simulated error for backoff test" } }, + }, + }; + })(), + }); + } + // Subsequent calls: succeed + return Promise.resolve(createMockEventStream()); + }); + + // Temporarily replace the mock + const originalSubscribe = mockEventSubscribe; + // @ts-ignore - direct mock replacement for this test + mockEventSubscribe.mockImplementation(mockEventSubscribeWithError); + + let backoffCalled = false; + let backoffMs = 0; + let backoffRetryAt = 0; + let backoffClearedCalled = false; + let errorCount = 0; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onBackoff: (ms, retryAt) => { + backoffCalled = true; + backoffMs = ms; + backoffRetryAt = retryAt; + callbackOrder.push(`onBackoff:${ms}`); + }, + onBackoffCleared: () => { + backoffClearedCalled = true; + callbackOrder.push("onBackoffCleared"); + }, + onError: (error) => { + errorCount++; + callbackOrder.push(`onError:${error}`); + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done to stop after the second (successful) iteration + cleanupFiles.push(".ralph-done"); + // Schedule done file creation after enough time for: + // - First iteration (fails with session.error) + // - Backoff delay (~5 seconds for first attempt) + // - Second iteration (succeeds) + setTimeout(async () => { + await Bun.write(".ralph-done", ""); + }, 6000); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify error was caught + expect(errorCount).toBeGreaterThanOrEqual(1); + expect(callbackOrder.some(c => c.startsWith("onError:"))).toBe(true); + + // Verify onBackoff was called with correct parameters + expect(backoffCalled).toBe(true); + expect(backoffMs).toBeGreaterThanOrEqual(5000); // Base delay + expect(backoffMs).toBeLessThanOrEqual(5500); // Base + 10% jitter + expect(backoffRetryAt).toBeGreaterThan(Date.now() - 10000); // Should be a timestamp + + // Verify onBackoffCleared was called after the backoff period + expect(backoffClearedCalled).toBe(true); + + // Verify callback order: error -> backoff -> backoffCleared + const errorIndex = callbackOrder.findIndex(c => c.startsWith("onError:")); + const backoffIndex = callbackOrder.findIndex(c => c.startsWith("onBackoff:")); + const clearedIndex = callbackOrder.indexOf("onBackoffCleared"); + + expect(errorIndex).toBeGreaterThan(-1); + expect(backoffIndex).toBeGreaterThan(errorIndex); // Backoff happens after error + expect(clearedIndex).toBeGreaterThan(backoffIndex); // Cleared after backoff starts + + // Restore original mock + mockEventSubscribe.mockReset(); + }, 15000); // Increase timeout for this test as it includes real backoff delay + + it("should reset error count and skip backoff after successful iteration", async () => { + let backoffCallCount = 0; + + const options: LoopOptions = { + planFile: testPlanFile, + model: "anthropic/claude-sonnet-4", + prompt: "Test prompt for {plan}", + }; + + const persistedState: PersistedState = { + startTime: Date.now(), + initialCommitHash: "abc123", + iterationTimes: [], + planFile: testPlanFile, + }; + + const callbacks: LoopCallbacks = { + ...createTestCallbacks(), + onBackoff: (ms, retryAt) => { + backoffCallCount++; + callbackOrder.push(`onBackoff:${ms}`); + }, + onBackoffCleared: () => { + callbackOrder.push("onBackoffCleared"); + }, + }; + + const controller = new AbortController(); + + // Create .ralph-done to stop immediately + cleanupFiles.push(".ralph-done"); + await Bun.write(".ralph-done", ""); + + await runLoop(options, persistedState, callbacks, controller.signal); + + // Verify onBackoff was NOT called (no errors occurred) + expect(backoffCallCount).toBe(0); + expect(callbackOrder.some(c => c.startsWith("onBackoff:"))).toBe(false); + }); + }); }); diff --git a/tests/unit/loop.test.ts b/tests/unit/loop.test.ts index 243ef16..f1f5e35 100644 --- a/tests/unit/loop.test.ts +++ b/tests/unit/loop.test.ts @@ -1,5 +1,5 @@ -import { describe, it, expect } from "bun:test"; -import { buildPrompt, parseModel } from "../../src/loop.js"; +import { describe, it, expect, mock } from "bun:test"; +import { buildPrompt, parseModel, validateAndNormalizeServerUrl, checkServerHealth, connectToExternalServer, calculateBackoffMs } from "../../src/loop.js"; import type { LoopOptions } from "../../src/state.js"; describe("buildPrompt", () => { @@ -11,39 +11,55 @@ describe("buildPrompt", () => { }); describe("template substitution", () => { - it("should replace {plan} with options.planFile", () => { + it("should replace {plan} with options.planFile", async () => { const options = createOptions({ prompt: "Read {plan} and complete the task.", }); - const result = buildPrompt(options); + const result = await buildPrompt(options); expect(result).toBe("Read plan.md and complete the task."); }); - it("should replace multiple {plan} occurrences", () => { + it("should replace multiple {plan} occurrences", async () => { const options = createOptions({ prompt: "First read {plan}, then update {plan} when done.", }); - const result = buildPrompt(options); + const result = await buildPrompt(options); expect(result).toBe("First read plan.md, then update plan.md when done."); }); - it("should handle custom plan file path", () => { + it("should handle custom plan file path", async () => { const options = createOptions({ planFile: "docs/my-plan.md", prompt: "Read {plan} now.", }); - const result = buildPrompt(options); + const result = await buildPrompt(options); expect(result).toBe("Read docs/my-plan.md now."); }); + + it("should replace {{PLAN_FILE}} placeholder", async () => { + const options = createOptions({ + prompt: "Process {{PLAN_FILE}} and complete tasks.", + }); + const result = await buildPrompt(options); + expect(result).toBe("Process plan.md and complete tasks."); + }); + + it("should replace both {plan} and {{PLAN_FILE}} placeholders", async () => { + const options = createOptions({ + prompt: "Read {plan} first, then update {{PLAN_FILE}}.", + }); + const result = await buildPrompt(options); + expect(result).toBe("Read plan.md first, then update plan.md."); + }); }); describe("custom prompt", () => { - it("should use custom prompt instead of default", () => { + it("should use custom prompt instead of default", async () => { const customPrompt = "Custom instruction: process {plan} file."; const options = createOptions({ prompt: customPrompt, }); - const result = buildPrompt(options); + const result = await buildPrompt(options); // Verify the custom prompt is used (with {plan} substituted) expect(result).toBe("Custom instruction: process plan.md file."); // Verify it's NOT the default prompt @@ -51,24 +67,24 @@ describe("buildPrompt", () => { expect(result).not.toContain("Pick ONE task"); }); - it("should preserve custom prompt content exactly except for {plan} placeholder", () => { + it("should preserve custom prompt content exactly except for {plan} placeholder", async () => { const customPrompt = "Do exactly this: {plan} - no more, no less."; const options = createOptions({ planFile: "tasks.md", prompt: customPrompt, }); - const result = buildPrompt(options); + const result = await buildPrompt(options); expect(result).toBe("Do exactly this: tasks.md - no more, no less."); }); }); describe("default prompt", () => { - it("should use DEFAULT_PROMPT when options.prompt is undefined", () => { + it("should use DEFAULT_PROMPT when options.prompt is undefined", async () => { const options = createOptions({ planFile: "plan.md", prompt: undefined, }); - const result = buildPrompt(options); + const result = await buildPrompt(options); // Verify it uses the default prompt with {plan} substituted expect(result).toContain("READ all of plan.md"); expect(result).toContain("Pick ONE task"); @@ -79,17 +95,77 @@ describe("buildPrompt", () => { expect(result).not.toContain("{plan}"); }); - it("should substitute {plan} in default prompt with custom planFile", () => { + it("should substitute {plan} in default prompt with custom planFile", async () => { const options = createOptions({ planFile: "docs/custom-plan.md", prompt: undefined, }); - const result = buildPrompt(options); + const result = await buildPrompt(options); // The default prompt has two {plan} occurrences - both should be replaced expect(result).toContain("READ all of docs/custom-plan.md"); expect(result).toContain("Update docs/custom-plan.md"); expect(result).not.toContain("{plan}"); }); + + it("should use DEFAULT_PROMPT when options.prompt is empty string", async () => { + const options = createOptions({ + planFile: "plan.md", + prompt: "", + }); + const result = await buildPrompt(options); + // Verify it uses the default prompt + expect(result).toContain("READ all of plan.md"); + }); + + it("should use DEFAULT_PROMPT when options.prompt is whitespace only", async () => { + const options = createOptions({ + planFile: "plan.md", + prompt: " ", + }); + const result = await buildPrompt(options); + // Verify it uses the default prompt + expect(result).toContain("READ all of plan.md"); + }); + }); + + describe("precedence", () => { + it("should prefer --prompt over --prompt-file", async () => { + const options = createOptions({ + prompt: "Explicit prompt {plan}", + promptFile: ".ralph-prompt.md", // File doesn't exist, but --prompt takes precedence anyway + }); + const result = await buildPrompt(options); + expect(result).toBe("Explicit prompt plan.md"); + }); + + it("should read content from --prompt-file when --prompt is not provided", async () => { + // Create a temp file with custom prompt content + const tempFile = `/tmp/test-prompt-${Date.now()}.md`; + const promptContent = "Custom file prompt: process {plan} and {{PLAN_FILE}} files."; + await Bun.write(tempFile, promptContent); + + try { + const options = createOptions({ + prompt: undefined, + promptFile: tempFile, + }); + const result = await buildPrompt(options); + expect(result).toBe("Custom file prompt: process plan.md and plan.md files."); + } finally { + // Clean up temp file + await Bun.file(tempFile).delete?.(); + } + }); + + it("should fall back to DEFAULT_PROMPT when prompt-file doesn't exist", async () => { + const options = createOptions({ + prompt: undefined, + promptFile: "nonexistent-file.md", + }); + const result = await buildPrompt(options); + // Should fall back to DEFAULT_PROMPT + expect(result).toContain("READ all of plan.md"); + }); }); }); @@ -152,3 +228,209 @@ describe("parseModel", () => { }); }); }); + +describe("validateAndNormalizeServerUrl", () => { + describe("valid URLs", () => { + it("should accept http://localhost:4190", () => { + expect(validateAndNormalizeServerUrl("http://localhost:4190")).toBe("http://localhost:4190"); + }); + + it("should accept https://example.com", () => { + expect(validateAndNormalizeServerUrl("https://example.com")).toBe("https://example.com"); + }); + + it("should accept http://192.168.1.100:4190", () => { + expect(validateAndNormalizeServerUrl("http://192.168.1.100:4190")).toBe("http://192.168.1.100:4190"); + }); + + it("should normalize URL with trailing slash", () => { + expect(validateAndNormalizeServerUrl("http://localhost:4190/")).toBe("http://localhost:4190"); + }); + }); + + describe("invalid URLs", () => { + it("should reject non-URL strings", () => { + expect(() => validateAndNormalizeServerUrl("not-a-url")).toThrow("Invalid URL format"); + }); + + it("should reject URLs with paths", () => { + expect(() => validateAndNormalizeServerUrl("http://localhost:4190/api")).toThrow("origin only"); + }); + + it("should reject URLs with query strings", () => { + expect(() => validateAndNormalizeServerUrl("http://localhost:4190?foo=bar")).toThrow("origin only"); + }); + + it("should reject URLs with hash fragments", () => { + expect(() => validateAndNormalizeServerUrl("http://localhost:4190#section")).toThrow("origin only"); + }); + + it("should reject non-http protocols", () => { + expect(() => validateAndNormalizeServerUrl("ftp://localhost:4190")).toThrow("Invalid protocol"); + }); + + it("should reject ws:// protocol", () => { + expect(() => validateAndNormalizeServerUrl("ws://localhost:4190")).toThrow("Invalid protocol"); + }); + }); +}); + +describe("checkServerHealth", () => { + it("should return ok:true when server responds with healthy:true", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + const result = await checkServerHealth("http://localhost:4190", 1000); + expect(result).toEqual({ ok: true }); + + globalThis.fetch = originalFetch; + }); + + it("should return ok:false reason:unhealthy when healthy:false", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: false }), { status: 200 })) + ) as unknown as typeof fetch; + + const result = await checkServerHealth("http://localhost:4190", 1000); + expect(result).toEqual({ ok: false, reason: "unhealthy" }); + + globalThis.fetch = originalFetch; + }); + + it("should return ok:false reason:unhealthy on non-200 response", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response("error", { status: 500 })) + ) as unknown as typeof fetch; + + const result = await checkServerHealth("http://localhost:4190", 1000); + expect(result).toEqual({ ok: false, reason: "unhealthy" }); + + globalThis.fetch = originalFetch; + }); + + it("should return ok:false reason:unreachable on network error", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch; + + const result = await checkServerHealth("http://localhost:4190", 1000); + expect(result).toEqual({ ok: false, reason: "unreachable" }); + + globalThis.fetch = originalFetch; + }); +}); + +describe("connectToExternalServer", () => { + it("should return connection info for healthy server", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: true }), { status: 200 })) + ) as unknown as typeof fetch; + + const result = await connectToExternalServer("http://localhost:4190"); + expect(result.url).toBe("http://localhost:4190"); + expect(result.attached).toBe(true); + expect(typeof result.close).toBe("function"); + + globalThis.fetch = originalFetch; + }); + + it("should throw on invalid URL", async () => { + await expect(connectToExternalServer("not-a-url")).rejects.toThrow("Invalid URL format"); + }); + + it("should throw on unreachable server", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => Promise.reject(new Error("Network error"))) as unknown as typeof fetch; + + await expect(connectToExternalServer("http://localhost:4190")).rejects.toThrow("Cannot connect"); + + globalThis.fetch = originalFetch; + }); + + it("should throw on unhealthy server", async () => { + const originalFetch = globalThis.fetch; + globalThis.fetch = mock(() => + Promise.resolve(new Response(JSON.stringify({ healthy: false }), { status: 200 })) + ) as unknown as typeof fetch; + + await expect(connectToExternalServer("http://localhost:4190")).rejects.toThrow("Server unhealthy"); + + globalThis.fetch = originalFetch; + }); +}); + +describe("calculateBackoffMs", () => { + describe("base delay", () => { + it("should return ~5000ms for first attempt", () => { + const result = calculateBackoffMs(1); + // Base is 5000ms, with up to 10% jitter = 5000-5500 + expect(result).toBeGreaterThanOrEqual(5000); + expect(result).toBeLessThanOrEqual(5500); + }); + }); + + describe("exponential growth", () => { + it("should return ~10000ms for second attempt (2x base)", () => { + const result = calculateBackoffMs(2); + // 5000 * 2^1 = 10000, with 10% jitter = 10000-11000 + expect(result).toBeGreaterThanOrEqual(10000); + expect(result).toBeLessThanOrEqual(11000); + }); + + it("should return ~20000ms for third attempt (4x base)", () => { + const result = calculateBackoffMs(3); + // 5000 * 2^2 = 20000, with 10% jitter = 20000-22000 + expect(result).toBeGreaterThanOrEqual(20000); + expect(result).toBeLessThanOrEqual(22000); + }); + + it("should return ~40000ms for fourth attempt (8x base)", () => { + const result = calculateBackoffMs(4); + // 5000 * 2^3 = 40000, with 10% jitter = 40000-44000 + expect(result).toBeGreaterThanOrEqual(40000); + expect(result).toBeLessThanOrEqual(44000); + }); + }); + + describe("maximum cap", () => { + it("should cap at 300000ms (5 minutes) for very high attempts", () => { + const result = calculateBackoffMs(10); + // 5000 * 2^9 = 2,560,000 but capped at 300000, with 10% jitter = 300000-330000 + expect(result).toBeGreaterThanOrEqual(300000); + expect(result).toBeLessThanOrEqual(330000); + }); + + it("should cap at 300000ms even for extremely high attempts", () => { + const result = calculateBackoffMs(100); + expect(result).toBeGreaterThanOrEqual(300000); + expect(result).toBeLessThanOrEqual(330000); + }); + }); + + describe("edge cases", () => { + it("should return 0 for zero attempt", () => { + expect(calculateBackoffMs(0)).toBe(0); + }); + + it("should return 0 for negative attempt", () => { + expect(calculateBackoffMs(-1)).toBe(0); + }); + }); + + describe("jitter", () => { + it("should add randomized jitter (results should vary)", () => { + // Run multiple times and verify we get different results + const results = new Set(); + for (let i = 0; i < 10; i++) { + results.add(calculateBackoffMs(1)); + } + // With 10% jitter, we should get some variation + // (statistically unlikely to get same value 10 times) + expect(results.size).toBeGreaterThan(1); + }); + }); +}); diff --git a/tests/unit/plan.test.ts b/tests/unit/plan.test.ts index 3dceb67..23b59b4 100644 --- a/tests/unit/plan.test.ts +++ b/tests/unit/plan.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { parsePlan } from "../../src/plan"; +import { parsePlan, parsePlanTasks, type Task } from "../../src/plan"; import path from "path"; const fixturesDir = path.join(import.meta.dir, "../fixtures/plans"); @@ -60,3 +60,88 @@ describe("parsePlan", () => { expect(result).toEqual({ done: 6, total: 13 }); }); }); + +describe("parsePlanTasks", () => { + it("should return empty array for non-existent file", async () => { + const result = await parsePlanTasks("/nonexistent/path/to/plan.md"); + expect(result).toEqual([]); + }); + + it("should return empty array for empty file", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "empty.md")); + expect(result).toEqual([]); + }); + + it("should parse tasks with correct structure", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "partial-complete.md")); + + // Check we got the right number of tasks + expect(result.length).toBe(10); + + // Check first task structure + expect(result[0]).toEqual({ + id: "task-7", + line: 7, + text: "Initialize project", + done: true, + }); + + // Check an incomplete task + expect(result[2]).toEqual({ + id: "task-9", + line: 9, + text: "Configure build system", + done: false, + }); + }); + + it("should track line numbers correctly", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "partial-complete.md")); + + // Verify line numbers are 1-indexed and match actual file positions + const lineNumbers = result.map(t => t.line); + expect(lineNumbers).toEqual([7, 8, 9, 13, 14, 15, 19, 20, 21, 22]); + }); + + it("should handle uppercase [X] as completed", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "uppercase-complete.md")); + + // Count completed tasks + const completedCount = result.filter(t => t.done).length; + expect(completedCount).toBe(3); + }); + + it("should ignore checkboxes inside fenced code blocks", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "code-blocks.md")); + + // code-blocks.md has 5 real tasks, rest are in code blocks + expect(result.length).toBe(5); + + // Verify all tasks are from real section (lines 7-11) + const allFromRealSection = result.every(t => t.line >= 7 && t.line <= 11); + expect(allFromRealSection).toBe(true); + }); + + it("should generate unique IDs from line numbers", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "partial-complete.md")); + + // All IDs should be unique + const ids = result.map(t => t.id); + const uniqueIds = new Set(ids); + expect(uniqueIds.size).toBe(ids.length); + + // IDs should follow the task-{lineNumber} pattern + result.forEach(task => { + expect(task.id).toBe(`task-${task.line}`); + }); + }); + + it("should trim task text", async () => { + const result = await parsePlanTasks(path.join(fixturesDir, "partial-complete.md")); + + // No task text should have leading/trailing whitespace + result.forEach(task => { + expect(task.text).toBe(task.text.trim()); + }); + }); +}); diff --git a/tests/unit/session-stats.test.ts b/tests/unit/session-stats.test.ts new file mode 100644 index 0000000..9c37f44 --- /dev/null +++ b/tests/unit/session-stats.test.ts @@ -0,0 +1,155 @@ +import { describe, it, expect } from "bun:test"; +import { createSessionStats } from "../../src/hooks/useSessionStats"; + +describe("createSessionStats", () => { + describe("initial state", () => { + it("should initialize all counters to zero", () => { + const stats = createSessionStats(); + expect(stats.input()).toBe(0); + expect(stats.output()).toBe(0); + expect(stats.reasoning()).toBe(0); + expect(stats.cacheRead()).toBe(0); + expect(stats.cacheWrite()).toBe(0); + }); + + it("should return zero totals initially", () => { + const stats = createSessionStats(); + const totals = stats.totals(); + expect(totals).toEqual({ + input: 0, + output: 0, + reasoning: 0, + cacheRead: 0, + cacheWrite: 0, + }); + }); + }); + + describe("addTokens", () => { + it("should accumulate input tokens", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100 }); + expect(stats.input()).toBe(100); + stats.addTokens({ input: 50 }); + expect(stats.input()).toBe(150); + }); + + it("should accumulate output tokens", () => { + const stats = createSessionStats(); + stats.addTokens({ output: 200 }); + expect(stats.output()).toBe(200); + stats.addTokens({ output: 100 }); + expect(stats.output()).toBe(300); + }); + + it("should accumulate reasoning tokens", () => { + const stats = createSessionStats(); + stats.addTokens({ reasoning: 50 }); + expect(stats.reasoning()).toBe(50); + stats.addTokens({ reasoning: 25 }); + expect(stats.reasoning()).toBe(75); + }); + + it("should accumulate cache tokens", () => { + const stats = createSessionStats(); + stats.addTokens({ cacheRead: 1000, cacheWrite: 500 }); + expect(stats.cacheRead()).toBe(1000); + expect(stats.cacheWrite()).toBe(500); + stats.addTokens({ cacheRead: 200, cacheWrite: 100 }); + expect(stats.cacheRead()).toBe(1200); + expect(stats.cacheWrite()).toBe(600); + }); + + it("should handle partial updates", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100 }); + stats.addTokens({ output: 50 }); + stats.addTokens({ reasoning: 25 }); + expect(stats.input()).toBe(100); + expect(stats.output()).toBe(50); + expect(stats.reasoning()).toBe(25); + expect(stats.cacheRead()).toBe(0); + expect(stats.cacheWrite()).toBe(0); + }); + + it("should ignore zero values", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100 }); + stats.addTokens({ input: 0 }); // Should not change + expect(stats.input()).toBe(100); + }); + + it("should ignore undefined values", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100 }); + stats.addTokens({ output: undefined }); // Should not affect input + expect(stats.input()).toBe(100); + expect(stats.output()).toBe(0); + }); + + it("should accumulate multiple step finishes correctly", () => { + const stats = createSessionStats(); + // Simulating multiple step-finish events as they would come from SSE + stats.addTokens({ input: 1500, output: 500, reasoning: 100, cacheRead: 2000, cacheWrite: 0 }); + stats.addTokens({ input: 800, output: 300, reasoning: 50, cacheRead: 1500, cacheWrite: 0 }); + stats.addTokens({ input: 1200, output: 400, reasoning: 75, cacheRead: 1800, cacheWrite: 0 }); + + const totals = stats.totals(); + expect(totals.input).toBe(3500); // 1500 + 800 + 1200 + expect(totals.output).toBe(1200); // 500 + 300 + 400 + expect(totals.reasoning).toBe(225); // 100 + 50 + 75 + expect(totals.cacheRead).toBe(5300); // 2000 + 1500 + 1800 + expect(totals.cacheWrite).toBe(0); + }); + }); + + describe("reset", () => { + it("should reset all counters to zero", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100, output: 50, reasoning: 25, cacheRead: 200, cacheWrite: 100 }); + + stats.reset(); + + expect(stats.input()).toBe(0); + expect(stats.output()).toBe(0); + expect(stats.reasoning()).toBe(0); + expect(stats.cacheRead()).toBe(0); + expect(stats.cacheWrite()).toBe(0); + }); + + it("should allow accumulation after reset", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100 }); + stats.reset(); + stats.addTokens({ input: 50 }); + expect(stats.input()).toBe(50); + }); + }); + + describe("totals accessor", () => { + it("should reflect current state", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100, output: 50 }); + + const totals1 = stats.totals(); + expect(totals1.input).toBe(100); + expect(totals1.output).toBe(50); + + stats.addTokens({ input: 25 }); + const totals2 = stats.totals(); + expect(totals2.input).toBe(125); + }); + + it("should return a new object each time", () => { + const stats = createSessionStats(); + stats.addTokens({ input: 100 }); + + const totals1 = stats.totals(); + const totals2 = stats.totals(); + + // Should be equal in value but different objects + expect(totals1).toEqual(totals2); + expect(totals1).not.toBe(totals2); + }); + }); +}); diff --git a/tests/unit/time.test.ts b/tests/unit/time.test.ts index 2e95ff2..f05eef1 100644 --- a/tests/unit/time.test.ts +++ b/tests/unit/time.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "bun:test"; -import { formatDuration, calculateEta, formatEta } from "../../src/util/time"; +import { formatDuration, calculateEta, formatEta, formatNumber } from "../../src/util/time"; describe("time utilities", () => { describe("formatDuration", () => { @@ -87,4 +87,52 @@ describe("time utilities", () => { }); }); }); + + describe("formatNumber", () => { + describe("small numbers", () => { + it("should format 0 as '0'", () => { + expect(formatNumber(0)).toBe("0"); + }); + + it("should format 123 as '123'", () => { + expect(formatNumber(123)).toBe("123"); + }); + + it("should format 999 as '999'", () => { + expect(formatNumber(999)).toBe("999"); + }); + }); + + describe("thousands (K suffix)", () => { + it("should format 1000 as '1.0K'", () => { + expect(formatNumber(1000)).toBe("1.0K"); + }); + + it("should format 1234 as '1.2K'", () => { + expect(formatNumber(1234)).toBe("1.2K"); + }); + + it("should format 12500 as '12.5K'", () => { + expect(formatNumber(12500)).toBe("12.5K"); + }); + + it("should format 999999 as '1000.0K'", () => { + expect(formatNumber(999999)).toBe("1000.0K"); + }); + }); + + describe("millions (M suffix)", () => { + it("should format 1000000 as '1.0M'", () => { + expect(formatNumber(1000000)).toBe("1.0M"); + }); + + it("should format 2500000 as '2.5M'", () => { + expect(formatNumber(2500000)).toBe("2.5M"); + }); + + it("should format 10000000 as '10.0M'", () => { + expect(formatNumber(10000000)).toBe("10.0M"); + }); + }); + }); }); From 31b3555c6182f762acfc629737e279a17cb368fd Mon Sep 17 00:00:00 2001 From: shuv Date: Wed, 7 Jan 2026 20:08:06 -0800 Subject: [PATCH 6/7] feat: integrate features into app, components, and CLI Wire up all new features into the main application: App Integration: - Wrap app with ThemeProvider, DialogProvider, ToastProvider, CommandProvider - Add keyboard handlers: Ctrl+P (palette), T (terminal), Shift+T (tasks) - Add colon key detection for steering mode - Register commands: pause/resume, copy attach, tasks, terminal Component Updates: - Header: Add [DEBUG] badge, use theme colors - Footer: Add token display, steering hint, theme colors - Log: Use theme colors, render event details, dim verbose events - Paused: Use theme colors New Components: - Steering overlay for live session interaction - Tasks panel with plan task display CLI Enhancements: - --debug / -d flag for manual session control - --agent / -a flag for agent selection - Build-time version injection - Improved TUI render options Debug Mode: - Skip automatic loop start - N key for new session - P key for prompt input --- bin/ralph | 156 +---- src/app.tsx | 933 +++++++++++++++++++++++++-- src/components/footer.tsx | 90 ++- src/components/header.tsx | 78 ++- src/components/log.tsx | 195 ++++-- src/components/paused.tsx | 40 +- src/components/steering.tsx | 135 ++++ src/components/tasks.tsx | 64 ++ src/index.ts | 222 ++++++- tests/integration/debug-mode.test.ts | 300 +++++++++ 10 files changed, 1864 insertions(+), 349 deletions(-) mode change 100644 => 120000 bin/ralph create mode 100644 src/components/steering.tsx create mode 100644 src/components/tasks.tsx create mode 100644 tests/integration/debug-mode.test.ts diff --git a/bin/ralph b/bin/ralph deleted file mode 100644 index 28637bd..0000000 --- a/bin/ralph +++ /dev/null @@ -1,155 +0,0 @@ -#!/usr/bin/env node -/** - * Ralph CLI Launcher - * - * This is a thin JavaScript wrapper that finds and executes the correct - * platform-specific binary for the current OS/architecture. - * - * The binary is located in one of the optional dependency packages: - * - @hona/ralph-cli-darwin-arm64 - * - @hona/ralph-cli-darwin-x64 - * - @hona/ralph-cli-linux-arm64 - * - @hona/ralph-cli-linux-x64 - * - @hona/ralph-cli-windows-x64 - * - * Environment variables: - * RALPH_BIN_PATH - Override path to the ralph binary - */ - -const childProcess = require("child_process"); -const fs = require("fs"); -const path = require("path"); -const os = require("os"); -const { createRequire } = require("module"); - -/** - * Run the binary with inherited stdio - */ -function run(target) { - const result = childProcess.spawnSync(target, process.argv.slice(2), { - stdio: "inherit", - }); - - if (result.error) { - console.error(result.error.message); - process.exit(1); - } - - const code = typeof result.status === "number" ? result.status : 0; - process.exit(code); -} - -// Check for environment variable override -const envPath = process.env.RALPH_BIN_PATH; -if (envPath) { - run(envPath); -} - -// Map OS and architecture to package naming conventions -const platformMap = { - darwin: "darwin", - linux: "linux", - win32: "windows", -}; - -const archMap = { - x64: "x64", - arm64: "arm64", -}; - -let platform = platformMap[os.platform()]; -if (!platform) { - platform = os.platform(); -} - -let arch = archMap[os.arch()]; -if (!arch) { - arch = os.arch(); -} - -// Construct package and binary names -const packageName = "@hona/ralph-cli-" + platform + "-" + arch; -const binaryName = platform === "windows" ? "ralph.exe" : "ralph"; - -/** - * Find the binary using Node's require.resolve (handles scoped packages correctly) - */ -function findBinaryWithRequire() { - try { - // Create a require function relative to this script - const localRequire = createRequire(__filename); - const packageJsonPath = localRequire.resolve(packageName + "/package.json"); - const packageDir = path.dirname(packageJsonPath); - const binaryPath = path.join(packageDir, "bin", binaryName); - - if (fs.existsSync(binaryPath)) { - return binaryPath; - } - } catch (e) { - // Package not found via require.resolve - } - return null; -} - -/** - * Fallback: Search for the binary in node_modules hierarchy manually - * Handles scoped packages stored at node_modules/@scope/package-name - */ -function findBinaryManual(startDir) { - // For scoped package @hona/ralph-cli-linux-x64, we need to look at: - // node_modules/@hona/ralph-cli-linux-x64/bin/ralph - const scopedParts = packageName.split("/"); - const scope = scopedParts[0]; // "@hona" - const pkgName = scopedParts[1]; // "ralph-cli-linux-x64" - - let current = startDir; - for (;;) { - const modules = path.join(current, "node_modules"); - - if (fs.existsSync(modules)) { - // Check scoped package path: node_modules/@hona/ralph-cli-{platform}-{arch} - const scopedPath = path.join(modules, scope, pkgName, "bin", binaryName); - if (fs.existsSync(scopedPath)) { - return scopedPath; - } - } - - // Move up to parent directory - const parent = path.dirname(current); - if (parent === current) { - // Reached filesystem root - return null; - } - current = parent; - } -} - -// Try to find the binary - first with require.resolve, then manual search -const scriptPath = fs.realpathSync(__filename); -const scriptDir = path.dirname(scriptPath); - -let resolved = findBinaryWithRequire(); -if (!resolved) { - resolved = findBinaryManual(scriptDir); -} - -if (!resolved) { - console.error( - "Error: Could not find the ralph binary for your platform.\n\n" + - "It seems that your package manager failed to install the correct version\n" + - "of the ralph CLI for your platform (" + - platform + - "-" + - arch + - ").\n\n" + - 'You can try manually installing the "' + - packageName + - '" package:\n\n' + - " npm install " + - packageName + - "\n" - ); - process.exit(1); -} - -run(resolved); diff --git a/bin/ralph b/bin/ralph new file mode 120000 index 0000000..282e6cb --- /dev/null +++ b/bin/ralph @@ -0,0 +1 @@ +../dist/hona-ralph-cli-linux-x64/bin/ralph \ No newline at end of file diff --git a/src/app.tsx b/src/app.tsx index f83e34a..d569a17 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -1,14 +1,32 @@ import { render, useKeyboard, useRenderer } from "@opentui/solid"; import type { KeyEvent } from "@opentui/core"; -import { createSignal, onCleanup, Setter } from "solid-js"; +import { createSignal, onCleanup, onMount, Setter, type Accessor } from "solid-js"; import { Header } from "./components/header"; import { Log } from "./components/log"; import { Footer } from "./components/footer"; import { PausedOverlay } from "./components/paused"; +import { SteeringOverlay } from "./components/steering"; +import { DialogProvider, DialogStack, useDialog, useInputFocus } from "./context/DialogContext"; +import { CommandProvider, useCommand, type CommandOption } from "./context/CommandContext"; +import { ToastProvider, useToast } from "./context/ToastContext"; +import { ThemeProvider, useTheme } from "./context/ThemeContext"; +import { ToastStack } from "./components/toast"; +import { DialogSelect, type SelectOption } from "./ui/DialogSelect"; +import { DialogAlert } from "./ui/DialogAlert"; +import { DialogPrompt } from "./ui/DialogPrompt"; +import { keymap, matchesKeybind, type KeybindDef } from "./lib/keymap"; import type { LoopState, LoopOptions, PersistedState } from "./state"; -import { colors } from "./components/colors"; -import { calculateEta } from "./util/time"; +import { detectInstalledTerminals, launchTerminal, getAttachCommand as getAttachCmdFromTerminal, type KnownTerminal } from "./lib/terminal-launcher"; +import { copyToClipboard, detectClipboardTool } from "./lib/clipboard"; +import { loadConfig, setPreferredTerminal } from "./lib/config"; +import { parsePlanTasks, type Task } from "./plan"; +import { Tasks } from "./components/tasks"; + + import { log } from "./util/log"; +import { createDebugSession } from "./loop"; +import { createLoopState, type LoopStateStore, type LoopAction } from "./hooks/useLoopState"; +import { createLoopStats, type LoopStatsStore } from "./hooks/useLoopStats"; type AppProps = { options: LoopOptions; @@ -24,6 +42,7 @@ type AppProps = { export type AppStateSetters = { setState: Setter; updateIterationTimes: (times: number[]) => void; + setSendMessage: (fn: ((message: string) => Promise) | null) => void; }; /** @@ -37,6 +56,7 @@ export type StartAppResult = { // Module-level state setters that will be populated when App renders let globalSetState: Setter | null = null; let globalUpdateIterationTimes: ((times: number[]) => void) | null = null; +let globalSendMessage: ((message: string) => Promise) | null = null; @@ -111,6 +131,9 @@ export async function startApp(props: StartAppProps): Promise { iterationTimesRef.push(...times); globalUpdateIterationTimes!(times); }, + setSendMessage: (fn) => { + globalSendMessage = fn; + }, }; return { exitPromise, stateSetters }; @@ -124,7 +147,30 @@ export function App(props: AppProps) { // which may interfere with logging and other output (matches OpenCode pattern). renderer.disableStdoutInterception(); - // State signal for loop state + // Create loop state store using the hook architecture + // This provides a reducer-based state management pattern with dispatch actions + const loopStore = createLoopState({ + status: "starting", + iteration: props.persistedState.iterationTimes.length + 1, + tasksComplete: 0, + totalTasks: 0, + commits: 0, + linesAdded: 0, + linesRemoved: 0, + events: [], + isIdle: true, + }); + + // Create loop stats store for tracking iteration timing and ETA + const loopStats = createLoopStats(); + + // Initialize loop stats with persisted state + loopStats.initialize( + props.persistedState.startTime, + props.persistedState.iterationTimes + ); + + // State signal for loop state (legacy - being migrated to loopStore) // Initialize iteration to length + 1 since we're about to start the next iteration const [state, setState] = createSignal({ status: "starting", @@ -138,10 +184,40 @@ export function App(props: AppProps) { isIdle: true, // Starts idle, waiting for first LLM response }); - // Signal to track iteration times (for ETA calculation) - const [iterationTimes, setIterationTimes] = createSignal( - props.iterationTimesRef || [...props.persistedState.iterationTimes] - ); + // Steering mode state signals + const [commandMode, setCommandMode] = createSignal(false); + const [commandInput, setCommandInput] = createSignal(""); + + // Tasks panel state signals + const [showTasks, setShowTasks] = createSignal(false); + const [tasks, setTasks] = createSignal([]); + + // Function to refresh tasks from plan file + const refreshTasks = async () => { + if (props.options.planFile) { + const parsed = await parsePlanTasks(props.options.planFile); + setTasks(parsed); + } + }; + + // Initialize tasks on mount and set up polling interval + let tasksRefreshInterval: ReturnType | null = null; + + onMount(() => { + refreshTasks(); + // Poll for task updates every 2 seconds + tasksRefreshInterval = setInterval(() => { + refreshTasks(); + }, 2000); + }); + + // Clean up tasks refresh interval on unmount + onCleanup(() => { + if (tasksRefreshInterval) { + clearInterval(tasksRefreshInterval); + tasksRefreshInterval = null; + } + }); // Export wrapped state setter for external access. Calls requestRender() // after updates to ensure TUI refreshes on all platforms. @@ -150,19 +226,25 @@ export function App(props: AppProps) { renderer.requestRender?.(); return result; }; - globalUpdateIterationTimes = (times: number[]) => setIterationTimes(times); - - // Track elapsed time from the persisted start time - const [elapsed, setElapsed] = createSignal( - Date.now() - props.persistedState.startTime - ); + // Update iteration times in loopStats (used for ETA calculation) + globalUpdateIterationTimes = (times: number[]) => { + // Re-initialize loopStats with the updated iteration times + // This keeps the hook-based stats in sync with external updates + loopStats.initialize(props.persistedState.startTime, times); + }; - // Update elapsed time periodically (5000ms to reduce render frequency) - // Skip updates when idle or paused to reduce unnecessary re-renders + // Update elapsed time and ETA periodically (5000ms to reduce render frequency) + // Uses loopStats hook for pause-aware elapsed time tracking const elapsedInterval = setInterval(() => { const currentState = state(); - if (!currentState.isIdle && currentState.status !== "paused") { - setElapsed(Date.now() - props.persistedState.startTime); + const status = currentState.status; + // Only tick when actively running (not paused, ready, or idle) + if (!currentState.isIdle && status !== "paused" && status !== "ready") { + // Tick loopStats for pause-aware elapsed time (hook-based approach) + loopStats.tick(); + // Update remaining tasks for ETA calculation + const remainingTasks = currentState.totalTasks - currentState.tasksComplete; + loopStats.setRemainingTasks(remainingTasks); } }, 5000); @@ -173,13 +255,6 @@ export function App(props: AppProps) { globalUpdateIterationTimes = null; }); - // Calculate ETA based on iteration times and remaining tasks - const eta = () => { - const currentState = state(); - const remainingTasks = currentState.totalTasks - currentState.tasksComplete; - return calculateEta(iterationTimes(), remainingTasks); - }; - // Pause file path const PAUSE_FILE = ".ralph-pause"; @@ -188,55 +263,744 @@ export function App(props: AppProps) { const file = Bun.file(PAUSE_FILE); const exists = await file.exists(); if (exists) { - // Resume: delete pause file and update status + // Resume: delete pause file and update status via dispatch await Bun.write(PAUSE_FILE, ""); // Ensure file exists before unlinking const fs = await import("node:fs/promises"); await fs.unlink(PAUSE_FILE); + // Use dispatch as primary state update mechanism + loopStore.dispatch({ type: "RESUME" }); + loopStats.resume(); + // Also update legacy state for external compatibility setState((prev) => ({ ...prev, status: "running" })); } else { - // Pause: create pause file and update status + // Pause: create pause file and update status via dispatch await Bun.write(PAUSE_FILE, String(process.pid)); + // Use dispatch as primary state update mechanism + loopStore.dispatch({ type: "PAUSE" }); + loopStats.pause(); + // Also update legacy state for external compatibility setState((prev) => ({ ...prev, status: "paused" })); } }; // Track if we've notified about keyboard events working (only notify once) - let keyboardEventNotified = false; + const [keyboardEventNotified, setKeyboardEventNotified] = createSignal(false); + + /** + * Show the command palette dialog. + * Converts registered commands to SelectOptions for the dialog. + */ + const showCommandPalette = () => { + // This function will be passed to CommandProvider's onShowPalette callback + // The actual implementation uses the dialog context inside AppContent + }; + + return ( + + + + + + + + + + ); +} + +/** + * Props for the inner AppContent component. + */ +type AppContentProps = { + state: () => LoopState; + setState: Setter; + options: LoopOptions; + commandMode: () => boolean; + setCommandMode: (v: boolean) => void; + setCommandInput: (v: string) => void; + togglePause: () => Promise; + renderer: ReturnType; + onQuit: () => void; + onKeyboardEvent?: () => void; + keyboardEventNotified: Accessor; + setKeyboardEventNotified: Setter; + showTasks: () => boolean; + setShowTasks: (v: boolean) => void; + tasks: () => Task[]; + refreshTasks: () => Promise; + // Hook-based state stores (for gradual migration) + loopStore: LoopStateStore; + loopStats: LoopStatsStore; +}; + +/** + * Inner component that uses context hooks for dialogs and commands. + * Separated from App to be inside the context providers. + */ +function AppContent(props: AppContentProps) { + const dialog = useDialog(); + const command = useCommand(); + const toast = useToast(); + const theme = useTheme(); + const { isInputFocused: dialogInputFocused } = useInputFocus(); + + // Get theme colors reactively - call theme.theme() to access the resolved theme + const t = () => theme.theme(); + + // Combined check for any input being focused + const isInputFocused = () => props.commandMode() || dialogInputFocused(); + + /** + * Get the attach command string for the current session. + * Returns null if no session is active. + */ + const getAttachCommand = (): string | null => { + const currentState = props.state(); + if (!currentState.sessionId) return null; + + const serverUrl = currentState.serverUrl || "http://localhost:10101"; + return `opencode attach ${serverUrl} --session ${currentState.sessionId}`; + }; + + /** + * Show a dialog with the attach command for manual copying. + * Used as fallback when clipboard is not available. + */ + const showAttachCommandDialog = () => { + const attachCmd = getAttachCommand(); + if (!attachCmd) { + dialog.show(() => ( + + )); + return; + } + + dialog.show(() => ( + + )); + }; + + /** + * Copy the attach command to clipboard. + * Falls back to showing a dialog if clipboard is unavailable. + */ + const copyAttachCommand = async () => { + const attachCmd = getAttachCommand(); + if (!attachCmd) { + toast.show({ + variant: "warning", + message: "No active session to copy attach command", + }); + return; + } + + // Check if clipboard tool is available + const clipboardTool = await detectClipboardTool(); + if (!clipboardTool) { + // No clipboard tool available - show dialog as fallback + log("app", "No clipboard tool available, showing dialog fallback"); + showAttachCommandDialog(); + return; + } + + // Attempt to copy to clipboard + const result = await copyToClipboard(attachCmd); + if (result.success) { + toast.show({ + variant: "success", + message: "Copied to clipboard", + }); + log("app", "Attach command copied to clipboard"); + } else { + toast.show({ + variant: "error", + message: `Failed to copy: ${result.error || "Unknown error"}`, + }); + log("app", "Failed to copy to clipboard", { error: result.error }); + // Fall back to dialog on error + showAttachCommandDialog(); + } + }; + + // Register default commands on mount + onMount(() => { + // Register "Start/Pause/Resume" command + command.register("togglePause", () => { + const status = props.state().status; + const title = status === "ready" ? "Start" : status === "paused" ? "Resume" : "Pause"; + const description = status === "ready" + ? "Start the automation loop" + : status === "paused" + ? "Resume the automation loop" + : "Pause the automation loop"; + return [ + { + title, + value: "togglePause", + description, + keybind: keymap.togglePause.label, + onSelect: () => { + props.togglePause(); + }, + }, + ]; + }); + + // Register "Copy attach command" action + command.register("copyAttach", () => [ + { + title: "Copy attach command", + value: "copyAttach", + description: "Copy attach command to clipboard", + keybind: keymap.copyAttach.label, + disabled: !props.state().sessionId, + onSelect: () => { + copyAttachCommand(); + }, + }, + ]); + + // Register "Choose default terminal" action + command.register("terminalConfig", () => [ + { + title: "Choose default terminal", + value: "terminalConfig", + description: "Select terminal for launching attach sessions", + keybind: keymap.terminalConfig.label, + onSelect: () => { + showTerminalConfigDialog(); + }, + }, + ]); + + // Register "Toggle tasks panel" action + command.register("toggleTasks", () => [ + { + title: props.showTasks() ? "Hide tasks panel" : "Show tasks panel", + value: "toggleTasks", + description: "Show/hide the tasks checklist from plan file", + keybind: keymap.toggleTasks.label, + onSelect: () => { + log("app", "Tasks panel toggled via command palette"); + props.setShowTasks(!props.showTasks()); + }, + }, + ]); + + // Register "Switch theme" command + command.register("switchTheme", () => [ + { + title: "Switch theme", + value: "switchTheme", + description: `Change UI color theme (current: ${theme.themeName()})`, + onSelect: () => { + // Defer to next tick so command palette's pop() completes first + queueMicrotask(() => showThemeDialog()); + }, + }, + ]); + }); + + /** + * Show terminal configuration dialog. + * Lists detected terminals and allows user to select one. + */ + const showTerminalConfigDialog = async () => { + // Detect installed terminals + const terminals = await detectInstalledTerminals(); + + if (terminals.length === 0) { + dialog.show(() => ( + + )); + return; + } + + // Convert terminals to SelectOption format + const options: SelectOption[] = terminals.map((terminal: KnownTerminal) => ({ + title: terminal.name, + value: terminal.command, + description: `Command: ${terminal.command}`, + })); + + dialog.show(() => ( + { + const selected = terminals.find((t: KnownTerminal) => t.command === opt.value); + if (selected) { + // Save to config + setPreferredTerminal(selected.name); + log("app", "Terminal preference saved", { terminal: selected.name }); + dialog.show(() => ( + + )); + } + }} + onCancel={() => {}} + borderColor={t().secondary} + /> + )); + }; + + /** + * Show theme selection dialog. + * Lists all available themes and allows user to switch. + */ + const showThemeDialog = () => { + const options: SelectOption[] = theme.themeNames.map((name) => ({ + title: name, + value: name, + description: name === theme.themeName() ? "(current)" : undefined, + })); - // Keyboard handling + dialog.show(() => ( + { + theme.setThemeName(opt.value); + log("app", "Theme changed", { theme: opt.value }); + toast.show({ + variant: "success", + message: `Theme changed to ${opt.value}`, + }); + // Force re-render after state updates propagate + queueMicrotask(() => props.renderer.requestRender?.()); + }} + onCancel={() => {}} + borderColor={t().accent} + /> + )); + props.renderer.requestRender?.(); + }; + + /** + * Handle N key press in debug mode: create a new session. + * Only available in debug mode. Creates a session and stores it in state. + */ + const handleDebugNewSession = async () => { + // Only available in debug mode + if (!props.options.debug) { + return; + } + + // Check if session already exists + const currentState = props.state(); + const existingSessionId = currentState.sessionId; + if (existingSessionId) { + dialog.show(() => ( + + )); + return; + } + + log("app", "Debug mode: creating new session via N key"); + + try { + const session = await createDebugSession({ + serverUrl: props.options.serverUrl, + serverTimeoutMs: props.options.serverTimeoutMs, + model: props.options.model, + agent: props.options.agent, + }); + + // Update state via dispatch as primary mechanism + props.loopStore.dispatch({ + type: "SET_SESSION", + sessionId: session.sessionId, + serverUrl: session.serverUrl, + attached: session.attached, + }); + props.loopStore.dispatch({ type: "SET_IDLE", isIdle: true }); + + // Also update legacy state for external compatibility + props.setState((prev) => ({ + ...prev, + sessionId: session.sessionId, + serverUrl: session.serverUrl, + attached: session.attached, + status: "ready", // Ready for input + })); + + // Store sendMessage function for steering mode + globalSendMessage = session.sendMessage; + + log("app", "Debug mode: session created successfully", { + sessionId: session.sessionId + }); + + dialog.show(() => ( + + )); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log("app", "Debug mode: failed to create session", { error: errorMsg }); + + dialog.show(() => ( + + )); + } + }; + + /** + * Handle P key press in debug mode: open prompt dialog for manual input. + * Only available in debug mode with an active session. + */ + const handleDebugPromptInput = () => { + // Only available in debug mode + if (!props.options.debug) { + return; + } + + // Check for active session + const currentState = props.state(); + if (!currentState.sessionId) { + dialog.show(() => ( + + )); + return; + } + + log("app", "Debug mode: opening prompt dialog via P key"); + + dialog.show(() => ( + { + if (!value.trim()) { + return; + } + + if (globalSendMessage) { + log("app", "Debug mode: sending prompt", { message: value.slice(0, 50) }); + try { + await globalSendMessage(value); + // Update status via dispatch as primary mechanism + props.loopStore.dispatch({ type: "START" }); + props.loopStore.dispatch({ type: "SET_IDLE", isIdle: false }); + // Also update legacy state for external compatibility + props.setState((prev) => ({ + ...prev, + status: "running", + isIdle: false, + })); + } catch (error) { + const errorMsg = error instanceof Error ? error.message : String(error); + log("app", "Debug mode: failed to send prompt", { error: errorMsg }); + dialog.show(() => ( + + )); + } + } else { + log("app", "Debug mode: no sendMessage function available"); + dialog.show(() => ( + + )); + } + }} + onCancel={() => { + log("app", "Debug mode: prompt dialog cancelled"); + }} + borderColor={t().accent} + /> + )); + }; + + /** + * Handle T key press: launch terminal with attach command or show config dialog. + * Requires an active session. Uses preferred terminal if configured. + */ + const handleTerminalLaunch = async () => { + const currentState = props.state(); + + // Check for active session + if (!currentState.sessionId) { + dialog.show(() => ( + + )); + return; + } + + // Load config to check for preferred terminal + const config = loadConfig(); + + if (!config.preferredTerminal) { + // No configured terminal: show config dialog + log("app", "No preferred terminal configured, showing dialog"); + await showTerminalConfigDialog(); + return; + } + + // Find the preferred terminal in detected terminals + const terminals = await detectInstalledTerminals(); + const preferredTerminal = terminals.find( + (t: KnownTerminal) => t.name === config.preferredTerminal + ); + + if (!preferredTerminal) { + // Preferred terminal not found/installed + log("app", "Preferred terminal not found", { preferred: config.preferredTerminal }); + dialog.show(() => ( + + )); + await showTerminalConfigDialog(); + return; + } + + // Build attach command using server URL from state (supports external/attached mode) + const serverUrl = currentState.serverUrl || "http://localhost:10101"; + const attachCmd = getAttachCmdFromTerminal(serverUrl, currentState.sessionId); + + log("app", "Launching terminal", { + terminal: preferredTerminal.name, + serverUrl, + sessionId: currentState.sessionId, + }); + + // Launch the terminal + const result = await launchTerminal(preferredTerminal, attachCmd); + + if (!result.success) { + dialog.show(() => ( + + )); + } + }; + + /** + * Detect if the `:` (colon) key was pressed. + * Handles multiple keyboard configurations: + * - Direct `:` character (Kitty protocol or non-US keyboards) + * - Shift+`;` (US keyboard layout via raw mode) + * - Semicolon with shift modifier + */ + const isColonKey = (e: KeyEvent): boolean => { + // Direct colon character (most common case with Kitty protocol) + if (e.name === ":") return true; + // Raw character is colon + if (e.raw === ":") return true; + // Shift+semicolon on US keyboard layout + if (e.name === ";" && e.shift) return true; + return false; + }; + + /** + * Show the command palette dialog with all registered commands. + */ + const showCommandPalette = () => { + if (dialog.hasDialogs()) { + return; + } + + const commands = command.getCommands(); + const options = commands.map((cmd): CommandOption & { onSelect: () => void } => ({ + title: cmd.title, + value: cmd.value, + description: cmd.description, + keybind: cmd.keybind, + disabled: cmd.disabled, + onSelect: cmd.onSelect, + })); + + dialog.show(() => ( + { + // Find and execute the command + const cmd = commands.find(c => c.value === opt.value); + cmd?.onSelect(); + }} + onCancel={() => {}} + borderColor={t().accent} + /> + )); + props.renderer.requestRender?.(); + }; + + // Keyboard handling - now inside context providers useKeyboard((e: KeyEvent) => { // Notify caller that OpenTUI keyboard handling is working - // This allows the caller to skip setting up a fallback stdin handler - if (!keyboardEventNotified && props.onKeyboardEvent) { - keyboardEventNotified = true; + // Also log the first key event for diagnostic purposes (Phase 1.1) + if (!props.keyboardEventNotified() && props.onKeyboardEvent) { + props.setKeyboardEventNotified(true); props.onKeyboardEvent(); + // Log first key event to diagnose keyboard issues + log("keyboard", "First OpenTUI key event received", { + key: e.name, + ctrl: e.ctrl, + shift: e.shift, + meta: e.meta, + raw: e.raw, + isInputFocused: isInputFocused(), + commandMode: props.commandMode(), + dialogInputFocused: dialogInputFocused(), + }); } - + const key = e.name.toLowerCase(); - // p key: toggle pause - if (key === "p" && !e.ctrl && !e.meta) { - togglePause(); + // SAFETY VALVE: Ctrl+C always quits, even if a dialog is open/broken + // This ensures users can always exit the app without killing the terminal + if (key === "c" && e.ctrl) { + log("app", "Quit requested via Ctrl+C (safety valve)"); + props.renderer.setTerminalTitle(""); + props.renderer.destroy(); + props.onQuit(); return; } - // q key: quit - if (key === "q" && !e.ctrl && !e.meta) { - log("app", "Quit requested via 'q' key"); - renderer.setTerminalTitle(""); - renderer.destroy(); - props.onQuit(); + // Skip if any input is focused (dialogs, steering mode, etc.) + if (isInputFocused()) return; + + // ESC key: close tasks panel if open + if (key === "escape" && props.showTasks()) { + log("app", "Tasks panel closed via ESC"); + props.setShowTasks(false); return; } - // Ctrl+C: quit - if (key === "c" && e.ctrl) { - log("app", "Quit requested via Ctrl+C"); - renderer.setTerminalTitle(""); - renderer.destroy(); + // c: open command palette + if (matchesKeybind(e, keymap.commandPalette)) { + log("app", "Command palette opened via 'c' key"); + showCommandPalette(); + return; + } + + // : key: open steering mode (requires active session) + if (isColonKey(e) && !e.ctrl && !e.meta) { + const currentState = props.state(); + // Only allow steering when there's an active session + if (currentState.sessionId) { + log("app", "Steering mode opened via ':' key"); + props.setCommandMode(true); + props.setCommandInput(""); + } + return; + } + + // p key: toggle pause OR prompt input (debug mode) + // Phase 2.2: Use matchesKeybind for consistent key routing + if (matchesKeybind(e, keymap.togglePause)) { + if (props.options.debug) { + // In debug mode, p opens prompt input dialog + handleDebugPromptInput(); + return; + } + // In normal mode, p toggles pause + props.togglePause(); + return; + } + + // t key: launch terminal with attach command (only when no modifiers) + if (matchesKeybind(e, keymap.terminalConfig)) { + handleTerminalLaunch(); + return; + } + + // Shift+T: toggle tasks panel + if (matchesKeybind(e, keymap.toggleTasks)) { + log("app", "Tasks panel toggled via Shift+T"); + props.setShowTasks(!props.showTasks()); + return; + } + + // n key: create new session (debug mode only) + if (key === "n" && !e.ctrl && !e.meta && !e.shift) { + if (props.options.debug) { + handleDebugNewSession(); + return; + } + } + + // q key: quit + // Phase 2.2: Use matchesKeybind for consistent key routing + if (matchesKeybind(e, keymap.quit)) { + log("app", "Quit requested via 'q' key"); + props.renderer.setTerminalTitle(""); + props.renderer.destroy(); props.onQuit(); return; } + + // Note: Ctrl+C is handled above as a safety valve (before isInputFocused check) }); return ( @@ -244,24 +1008,73 @@ export function App(props: AppProps) { flexDirection="column" width="100%" height="100%" - backgroundColor={colors.bgDark} + backgroundColor={t().background} >
- +