From 0dc8337365183ed9bac085971a06e6f5c667f270 Mon Sep 17 00:00:00 2001 From: Levi Neely Date: Fri, 24 Apr 2026 21:14:06 +0200 Subject: [PATCH] theme system, acme theme, 9P filesystem browser - Theme plumbing: registry, localStorage, CSS variable overrides - Acme theme: Plan 9 palette, Go fonts, 9term-style chat, u:/a: prefixes - 125% zoom, acme as default - 9P filesystem browser: clickable welcome tree, directory/file viewer - /s/ shows sessions only, click opens chat directly - Session deselect (click again) returns to welcome - Kill button on session cards, improved stop button visibility - Tool call display: terminal icon, extract code from JSON - Refactor hardcoded colors to CSS variables --- index.html | 1 + src/app.tsx | 47 +++++++- src/components/ChatPane.tsx | 89 ++++++++++++--- src/components/Sidebar.tsx | 22 +++- src/lib/api.ts | 8 ++ src/lib/theme.ts | 91 +++++++++++++++ src/style.css | 217 ++++++++++++++++++++++++++++++------ vite.config.ts | 4 + 8 files changed, 424 insertions(+), 55 deletions(-) create mode 100644 src/lib/theme.ts diff --git a/index.html b/index.html index 4808f0b..d508699 100644 --- a/index.html +++ b/index.html @@ -7,6 +7,7 @@ +
diff --git a/src/app.tsx b/src/app.tsx index e353947..9674e59 100644 --- a/src/app.tsx +++ b/src/app.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'preact/hooks' import { Sidebar } from './components/Sidebar' import { ChatPane } from './components/ChatPane' import { NewSession } from './components/NewSession' +import { getTheme, getThemes, loadThemeName, saveThemeName, applyTheme } from './lib/theme' import { listSessions, getSessionCfg, @@ -10,6 +11,7 @@ import { sendCtl, createSession, killSession, + browse9p, } from './lib/api' import type { IdxEntry } from './lib/api' import { parseChat } from './lib/chat' @@ -21,6 +23,42 @@ export function App() { const [cfg, setCfg] = useState | null>(null) const [messages, setMessages] = useState([]) const [showNew, setShowNew] = useState(false) + const [themeName, setThemeName] = useState(loadThemeName) + const [browsePath, setBrowsePath] = useState(null) + const [browseData, setBrowseData] = useState(null) + + // Apply theme on mount and when changed. + useEffect(() => { applyTheme(getTheme(themeName)) }, [themeName]) + + const handleSetTheme = useCallback((name: string) => { + saveThemeName(name) + setThemeName(name) + }, []) + + const handleBrowse = useCallback(async (path: string) => { + setSelectedId(null) + setBrowsePath(path) + if (path === '/mnt/') { + // Synthetic root listing + setBrowseData([ + { name: 's', is_dir: true }, + { name: 'a', is_dir: true }, + { name: 'm', is_dir: true }, + { name: 't', is_dir: true }, + { name: 'sk', is_dir: true }, + ]) + } else { + const apiPath = path.startsWith('/mnt/') ? '/' + path.slice(5) : path + const data = await browse9p(apiPath) + setBrowseData(data) + } + }, []) + + const handleSelect = useCallback((id: string | null) => { + setSelectedId(id) + setBrowsePath(null) + setBrowseData(null) + }, []) // Session list — poll every 5s. useEffect(() => { @@ -89,9 +127,12 @@ export function App() { setShowNew(true)} onKill={handleKill} + themes={getThemes()} + currentTheme={themeName} + onThemeChange={handleSetTheme} /> s.id === selectedId) ?? null} @@ -99,6 +140,10 @@ export function App() { messages={messages} onSend={handleSend} onStop={handleStop} + browsePath={browsePath} + browseData={browseData} + onBrowse={handleBrowse} + onSelectSession={handleSelect} /> {showNew && ( setShowNew(false)} onCreate={handleCreate} /> diff --git a/src/components/ChatPane.tsx b/src/components/ChatPane.tsx index 30c734e..f73eb48 100644 --- a/src/components/ChatPane.tsx +++ b/src/components/ChatPane.tsx @@ -13,6 +13,10 @@ interface Props { messages: ChatMessage[] onSend: (text: string) => void onStop: () => void + browsePath: string | null + browseData: any + onBrowse: (path: string) => void + onSelectSession: (id: string) => void } function stateClass(state: string) { @@ -21,7 +25,7 @@ function stateClass(state: string) { return 'running' } -export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) { +export function ChatPane({ session, cfg, messages, onSend, onStop, browsePath, browseData, onBrowse, onSelectSession }: Props) { const listRef = useRef(null) const [atBottom, setAtBottom] = useState(true) @@ -42,7 +46,11 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) { if (!session || !cfg) { return (
- + {browsePath && browsePath !== '/mnt/' ? ( + + ) : ( + + )}
) } @@ -54,6 +62,7 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
+ onBrowse('/mnt/s/')}>↑{' '} /s/{sid}/chat
@@ -75,13 +84,13 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
{msg.role === 'user' ? ( -
{msg.content}
+
u: {msg.content}
) : msg.role === 'tool' ? ( ) : (
/, '

a: ') }} /> )}

@@ -107,6 +116,11 @@ function formatToolContent(raw: string): { name: string; body: string } { if (!m) return { name: '', body: raw } try { const obj = JSON.parse(m[2]) + // For execute_code, extract just the code/tool from steps + if (m[1] === 'execute_code' && obj.steps) { + const lines = obj.steps.map((s: any) => s.code ?? s.tool ?? JSON.stringify(s)).join('\n') + return { name: m[1], body: lines } + } return { name: m[1], body: JSON.stringify(obj, null, 2) } } catch { return { name: m[1], body: m[2] } @@ -117,20 +131,22 @@ function ToolCall({ content }: { content: string }) { const { name, body } = formatToolContent(content) return (
- →{' '} - {name && {name}} +
+ 🖥️ + {name && {name}} +
{body}
) } -function Welcome() { +function Welcome({ onBrowse }: { onBrowse: (path: string) => void }) { const tree: [string, string][] = [ - ['/s/', 'sessions'], - ['/m/', 'memory'], - ['/t/', 'tools'], - ['/a/', 'agents'], - ['/sk/', 'skills'], + ['/mnt/s/', 'sessions'], + ['/mnt/m/', 'memory'], + ['/mnt/t/', 'tools'], + ['/mnt/a/', 'agents'], + ['/mnt/sk/', 'skills'], ] return ( @@ -140,14 +156,57 @@ function Welcome() {
9p filesystem
{tree.map(([path, desc]) => ( -
- {path} + ))}
-
select a session or create one
+
select a session or browse the filesystem
+
+ ) +} + +function FsBrowser({ path, data, onNavigate, onSelectSession }: { path: string; data: any; onNavigate: (p: string) => void; onSelectSession: (id: string) => void }) { + const parent = path === '/mnt/' ? null : path.replace(/[^/]+\/?$/, '') || '/mnt/' + const displayPath = path.startsWith('/mnt') ? path.slice(4) : path + const isSessionDir = path === '/mnt/s/' + + // For /s/, filter to directories only and clicking selects the session + const filteredData = isSessionDir && Array.isArray(data) + ? data.filter((e: any) => e.is_dir) + : data + return ( +
+
+ {parent && onNavigate(parent)}>↑} + {parent && ' '}{displayPath} +
+
+ {filteredData === null ? ( +
loading…
+ ) : Array.isArray(filteredData) && filteredData.length > 0 && typeof filteredData[0] === 'object' ? ( + filteredData.map((entry: any) => ( +
isSessionDir && entry.is_dir + ? onSelectSession(entry.name) + : entry.is_dir ? onNavigate(path + entry.name + '/') : onNavigate(path + entry.name)} + > + {entry.name}{entry.is_dir ? '/' : ''} + {entry.size > 0 && {entry.size}} +
+ )) + ) : Array.isArray(filteredData) ? ( +
{filteredData.join('\n')}
+ ) : typeof filteredData === 'object' ? ( +
{Object.entries(filteredData).map(([k, v]) => `${k}=${v}`).join('\n')}
+ ) : ( +
{String(filteredData)}
+ )} +
) } diff --git a/src/components/Sidebar.tsx b/src/components/Sidebar.tsx index 4106620..712b060 100644 --- a/src/components/Sidebar.tsx +++ b/src/components/Sidebar.tsx @@ -1,12 +1,16 @@ import type { IdxEntry } from '../lib/api' +import type { Theme } from '../lib/theme' import { shortId, truncate } from '../lib/chat' interface Props { sessions: IdxEntry[] selectedId: string | null - onSelect: (id: string) => void + onSelect: (id: string | null) => void onNew: () => void onKill: (id: string) => void + themes: Theme[] + currentTheme: string + onThemeChange: (name: string) => void } function stateClass(state: string) { @@ -15,7 +19,7 @@ function stateClass(state: string) { return 'running' // thinking, calling: , etc. } -export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props) { +export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, themes, currentTheme, onThemeChange }: Props) { return ( ) } diff --git a/src/lib/api.ts b/src/lib/api.ts index aa487ba..8d010e7 100644 --- a/src/lib/api.ts +++ b/src/lib/api.ts @@ -70,3 +70,11 @@ export async function getBackends(): Promise { export async function killSession(id: string): Promise { await fetch(`/s/${id}`, { method: 'DELETE' }) } + +export async function browse9p(path: string): Promise { + const r = await fetch(path) + if (!r.ok) return null + const ct = r.headers.get('content-type') ?? '' + if (ct.includes('text/plain')) return await r.text() + return await r.json() +} diff --git a/src/lib/theme.ts b/src/lib/theme.ts new file mode 100644 index 0000000..1e88617 --- /dev/null +++ b/src/lib/theme.ts @@ -0,0 +1,91 @@ +export interface Theme { + name: string + label: string + vars: Record +} + +const midnight: Theme = { + name: 'midnight', + label: 'Midnight', + vars: {}, // default — uses :root values from CSS +} + +const acme: Theme = { + name: 'acme', + label: 'Acme', + vars: { + '--bg': '#ffffea', // acme yellow body + '--bg-elevated': '#eaffff', // acme blue tag bar + + '--mono': "'Go Mono', monospace", + '--sans': "'Go', system-ui, sans-serif", + '--bg-soft': '#ffffff', // white inputs + '--bg-card': '#eaffea', // rio pale green window body + '--border': '#888888', + '--border-focus': '#444444', + + '--text': '#000000', + '--text-dim': '#333333', + '--text-faint': '#777777', + + '--accent': '#268bd2', + '--accent-soft': 'rgba(38, 139, 210, 0.1)', + '--accent-glow': 'rgba(38, 139, 210, 0.2)', + '--accent-fg': '#ffffea', + '--accent-border': 'rgba(38, 139, 210, 0.3)', + '--accent-focus': 'rgba(38, 139, 210, 0.5)', + + '--running': '#859900', + '--running-glow': 'rgba(133, 153, 0, 0.35)', + '--idle': '#888888', + '--stopped': '#aaaaaa', + '--danger': '#dc322f', + '--warning': '#b58900', + + '--hover': 'rgba(0, 0, 0, 0.05)', + '--hover-strong': 'rgba(0, 0, 0, 0.08)', + '--code-bg': '#ffffff', // white inner code box + '--overlay-bg': 'rgba(0, 0, 0, 0.3)', + + '--user-bubble-bg': 'transparent', + '--user-bubble-border': 'transparent', + '--asst-bubble-bg': 'transparent', + '--asst-bubble-border': 'transparent', + + '--tool-bubble-bg': '#ffffff', + '--tool-bubble-accent': '#b58900', + '--tool-text': '#000000', + '--tool-arrow-color': '#000000', + '--tool-header-bg': 'transparent', + }, +} + +const themes: Theme[] = [midnight, acme] + +export function getThemes(): Theme[] { + return themes +} + +export function getTheme(name: string): Theme { + return themes.find(t => t.name === name) ?? midnight +} + +const STORAGE_KEY = 'ollie-theme' + +export function loadThemeName(): string { + return localStorage.getItem(STORAGE_KEY) ?? 'acme' +} + +export function saveThemeName(name: string) { + localStorage.setItem(STORAGE_KEY, name) +} + +export function applyTheme(theme: Theme) { + const el = document.documentElement + // Clear any previously applied theme vars + el.removeAttribute('style') + for (const [k, v] of Object.entries(theme.vars)) { + el.style.setProperty(k, v) + } + el.dataset.theme = theme.name +} diff --git a/src/style.css b/src/style.css index 702e961..81a7548 100644 --- a/src/style.css +++ b/src/style.css @@ -15,6 +15,8 @@ --accent-soft: rgba(34, 211, 238, 0.07); --accent-glow: rgba(34, 211, 238, 0.18); --accent-fg: #061419; + --accent-border: var(--accent-border); + --accent-focus: var(--accent-focus); --running: #34d399; --running-glow: rgba(52, 211, 153, 0.35); @@ -23,6 +25,21 @@ --danger: #fb7185; --warning: #fbbf24; + --hover: rgba(255, 255, 255, 0.025); + --hover-strong: rgba(255, 255, 255, 0.04); + --code-bg: #07070f; + --overlay-bg: rgba(0, 0, 0, 0.65); + + --user-bubble-bg: var(--accent-soft); + --user-bubble-border: var(--accent-border); + --asst-bubble-bg: var(--bg-card); + --asst-bubble-border: var(--border); + --tool-bubble-bg: transparent; + --tool-bubble-accent: var(--warning); + --tool-text: var(--text-dim); + --tool-arrow-color: var(--warning); + --tool-header-bg: transparent; + --mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace; --sans: system-ui, -apple-system, 'Segoe UI', sans-serif; @@ -37,6 +54,7 @@ /* ---- Reset ---- */ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } html, body, #app { height: 100%; } +html { zoom: 1.25; } body { font-family: var(--sans); font-size: 14px; @@ -58,7 +76,7 @@ input, select, textarea { font-family: inherit; } .layout { display: grid; grid-template-columns: var(--sidebar) 1fr; - height: 100vh; + height: 100%; overflow: hidden; } @@ -118,6 +136,26 @@ input, select, textarea { font-family: inherit; } color: var(--text-faint); } +.sidebar-footer { + padding: 10px 12px; + border-top: 1px solid var(--border); + flex-shrink: 0; +} +.theme-select { + width: 100%; + background: var(--bg-soft); + border: 1px solid var(--border); + border-radius: var(--r-md); + padding: 6px 10px; + color: var(--text-dim); + font-family: var(--mono); + font-size: 11px; + outline: none; + appearance: none; + cursor: pointer; +} +.theme-select:focus { border-color: var(--accent-focus); } + /* ---- Session Card ---- */ .session-card { padding: 10px 10px 10px 12px; @@ -130,13 +168,13 @@ input, select, textarea { font-family: inherit; } } .session-card:hover { - background: rgba(255, 255, 255, 0.025); + background: var(--hover); border-color: var(--border); } .session-card.selected { background: var(--accent-soft); - border-color: rgba(34, 211, 238, 0.22); + border-color: var(--accent-border); } .session-card-row { @@ -224,14 +262,14 @@ input, select, textarea { font-family: inherit; } border-radius: 3px; border: 1px solid var(--border); color: var(--text-dim); - background: rgba(255, 255, 255, 0.025); + background: var(--hover); white-space: nowrap; line-height: 1.5; } .badge.accent { color: var(--accent); - border-color: rgba(34, 211, 238, 0.22); - background: rgba(34, 211, 238, 0.05); + border-color: var(--accent-border); + background: var(--accent-soft); } .badge.warn { color: var(--warning); @@ -264,7 +302,7 @@ input, select, textarea { font-family: inherit; } .btn-ghost:hover:not(:disabled) { border-color: var(--border-focus); color: var(--text); - background: rgba(255, 255, 255, 0.04); + background: var(--hover-strong); } .btn-primary { @@ -287,7 +325,7 @@ input, select, textarea { font-family: inherit; } display: flex; flex-direction: column; overflow: hidden; - height: 100vh; + height: 100%; background: var(--bg); } @@ -436,20 +474,23 @@ input, select, textarea { font-family: inherit; } .message.user .msg-bubble { max-width: 72%; - background: var(--accent-soft); - border: 1px solid rgba(34, 211, 238, 0.16); + background: var(--user-bubble-bg); + border: 1px solid var(--user-bubble-border); border-bottom-right-radius: var(--r-sm); color: var(--text); } .message.assistant .msg-bubble { - max-width: 100%; - width: 100%; - background: var(--bg-card); - border: 1px solid var(--border); + max-width: 80%; + background: var(--asst-bubble-bg); + border: 1px solid var(--asst-bubble-border); border-bottom-left-radius: var(--r-sm); } +.msg-prefix { + display: none; +} + .user-text { white-space: pre-wrap; word-break: break-word; @@ -482,7 +523,7 @@ input, select, textarea { font-family: inherit; } } .md pre { - background: #07070f; + background: var(--code-bg); border: 1px solid var(--border); border-radius: var(--r-md); padding: 14px 16px; @@ -499,8 +540,8 @@ input, select, textarea { font-family: inherit; } } .md :not(pre) > code { - background: rgba(34, 211, 238, 0.08); - border: 1px solid rgba(34, 211, 238, 0.15); + background: var(--accent-soft); + border: 1px solid var(--accent-border); border-radius: 3px; padding: 0.1em 0.4em; color: var(--accent); @@ -524,36 +565,40 @@ input, select, textarea { font-family: inherit; } .message.tool .msg-bubble { max-width: 100%; width: 100%; - background: transparent; + background: var(--tool-bubble-bg); border: 1px solid var(--border); - border-left: 3px solid var(--warning); - border-radius: var(--r-sm); - padding: 6px 12px; + border-radius: var(--r-md); + padding: 0; + overflow: hidden; } .tool-call { font-family: var(--mono); font-size: 12px; - color: var(--text-dim); + color: var(--tool-text); +} +.tool-header { + display: flex; + align-items: center; + gap: 6px; + padding: 6px 12px; } .tool-arrow { - color: var(--warning); - font-weight: 600; + font-size: 1.3em; + line-height: 1; } .tool-name { - color: var(--accent); - font-weight: 500; + color: var(--tool-text); + font-weight: 600; } .tool-body { - margin: 6px 0 0; - padding: 8px 12px; - background: #07070f; - border: 1px solid var(--border); - border-radius: var(--r-sm); - font-size: 11.5px; + margin: 0; + padding: 8px 14px; + background: var(--code-bg); + font-size: 12px; line-height: 1.55; white-space: pre-wrap; word-break: break-all; - color: var(--text-dim); + color: var(--text); overflow-x: auto; } @@ -612,7 +657,7 @@ input, select, textarea { font-family: inherit; } max-height: 160px; transition: border-color 0.13s; } -.prompt-textarea:focus { border-color: rgba(34, 211, 238, 0.35); } +.prompt-textarea:focus { border-color: var(--accent-focus); } .prompt-textarea::placeholder { color: var(--text-faint); } .prompt-textarea:disabled { opacity: 0.45; cursor: not-allowed; } @@ -620,7 +665,7 @@ input, select, textarea { font-family: inherit; } .overlay { position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.65); + background: var(--overlay-bg); backdrop-filter: blur(6px); display: flex; align-items: center; @@ -685,7 +730,7 @@ input, select, textarea { font-family: inherit; } } .field input:focus, -.field select:focus { border-color: rgba(34, 211, 238, 0.35); } +.field select:focus { border-color: var(--accent-focus); } .field select { background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='8' viewBox='0 0 12 8'%3E%3Cpath d='M1 1l5 5 5-5' stroke='%233e3e54' stroke-width='1.5' fill='none' stroke-linecap='round'/%3E%3C/svg%3E"); @@ -705,3 +750,103 @@ input, select, textarea { font-family: inherit; } gap: 10px; justify-content: flex-end; } + +/* ---- Acme theme overrides ---- */ +[data-theme="acme"] .message-list { + padding: 8px 16px 8px; +} +[data-theme="acme"] .message { + margin-bottom: 4px; +} +[data-theme="acme"] .message.user { + justify-content: flex-start; +} +[data-theme="acme"] .msg-bubble { + padding: 4px 8px; + border-radius: 0; +} +[data-theme="acme"] .message.user .msg-bubble { + max-width: 100%; +} +[data-theme="acme"] .message.assistant .msg-bubble { + max-width: 100%; +} +[data-theme="acme"] .message.tool .msg-bubble { + border-radius: var(--r-sm); + margin: 4px 0; +} +[data-theme="acme"] .tool-body { + padding: 4px 10px; +} +[data-theme="acme"] .tool-header { + padding: 4px 10px; +} +[data-theme="acme"] .msg-prefix { + display: inline; + font-family: var(--mono); + font-weight: 600; + color: #000000; +} + +/* ---- Filesystem browser ---- */ +.fs-browser { + flex: 1; + display: flex; + flex-direction: column; + overflow: hidden; +} +.fs-path { + font-family: var(--mono); + font-size: 12px; + padding: 0 20px; + height: 52px; + display: flex; + align-items: center; + gap: 8px; + border-bottom: 1px solid var(--border); + color: var(--text-faint); + background: var(--bg-elevated); +} +.fs-path-link { + cursor: pointer; + color: var(--accent); + font-weight: 600; +} +.fs-path-link:hover { text-decoration: underline; } +.fs-content { + flex: 1; + overflow-y: auto; + padding: 8px 0; +} +.fs-empty { + padding: 16px; + font-family: var(--mono); + font-size: 12px; + color: var(--text-faint); +} +.fs-entry { + display: flex; + justify-content: space-between; + padding: 4px 16px; + font-family: var(--mono); + font-size: 13px; + cursor: pointer; + color: var(--text); +} +.fs-entry:hover { background: var(--hover); } +.fs-dir .fs-entry-name { color: var(--accent); font-weight: 500; } +.fs-entry-size { + color: var(--text-faint); + font-size: 11px; +} +.fs-file { + padding: 12px 16px; + font-family: var(--mono); + font-size: 12px; + line-height: 1.6; + white-space: pre-wrap; + word-break: break-all; + color: var(--text); +} +.tree-row-link { cursor: pointer; } +.tree-row-link:hover .tree-path { text-decoration: underline; } diff --git a/vite.config.ts b/vite.config.ts index d7129ea..cf6331f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -6,6 +6,10 @@ export default defineConfig({ server: { proxy: { '/s/': 'http://localhost:8080', + '/a/': 'http://localhost:8080', + '/m/': 'http://localhost:8080', + '/t/': 'http://localhost:8080', + '/sk/': 'http://localhost:8080', '/backends': 'http://localhost:8080', '/openapi.json': 'http://localhost:8080', },