From e7a472b6f12106bb86bbc5ea5a2ddcf654ea4a87 Mon Sep 17 00:00:00 2001 From: Sylvain Gougouzian Date: Mon, 13 Jul 2026 12:38:21 +0200 Subject: [PATCH] feat: dynamic theme system with 5 themes and persistence - 5 themes: dark, light, nord, dracula, tokyonight - t() function for runtime theme resolution - persist theme choice to ~/.paikea/config.json - theme switching via Ctrl+P palette - remove decorative frise from header --- src/state/config.ts | 40 ++++ src/tui/components/AgentSteps.tsx | 26 +-- src/tui/components/CommandPalette.tsx | 16 +- src/tui/components/Header.tsx | 42 ++--- src/tui/components/PromptInput.tsx | 18 +- src/tui/components/ResultPane.tsx | 51 +++-- src/tui/components/StatusBar.tsx | 77 ++++---- src/tui/components/ThinkingPane.tsx | 28 ++- src/tui/components/Timeline.tsx | 40 ++-- src/tui/theme.tsx | 261 ++++++++++++++++++++++---- 10 files changed, 431 insertions(+), 168 deletions(-) create mode 100644 src/state/config.ts diff --git a/src/state/config.ts b/src/state/config.ts new file mode 100644 index 0000000..6ff50e6 --- /dev/null +++ b/src/state/config.ts @@ -0,0 +1,40 @@ +import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs"; +import { join } from "node:path"; + +const CONFIG_DIR = join(process.env.HOME ?? "~", ".paikea"); +const CONFIG_FILE = join(CONFIG_DIR, "config.json"); + +interface PaikeaConfig { + theme: string; +} + +function ensureConfigDir() { + mkdirSync(CONFIG_DIR, { recursive: true }); +} + +export function loadConfig(): PaikeaConfig { + if (!existsSync(CONFIG_FILE)) { + return { theme: "dark" }; + } + try { + const raw = readFileSync(CONFIG_FILE, "utf-8"); + return JSON.parse(raw) as PaikeaConfig; + } catch { + return { theme: "dark" }; + } +} + +export function saveConfig(config: PaikeaConfig) { + ensureConfigDir(); + writeFileSync(CONFIG_FILE, JSON.stringify(config, null, 2)); +} + +export function getSavedTheme(): string { + return loadConfig().theme; +} + +export function saveTheme(themeName: string) { + const config = loadConfig(); + config.theme = themeName; + saveConfig(config); +} diff --git a/src/tui/components/AgentSteps.tsx b/src/tui/components/AgentSteps.tsx index 1861344..f982953 100644 --- a/src/tui/components/AgentSteps.tsx +++ b/src/tui/components/AgentSteps.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import type { AgentStep } from "../../types"; -import { colors, symbols } from "../theme"; +import { symbols, t } from "../theme"; const TOOL_ICONS: Record = { web_search: "๐Ÿ”", @@ -13,21 +13,25 @@ const TOOL_ICONS: Record = { interface AgentStepsProps { steps: AgentStep[]; + maxHeight: number; } -export function AgentSteps({ steps }: AgentStepsProps) { - if (steps.length === 0) return null; +export function AgentSteps({ steps, maxHeight }: AgentStepsProps) { + if (steps.length === 0 || maxHeight < 2) return null; + + const visibleSteps = steps.slice(0, Math.max(1, maxHeight - 2)); return ( - + โ”€โ”€ agent โ”€โ”€ - {steps.map((step) => { + {visibleSteps.map((step) => { const icon = getStepIcon(step); const text = getStepText(step); const indicator = getStepIndicator(step); @@ -87,11 +91,11 @@ function getStepIndicator(step: AgentStep): string { } function getStepColor(step: AgentStep): string { - if (step.status === "error") return colors.fg.error; - if (step.status === "running") return colors.fg.accent; - if (step.type === "tool_call") return colors.fg.warning; - if (step.type === "tool_result") return colors.fg.success; - return colors.fg.secondary; + if (step.status === "error") return t().fg.error; + if (step.status === "running") return t().fg.accent; + if (step.type === "tool_call") return t().fg.warning; + if (step.type === "tool_result") return t().fg.success; + return t().fg.secondary; } function truncateStr(text: string, max: number): string { diff --git a/src/tui/components/CommandPalette.tsx b/src/tui/components/CommandPalette.tsx index f15f9e3..b6abfb4 100644 --- a/src/tui/components/CommandPalette.tsx +++ b/src/tui/components/CommandPalette.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import type { Action } from "../../types"; -import { colors } from "../theme"; +import { t } from "../theme"; interface CommandPaletteProps { actions: Action[]; @@ -18,26 +18,26 @@ export function CommandPalette({ left="50%" top="50%" borderStyle="round" - borderColor={colors.fg.accent} - backgroundColor={colors.bg.panelAlt} + borderColor={t().fg.accent} + backgroundColor={t().bg.panelAlt} > - + {" "} Actions{" "} - โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ + โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€โ”€ {actions.map((action, i) => { const isSelected = i === selectedIndex; return ( - + {isSelected ? "โฏ " : " "} - + {action.label} {action.shortcut && ( - {action.shortcut} + {action.shortcut} )} ); diff --git a/src/tui/components/Header.tsx b/src/tui/components/Header.tsx index f766404..7cce982 100644 --- a/src/tui/components/Header.tsx +++ b/src/tui/components/Header.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import type { Model } from "../../types"; -import { colors, FRISE_CHARS, friseColor, symbols } from "../theme"; +import { symbols, t } from "../theme"; interface HeaderProps { model: Model | null; @@ -8,34 +8,18 @@ interface HeaderProps { export function Header({ model }: HeaderProps) { return ( - - - - ๐Ÿ„ paikea - - - - {symbols.gear} {model?.name ?? "none"} - - - - enter send ยท tab model ยท โ†‘ tools ยท : palette ยท esc quit - - - + + + ๐Ÿ„ paikea + + + + {symbols.gear} {model?.name ?? "none"} + + + + enter send ยท tab model ยท ctrl+โ† โ†’ steps ยท ctrl+p palette ยท esc quit + ); } - -function Frise() { - const chars = FRISE_CHARS.map((char, idx) => { - const color = friseColor(idx); - return ( - - {char} - - ); - }); - - return {chars}; -} diff --git a/src/tui/components/PromptInput.tsx b/src/tui/components/PromptInput.tsx index 49bb659..a9141de 100644 --- a/src/tui/components/PromptInput.tsx +++ b/src/tui/components/PromptInput.tsx @@ -1,5 +1,5 @@ import { Box, Text } from "ink"; -import { colors } from "../theme"; +import { t } from "../theme"; interface PromptInputProps { prompt: string; @@ -16,20 +16,20 @@ export function PromptInput({ suggestions, suggestionIndex, }: PromptInputProps) { - const lineColor = focused ? colors.fg.accent : colors.fg.dim; + const lineColor = focused ? t().fg.accent : t().fg.dim; return ( - + โฏ{" "} {prompt ? ( - {prompt} + {prompt} ) : ( - type your prompt... + type your prompt... )} - {focused && โ–ˆ} + {focused && โ–ˆ} {(() => { @@ -42,15 +42,15 @@ export function PromptInput({ return ( <> {remaining && ( - + {remaining} )} - tab โ†’ + tab โ†’ ); } - return Enter to send; + return Enter to send; })()} diff --git a/src/tui/components/ResultPane.tsx b/src/tui/components/ResultPane.tsx index 84935b4..49a640a 100644 --- a/src/tui/components/ResultPane.tsx +++ b/src/tui/components/ResultPane.tsx @@ -1,25 +1,38 @@ import { Box, Text } from "ink"; -import { colors, symbols } from "../theme"; +import { symbols, t } from "../theme"; interface ResultPaneProps { content: string; scrollOffset: number; + maxHeight: number; } -export function ResultPane({ content, scrollOffset }: ResultPaneProps) { +export function ResultPane({ + content, + scrollOffset, + maxHeight, +}: ResultPaneProps) { + const height = Math.max(3, maxHeight); + return ( - + โ”€โ”€ response โ”€โ”€ {content ? ( - + ) : ( - + { symbols.spinner[ Math.floor(Date.now() / 100) % symbols.spinner.length @@ -35,44 +48,50 @@ export function ResultPane({ content, scrollOffset }: ResultPaneProps) { function ResultContent({ content, scrollOffset, + maxLines, }: { content: string; scrollOffset: number; + maxLines: number; }) { const lines = content.split("\n"); - const visibleLines = lines.slice(scrollOffset, scrollOffset + 50); + const visibleLines = lines.slice( + scrollOffset, + scrollOffset + Math.max(1, maxLines), + ); return ( - {visibleLines.map((line) => { + {visibleLines.map((line, idx) => { let displayLine = line; - let color = colors.fg.primary; + let color = t().fg.primary; let bold = false; if (line.startsWith("# ")) { displayLine = line.slice(2); - color = colors.fg.accent; + color = t().fg.accent; bold = true; } else if (line.startsWith("## ")) { displayLine = line.slice(3); - color = colors.fg.accent; + color = t().fg.accent; } else if (line.startsWith("### ")) { displayLine = line.slice(4); - color = colors.fg.warning; + color = t().fg.warning; } else if (line.startsWith("**") && line.endsWith("**")) { displayLine = line.slice(2, -2); bold = true; } else if (line.startsWith("```")) { - color = colors.fg.dim; + color = t().fg.dim; } else if (line.startsWith("- ")) { displayLine = ` ${symbols.dot} ${line.slice(2)}`; - color = colors.fg.secondary; + color = t().fg.secondary; } else if (/^\d+\.\s/.test(line)) { - color = colors.fg.secondary; + color = t().fg.secondary; } return ( - + // biome-ignore lint/suspicious/noArrayIndexKey: flat text list, never reorders + {displayLine} ); diff --git a/src/tui/components/StatusBar.tsx b/src/tui/components/StatusBar.tsx index cce27c1..ed31325 100644 --- a/src/tui/components/StatusBar.tsx +++ b/src/tui/components/StatusBar.tsx @@ -1,6 +1,6 @@ import { Box, Text } from "ink"; import type { Model } from "../../types"; -import { colors, symbols } from "../theme"; +import { symbols, t } from "../theme"; interface StatusBarProps { model: Model | null; @@ -8,6 +8,7 @@ interface StatusBarProps { streaming: boolean; toolbarIndex: number; isToolbarMode: boolean; + confirmQuit: boolean; } const TOOLBAR_BUTTONS = [ @@ -23,44 +24,54 @@ export function StatusBar({ streaming, toolbarIndex, isToolbarMode, + confirmQuit, }: StatusBarProps) { return ( - tokens {tokens} - - {!streaming ? ( - TOOLBAR_BUTTONS.map((btn, i) => { - const isSelected = isToolbarMode && i === toolbarIndex; - return ( - - - {" "} - {btn.label}{" "} - - - ); - }) - ) : ( - + {confirmQuit ? ( + {" "} - { - symbols.spinner[ - Math.floor(Date.now() / 100) % symbols.spinner.length - ] - }{" "} - streaming{" "} + sure quit? (esc again){" "} - )} - - {streaming ? ( - - ) : isToolbarMode ? ( - Tab/Enter ) : ( - : palette + <> + tokens {tokens} + + {!streaming ? ( + TOOLBAR_BUTTONS.map((btn, i) => { + const isSelected = isToolbarMode && i === toolbarIndex; + return ( + + + {" "} + {btn.label}{" "} + + + ); + }) + ) : ( + + {" "} + { + symbols.spinner[ + Math.floor(Date.now() / 100) % symbols.spinner.length + ] + }{" "} + streaming{" "} + + )} + + {streaming ? ( + + ) : isToolbarMode ? ( + Tab/Enter + ) : ( + ctrl+p palette + )} + )} ); diff --git a/src/tui/components/ThinkingPane.tsx b/src/tui/components/ThinkingPane.tsx index 8a7317d..66c790e 100644 --- a/src/tui/components/ThinkingPane.tsx +++ b/src/tui/components/ThinkingPane.tsx @@ -1,27 +1,41 @@ import { Box, Text } from "ink"; -import { colors, symbols } from "../theme"; +import { symbols, t } from "../theme"; interface ThinkingPaneProps { content: string; visible: boolean; + maxHeight: number; } -export function ThinkingPane({ content, visible }: ThinkingPaneProps) { - if (!visible) return null; +export function ThinkingPane({ + content, + visible, + maxHeight, +}: ThinkingPaneProps) { + if (!visible || maxHeight < 2) return null; + + const lines = content.split("\n"); + const visibleLines = lines.slice(0, Math.max(1, maxHeight - 2)); return ( - + โ”€โ”€ thinking โ”€โ”€ {content ? ( - {content} + visibleLines.map((line, i) => ( + // biome-ignore lint/suspicious/noArrayIndexKey: flat text list, never reorders + + {line} + + )) ) : ( - + { symbols.spinner[ Math.floor(Date.now() / 100) % symbols.spinner.length diff --git a/src/tui/components/Timeline.tsx b/src/tui/components/Timeline.tsx index e31039d..9a71c8f 100644 --- a/src/tui/components/Timeline.tsx +++ b/src/tui/components/Timeline.tsx @@ -1,49 +1,57 @@ import { Box, Text } from "ink"; import type { Step } from "../../types"; -import { colors, symbols } from "../theme"; +import { symbols, t } from "../theme"; interface TimelineProps { steps: Step[]; + selectedIndex: number; } -export function Timeline({ steps }: TimelineProps) { +export function Timeline({ steps, selectedIndex }: TimelineProps) { if (steps.length === 0) return null; return ( {steps.map((step, i) => { const isLast = i === steps.length - 1; + const isSelected = i === selectedIndex; return ( {step.status === "done" || step.status === "current" ? symbols.bullet - : symbols.empty} + : isSelected + ? "โ—†" + : symbols.empty} {step.name} {!isLast && ( - + {" "} {symbols.divider} {symbols.divider} diff --git a/src/tui/theme.tsx b/src/tui/theme.tsx index ed70f32..be85aac 100644 --- a/src/tui/theme.tsx +++ b/src/tui/theme.tsx @@ -1,24 +1,228 @@ -export const colors = { +export interface ThemeColors { fg: { - primary: "#e0e0e0", - secondary: "#8a8a8a", - dim: "#555555", - accent: "#45b7d1", - success: "#6bcb77", - warning: "#ffc93c", - error: "#ff6b6b", - thinking: "#a29bfe", - muted: "#636e72", - }, + primary: string; + secondary: string; + dim: string; + accent: string; + success: string; + warning: string; + error: string; + thinking: string; + muted: string; + }; bg: { - header: "#0d1117", - panel: "#161b22", - panelAlt: "#1c2333", - status: "#0d1117", - input: "#161b22", - thinking: "#1a1530", + header: string; + panel: string; + panelAlt: string; + status: string; + input: string; + thinking: string; + }; +} + +export interface Theme { + name: string; + colors: ThemeColors; +} + +const themes: Theme[] = [ + { + name: "dark", + colors: { + fg: { + primary: "#e0e0e0", + secondary: "#8a8a8a", + dim: "#555555", + accent: "#45b7d1", + success: "#6bcb77", + warning: "#ffc93c", + error: "#ff6b6b", + thinking: "#a29bfe", + muted: "#636e72", + }, + bg: { + header: "#0d1117", + panel: "#161b22", + panelAlt: "#1c2333", + status: "#0d1117", + input: "#161b22", + thinking: "#1a1530", + }, + }, }, -}; + { + name: "light", + colors: { + fg: { + primary: "#1a1a2e", + secondary: "#6c7a89", + dim: "#a0aec0", + accent: "#2b6cb0", + success: "#276749", + warning: "#b7791f", + error: "#c53030", + thinking: "#553c9a", + muted: "#718096", + }, + bg: { + header: "#f7fafc", + panel: "#edf2f7", + panelAlt: "#e2e8f0", + status: "#f7fafc", + input: "#edf2f7", + thinking: "#eef0f7", + }, + }, + }, + { + name: "nord", + colors: { + fg: { + primary: "#eceff4", + secondary: "#616e88", + dim: "#4c566a", + accent: "#88c0d0", + success: "#a3be8c", + warning: "#ebcb8b", + error: "#bf616a", + thinking: "#b48ead", + muted: "#7b88a1", + }, + bg: { + header: "#2e3440", + panel: "#3b4252", + panelAlt: "#434c5e", + status: "#2e3440", + input: "#3b4252", + thinking: "#353c4a", + }, + }, + }, + { + name: "dracula", + colors: { + fg: { + primary: "#f8f8f2", + secondary: "#6272a4", + dim: "#44475a", + accent: "#bd93f9", + success: "#50fa7b", + warning: "#f1fa8c", + error: "#ff5555", + thinking: "#ff79c6", + muted: "#8394a2", + }, + bg: { + header: "#282a36", + panel: "#2d303e", + panelAlt: "#343746", + status: "#282a36", + input: "#2d303e", + thinking: "#2a2d3b", + }, + }, + }, + { + name: "tokyonight", + colors: { + fg: { + primary: "#c0caf5", + secondary: "#565f89", + dim: "#414868", + accent: "#7aa2f7", + success: "#9ece6a", + warning: "#e0af68", + error: "#f7768e", + thinking: "#bb9af7", + muted: "#787c99", + }, + bg: { + header: "#1a1b26", + panel: "#1f2335", + panelAlt: "#24283b", + status: "#1a1b26", + input: "#1f2335", + thinking: "#1e2030", + }, + }, + }, +]; + +let currentThemeIndex = 0; + +export function getThemes(): Theme[] { + return themes; +} + +export function getCurrentTheme(): Theme { + const theme = themes[currentThemeIndex]; + if (theme) return theme; + const fallback = themes[0]; + if (fallback) return fallback; + return { + name: "dark", + colors: { + fg: { + primary: "#e0e0e0", + secondary: "#8a8a8a", + dim: "#555555", + accent: "#45b7d1", + success: "#6bcb77", + warning: "#ffc93c", + error: "#ff6b6b", + thinking: "#a29bfe", + muted: "#636e72", + }, + bg: { + header: "#0d1117", + panel: "#161b22", + panelAlt: "#1c2333", + status: "#0d1117", + input: "#161b22", + thinking: "#1a1530", + }, + }, + }; +} + +export function setThemeByName(name: string): Theme | null { + const idx = themes.findIndex((t) => t.name === name); + if (idx === -1) return null; + currentThemeIndex = idx; + return themes[idx] ?? null; +} + +export function getThemeNames(): string[] { + return themes.map((t) => t.name); +} + +export function t(): ThemeColors { + const theme = themes[currentThemeIndex]; + if (theme) return theme.colors; + const fallback = themes[0]; + if (fallback) return fallback.colors; + return { + fg: { + primary: "#e0e0e0", + secondary: "#8a8a8a", + dim: "#555555", + accent: "#45b7d1", + success: "#6bcb77", + warning: "#ffc93c", + error: "#ff6b6b", + thinking: "#a29bfe", + muted: "#636e72", + }, + bg: { + header: "#0d1117", + panel: "#161b22", + panelAlt: "#1c2333", + status: "#0d1117", + input: "#161b22", + thinking: "#1a1530", + }, + }; +} export const symbols = { bullet: "โ—", @@ -31,24 +235,3 @@ export const symbols = { cross: "โœ—", gear: "โš™", }; - -const FRISE_PALETTE = [ - "#ff6b6b", - "#ff8e53", - "#ffc93c", - "#6bcb77", - "#4ecdc4", - "#45b7d1", - "#6c5ce7", - "#a29bfe", - "#fd79a8", - "#e17055", -]; - -export function friseColor(index: number): string { - return ( - FRISE_PALETTE[index % FRISE_PALETTE.length] ?? FRISE_PALETTE[0] ?? "#ffffff" - ); -} - -export const FRISE_CHARS = ["โ–‘", "โ–’", "โ–“", "โ–ˆ", "โ–“", "โ–’", "โ–‘"]; -- 2.51.2