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
This commit is contained in:
parent
a199572a74
commit
0dc8337365
|
|
@ -7,6 +7,7 @@
|
||||||
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
||||||
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
||||||
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
<link href="https://fonts.googleapis.com/css2?family=JetBrains+Mono:wght@400;500;600&display=swap" rel="stylesheet" />
|
||||||
|
<link href="https://fonts.googleapis.com/css2?family=Go+Mono&family=Go:wght@400;700&display=swap" rel="stylesheet" />
|
||||||
</head>
|
</head>
|
||||||
<body>
|
<body>
|
||||||
<div id="app"></div>
|
<div id="app"></div>
|
||||||
|
|
|
||||||
47
src/app.tsx
47
src/app.tsx
|
|
@ -2,6 +2,7 @@ import { useState, useEffect, useCallback } from 'preact/hooks'
|
||||||
import { Sidebar } from './components/Sidebar'
|
import { Sidebar } from './components/Sidebar'
|
||||||
import { ChatPane } from './components/ChatPane'
|
import { ChatPane } from './components/ChatPane'
|
||||||
import { NewSession } from './components/NewSession'
|
import { NewSession } from './components/NewSession'
|
||||||
|
import { getTheme, getThemes, loadThemeName, saveThemeName, applyTheme } from './lib/theme'
|
||||||
import {
|
import {
|
||||||
listSessions,
|
listSessions,
|
||||||
getSessionCfg,
|
getSessionCfg,
|
||||||
|
|
@ -10,6 +11,7 @@ import {
|
||||||
sendCtl,
|
sendCtl,
|
||||||
createSession,
|
createSession,
|
||||||
killSession,
|
killSession,
|
||||||
|
browse9p,
|
||||||
} from './lib/api'
|
} from './lib/api'
|
||||||
import type { IdxEntry } from './lib/api'
|
import type { IdxEntry } from './lib/api'
|
||||||
import { parseChat } from './lib/chat'
|
import { parseChat } from './lib/chat'
|
||||||
|
|
@ -21,6 +23,42 @@ export function App() {
|
||||||
const [cfg, setCfg] = useState<Record<string, string> | null>(null)
|
const [cfg, setCfg] = useState<Record<string, string> | null>(null)
|
||||||
const [messages, setMessages] = useState<ChatMessage[]>([])
|
const [messages, setMessages] = useState<ChatMessage[]>([])
|
||||||
const [showNew, setShowNew] = useState(false)
|
const [showNew, setShowNew] = useState(false)
|
||||||
|
const [themeName, setThemeName] = useState(loadThemeName)
|
||||||
|
const [browsePath, setBrowsePath] = useState<string | null>(null)
|
||||||
|
const [browseData, setBrowseData] = useState<any>(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.
|
// Session list — poll every 5s.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|
@ -89,9 +127,12 @@ export function App() {
|
||||||
<Sidebar
|
<Sidebar
|
||||||
sessions={sessions}
|
sessions={sessions}
|
||||||
selectedId={selectedId}
|
selectedId={selectedId}
|
||||||
onSelect={setSelectedId}
|
onSelect={handleSelect}
|
||||||
onNew={() => setShowNew(true)}
|
onNew={() => setShowNew(true)}
|
||||||
onKill={handleKill}
|
onKill={handleKill}
|
||||||
|
themes={getThemes()}
|
||||||
|
currentTheme={themeName}
|
||||||
|
onThemeChange={handleSetTheme}
|
||||||
/>
|
/>
|
||||||
<ChatPane
|
<ChatPane
|
||||||
session={sessions.find(s => s.id === selectedId) ?? null}
|
session={sessions.find(s => s.id === selectedId) ?? null}
|
||||||
|
|
@ -99,6 +140,10 @@ export function App() {
|
||||||
messages={messages}
|
messages={messages}
|
||||||
onSend={handleSend}
|
onSend={handleSend}
|
||||||
onStop={handleStop}
|
onStop={handleStop}
|
||||||
|
browsePath={browsePath}
|
||||||
|
browseData={browseData}
|
||||||
|
onBrowse={handleBrowse}
|
||||||
|
onSelectSession={handleSelect}
|
||||||
/>
|
/>
|
||||||
{showNew && (
|
{showNew && (
|
||||||
<NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} />
|
<NewSession onClose={() => setShowNew(false)} onCreate={handleCreate} />
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,10 @@ interface Props {
|
||||||
messages: ChatMessage[]
|
messages: ChatMessage[]
|
||||||
onSend: (text: string) => void
|
onSend: (text: string) => void
|
||||||
onStop: () => void
|
onStop: () => void
|
||||||
|
browsePath: string | null
|
||||||
|
browseData: any
|
||||||
|
onBrowse: (path: string) => void
|
||||||
|
onSelectSession: (id: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateClass(state: string) {
|
function stateClass(state: string) {
|
||||||
|
|
@ -21,7 +25,7 @@ function stateClass(state: string) {
|
||||||
return 'running'
|
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<HTMLDivElement>(null)
|
const listRef = useRef<HTMLDivElement>(null)
|
||||||
const [atBottom, setAtBottom] = useState(true)
|
const [atBottom, setAtBottom] = useState(true)
|
||||||
|
|
||||||
|
|
@ -42,7 +46,11 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
|
||||||
if (!session || !cfg) {
|
if (!session || !cfg) {
|
||||||
return (
|
return (
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<Welcome />
|
{browsePath && browsePath !== '/mnt/' ? (
|
||||||
|
<FsBrowser path={browsePath} data={browseData} onNavigate={onBrowse} onSelectSession={onSelectSession} />
|
||||||
|
) : (
|
||||||
|
<Welcome onBrowse={onBrowse} />
|
||||||
|
)}
|
||||||
</main>
|
</main>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -54,6 +62,7 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
|
||||||
<main class="main">
|
<main class="main">
|
||||||
<header class="chat-header">
|
<header class="chat-header">
|
||||||
<div class="chat-path">
|
<div class="chat-path">
|
||||||
|
<span class="fs-path-link" onClick={() => onBrowse('/mnt/s/')}>↑</span>{' '}
|
||||||
/s/<span class="path-hi">{sid}</span>/chat
|
/s/<span class="path-hi">{sid}</span>/chat
|
||||||
</div>
|
</div>
|
||||||
<div class="header-meta">
|
<div class="header-meta">
|
||||||
|
|
@ -75,13 +84,13 @@ export function ChatPane({ session, cfg, messages, onSend, onStop }: Props) {
|
||||||
<div key={i} class={`message ${msg.role}`}>
|
<div key={i} class={`message ${msg.role}`}>
|
||||||
<div class="msg-bubble">
|
<div class="msg-bubble">
|
||||||
{msg.role === 'user' ? (
|
{msg.role === 'user' ? (
|
||||||
<div class="user-text">{msg.content}</div>
|
<div class="user-text"><span class="msg-prefix">u: </span>{msg.content}</div>
|
||||||
) : msg.role === 'tool' ? (
|
) : msg.role === 'tool' ? (
|
||||||
<ToolCall content={msg.content} />
|
<ToolCall content={msg.content} />
|
||||||
) : (
|
) : (
|
||||||
<div
|
<div
|
||||||
class="md"
|
class="md"
|
||||||
dangerouslySetInnerHTML={{ __html: marked.parse(msg.content) as string }}
|
dangerouslySetInnerHTML={{ __html: (marked.parse(msg.content) as string).replace(/^<p>/, '<p><span class="msg-prefix">a: </span>') }}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
@ -107,6 +116,11 @@ function formatToolContent(raw: string): { name: string; body: string } {
|
||||||
if (!m) return { name: '', body: raw }
|
if (!m) return { name: '', body: raw }
|
||||||
try {
|
try {
|
||||||
const obj = JSON.parse(m[2])
|
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) }
|
return { name: m[1], body: JSON.stringify(obj, null, 2) }
|
||||||
} catch {
|
} catch {
|
||||||
return { name: m[1], body: m[2] }
|
return { name: m[1], body: m[2] }
|
||||||
|
|
@ -117,20 +131,22 @@ function ToolCall({ content }: { content: string }) {
|
||||||
const { name, body } = formatToolContent(content)
|
const { name, body } = formatToolContent(content)
|
||||||
return (
|
return (
|
||||||
<div class="tool-call">
|
<div class="tool-call">
|
||||||
<span class="tool-arrow">→</span>{' '}
|
<div class="tool-header">
|
||||||
{name && <span class="tool-name">{name}</span>}
|
<span class="tool-arrow">🖥️</span>
|
||||||
|
{name && <span class="tool-name">{name}</span>}
|
||||||
|
</div>
|
||||||
<pre class="tool-body">{body}</pre>
|
<pre class="tool-body">{body}</pre>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
function Welcome() {
|
function Welcome({ onBrowse }: { onBrowse: (path: string) => void }) {
|
||||||
const tree: [string, string][] = [
|
const tree: [string, string][] = [
|
||||||
['/s/', 'sessions'],
|
['/mnt/s/', 'sessions'],
|
||||||
['/m/', 'memory'],
|
['/mnt/m/', 'memory'],
|
||||||
['/t/', 'tools'],
|
['/mnt/t/', 'tools'],
|
||||||
['/a/', 'agents'],
|
['/mnt/a/', 'agents'],
|
||||||
['/sk/', 'skills'],
|
['/mnt/sk/', 'skills'],
|
||||||
]
|
]
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
@ -140,14 +156,57 @@ function Welcome() {
|
||||||
<div class="welcome-tree">
|
<div class="welcome-tree">
|
||||||
<div class="welcome-tree-title">9p filesystem</div>
|
<div class="welcome-tree-title">9p filesystem</div>
|
||||||
{tree.map(([path, desc]) => (
|
{tree.map(([path, desc]) => (
|
||||||
<div class="tree-row" key={path}>
|
<div class="tree-row tree-row-link" key={path} onClick={() => onBrowse(path)}>
|
||||||
<span class="tree-path">{path}</span>
|
<span class="tree-path">{path.replace('/mnt', '')}</span>
|
||||||
<span class="tree-sep">·</span>
|
<span class="tree-sep">·</span>
|
||||||
<span class="tree-desc">{desc}</span>
|
<span class="tree-desc">{desc}</span>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div class="welcome-hint">select a session or create one</div>
|
<div class="welcome-hint">select a session or browse the filesystem</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div class="fs-browser">
|
||||||
|
<div class="fs-path">
|
||||||
|
{parent && <span class="fs-path-link" onClick={() => onNavigate(parent)}>↑</span>}
|
||||||
|
{parent && ' '}{displayPath}
|
||||||
|
</div>
|
||||||
|
<div class="fs-content">
|
||||||
|
{filteredData === null ? (
|
||||||
|
<div class="fs-empty">loading…</div>
|
||||||
|
) : Array.isArray(filteredData) && filteredData.length > 0 && typeof filteredData[0] === 'object' ? (
|
||||||
|
filteredData.map((entry: any) => (
|
||||||
|
<div
|
||||||
|
key={entry.name}
|
||||||
|
class={`fs-entry${entry.is_dir ? ' fs-dir' : ''}`}
|
||||||
|
onClick={() => isSessionDir && entry.is_dir
|
||||||
|
? onSelectSession(entry.name)
|
||||||
|
: entry.is_dir ? onNavigate(path + entry.name + '/') : onNavigate(path + entry.name)}
|
||||||
|
>
|
||||||
|
<span class="fs-entry-name">{entry.name}{entry.is_dir ? '/' : ''}</span>
|
||||||
|
{entry.size > 0 && <span class="fs-entry-size">{entry.size}</span>}
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
) : Array.isArray(filteredData) ? (
|
||||||
|
<pre class="fs-file">{filteredData.join('\n')}</pre>
|
||||||
|
) : typeof filteredData === 'object' ? (
|
||||||
|
<pre class="fs-file">{Object.entries(filteredData).map(([k, v]) => `${k}=${v}`).join('\n')}</pre>
|
||||||
|
) : (
|
||||||
|
<pre class="fs-file">{String(filteredData)}</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,12 +1,16 @@
|
||||||
import type { IdxEntry } from '../lib/api'
|
import type { IdxEntry } from '../lib/api'
|
||||||
|
import type { Theme } from '../lib/theme'
|
||||||
import { shortId, truncate } from '../lib/chat'
|
import { shortId, truncate } from '../lib/chat'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessions: IdxEntry[]
|
sessions: IdxEntry[]
|
||||||
selectedId: string | null
|
selectedId: string | null
|
||||||
onSelect: (id: string) => void
|
onSelect: (id: string | null) => void
|
||||||
onNew: () => void
|
onNew: () => void
|
||||||
onKill: (id: string) => void
|
onKill: (id: string) => void
|
||||||
|
themes: Theme[]
|
||||||
|
currentTheme: string
|
||||||
|
onThemeChange: (name: string) => void
|
||||||
}
|
}
|
||||||
|
|
||||||
function stateClass(state: string) {
|
function stateClass(state: string) {
|
||||||
|
|
@ -15,7 +19,7 @@ function stateClass(state: string) {
|
||||||
return 'running' // thinking, calling: <tool>, etc.
|
return 'running' // thinking, calling: <tool>, etc.
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props) {
|
export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill, themes, currentTheme, onThemeChange }: Props) {
|
||||||
return (
|
return (
|
||||||
<aside class="sidebar">
|
<aside class="sidebar">
|
||||||
<div class="sidebar-header">
|
<div class="sidebar-header">
|
||||||
|
|
@ -41,7 +45,7 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props
|
||||||
<div
|
<div
|
||||||
key={s.id}
|
key={s.id}
|
||||||
class={`session-card${s.id === selectedId ? ' selected' : ''}`}
|
class={`session-card${s.id === selectedId ? ' selected' : ''}`}
|
||||||
onClick={() => onSelect(s.id)}
|
onClick={() => onSelect(s.id === selectedId ? null : s.id)}
|
||||||
>
|
>
|
||||||
<div class="session-card-row">
|
<div class="session-card-row">
|
||||||
<div class={`state-dot ${stateClass(s.state)}`} />
|
<div class={`state-dot ${stateClass(s.state)}`} />
|
||||||
|
|
@ -61,6 +65,18 @@ export function Sidebar({ sessions, selectedId, onSelect, onNew, onKill }: Props
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{themes.length > 1 && (
|
||||||
|
<div class="sidebar-footer">
|
||||||
|
<select
|
||||||
|
class="theme-select"
|
||||||
|
value={currentTheme}
|
||||||
|
onChange={e => onThemeChange((e.target as HTMLSelectElement).value)}
|
||||||
|
>
|
||||||
|
{themes.map(t => <option key={t.name} value={t.name}>{t.label}</option>)}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</aside>
|
</aside>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -70,3 +70,11 @@ export async function getBackends(): Promise<string[]> {
|
||||||
export async function killSession(id: string): Promise<void> {
|
export async function killSession(id: string): Promise<void> {
|
||||||
await fetch(`/s/${id}`, { method: 'DELETE' })
|
await fetch(`/s/${id}`, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function browse9p(path: string): Promise<any> {
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,91 @@
|
||||||
|
export interface Theme {
|
||||||
|
name: string
|
||||||
|
label: string
|
||||||
|
vars: Record<string, string>
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
217
src/style.css
217
src/style.css
|
|
@ -15,6 +15,8 @@
|
||||||
--accent-soft: rgba(34, 211, 238, 0.07);
|
--accent-soft: rgba(34, 211, 238, 0.07);
|
||||||
--accent-glow: rgba(34, 211, 238, 0.18);
|
--accent-glow: rgba(34, 211, 238, 0.18);
|
||||||
--accent-fg: #061419;
|
--accent-fg: #061419;
|
||||||
|
--accent-border: var(--accent-border);
|
||||||
|
--accent-focus: var(--accent-focus);
|
||||||
|
|
||||||
--running: #34d399;
|
--running: #34d399;
|
||||||
--running-glow: rgba(52, 211, 153, 0.35);
|
--running-glow: rgba(52, 211, 153, 0.35);
|
||||||
|
|
@ -23,6 +25,21 @@
|
||||||
--danger: #fb7185;
|
--danger: #fb7185;
|
||||||
--warning: #fbbf24;
|
--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;
|
--mono: 'JetBrains Mono', 'Cascadia Code', 'Fira Code', ui-monospace, monospace;
|
||||||
--sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
--sans: system-ui, -apple-system, 'Segoe UI', sans-serif;
|
||||||
|
|
||||||
|
|
@ -37,6 +54,7 @@
|
||||||
/* ---- Reset ---- */
|
/* ---- Reset ---- */
|
||||||
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
|
||||||
html, body, #app { height: 100%; }
|
html, body, #app { height: 100%; }
|
||||||
|
html { zoom: 1.25; }
|
||||||
body {
|
body {
|
||||||
font-family: var(--sans);
|
font-family: var(--sans);
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
|
|
@ -58,7 +76,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
.layout {
|
.layout {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: var(--sidebar) 1fr;
|
grid-template-columns: var(--sidebar) 1fr;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -118,6 +136,26 @@ input, select, textarea { font-family: inherit; }
|
||||||
color: var(--text-faint);
|
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 ---- */
|
||||||
.session-card {
|
.session-card {
|
||||||
padding: 10px 10px 10px 12px;
|
padding: 10px 10px 10px 12px;
|
||||||
|
|
@ -130,13 +168,13 @@ input, select, textarea { font-family: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-card:hover {
|
.session-card:hover {
|
||||||
background: rgba(255, 255, 255, 0.025);
|
background: var(--hover);
|
||||||
border-color: var(--border);
|
border-color: var(--border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-card.selected {
|
.session-card.selected {
|
||||||
background: var(--accent-soft);
|
background: var(--accent-soft);
|
||||||
border-color: rgba(34, 211, 238, 0.22);
|
border-color: var(--accent-border);
|
||||||
}
|
}
|
||||||
|
|
||||||
.session-card-row {
|
.session-card-row {
|
||||||
|
|
@ -224,14 +262,14 @@ input, select, textarea { font-family: inherit; }
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
color: var(--text-dim);
|
color: var(--text-dim);
|
||||||
background: rgba(255, 255, 255, 0.025);
|
background: var(--hover);
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
.badge.accent {
|
.badge.accent {
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
border-color: rgba(34, 211, 238, 0.22);
|
border-color: var(--accent-border);
|
||||||
background: rgba(34, 211, 238, 0.05);
|
background: var(--accent-soft);
|
||||||
}
|
}
|
||||||
.badge.warn {
|
.badge.warn {
|
||||||
color: var(--warning);
|
color: var(--warning);
|
||||||
|
|
@ -264,7 +302,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
.btn-ghost:hover:not(:disabled) {
|
.btn-ghost:hover:not(:disabled) {
|
||||||
border-color: var(--border-focus);
|
border-color: var(--border-focus);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
background: rgba(255, 255, 255, 0.04);
|
background: var(--hover-strong);
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-primary {
|
.btn-primary {
|
||||||
|
|
@ -287,7 +325,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
height: 100vh;
|
height: 100%;
|
||||||
background: var(--bg);
|
background: var(--bg);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -436,20 +474,23 @@ input, select, textarea { font-family: inherit; }
|
||||||
|
|
||||||
.message.user .msg-bubble {
|
.message.user .msg-bubble {
|
||||||
max-width: 72%;
|
max-width: 72%;
|
||||||
background: var(--accent-soft);
|
background: var(--user-bubble-bg);
|
||||||
border: 1px solid rgba(34, 211, 238, 0.16);
|
border: 1px solid var(--user-bubble-border);
|
||||||
border-bottom-right-radius: var(--r-sm);
|
border-bottom-right-radius: var(--r-sm);
|
||||||
color: var(--text);
|
color: var(--text);
|
||||||
}
|
}
|
||||||
|
|
||||||
.message.assistant .msg-bubble {
|
.message.assistant .msg-bubble {
|
||||||
max-width: 100%;
|
max-width: 80%;
|
||||||
width: 100%;
|
background: var(--asst-bubble-bg);
|
||||||
background: var(--bg-card);
|
border: 1px solid var(--asst-bubble-border);
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-bottom-left-radius: var(--r-sm);
|
border-bottom-left-radius: var(--r-sm);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.msg-prefix {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
.user-text {
|
.user-text {
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-word;
|
word-break: break-word;
|
||||||
|
|
@ -482,7 +523,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.md pre {
|
.md pre {
|
||||||
background: #07070f;
|
background: var(--code-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-radius: var(--r-md);
|
border-radius: var(--r-md);
|
||||||
padding: 14px 16px;
|
padding: 14px 16px;
|
||||||
|
|
@ -499,8 +540,8 @@ input, select, textarea { font-family: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.md :not(pre) > code {
|
.md :not(pre) > code {
|
||||||
background: rgba(34, 211, 238, 0.08);
|
background: var(--accent-soft);
|
||||||
border: 1px solid rgba(34, 211, 238, 0.15);
|
border: 1px solid var(--accent-border);
|
||||||
border-radius: 3px;
|
border-radius: 3px;
|
||||||
padding: 0.1em 0.4em;
|
padding: 0.1em 0.4em;
|
||||||
color: var(--accent);
|
color: var(--accent);
|
||||||
|
|
@ -524,36 +565,40 @@ input, select, textarea { font-family: inherit; }
|
||||||
.message.tool .msg-bubble {
|
.message.tool .msg-bubble {
|
||||||
max-width: 100%;
|
max-width: 100%;
|
||||||
width: 100%;
|
width: 100%;
|
||||||
background: transparent;
|
background: var(--tool-bubble-bg);
|
||||||
border: 1px solid var(--border);
|
border: 1px solid var(--border);
|
||||||
border-left: 3px solid var(--warning);
|
border-radius: var(--r-md);
|
||||||
border-radius: var(--r-sm);
|
padding: 0;
|
||||||
padding: 6px 12px;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.tool-call {
|
.tool-call {
|
||||||
font-family: var(--mono);
|
font-family: var(--mono);
|
||||||
font-size: 12px;
|
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 {
|
.tool-arrow {
|
||||||
color: var(--warning);
|
font-size: 1.3em;
|
||||||
font-weight: 600;
|
line-height: 1;
|
||||||
}
|
}
|
||||||
.tool-name {
|
.tool-name {
|
||||||
color: var(--accent);
|
color: var(--tool-text);
|
||||||
font-weight: 500;
|
font-weight: 600;
|
||||||
}
|
}
|
||||||
.tool-body {
|
.tool-body {
|
||||||
margin: 6px 0 0;
|
margin: 0;
|
||||||
padding: 8px 12px;
|
padding: 8px 14px;
|
||||||
background: #07070f;
|
background: var(--code-bg);
|
||||||
border: 1px solid var(--border);
|
font-size: 12px;
|
||||||
border-radius: var(--r-sm);
|
|
||||||
font-size: 11.5px;
|
|
||||||
line-height: 1.55;
|
line-height: 1.55;
|
||||||
white-space: pre-wrap;
|
white-space: pre-wrap;
|
||||||
word-break: break-all;
|
word-break: break-all;
|
||||||
color: var(--text-dim);
|
color: var(--text);
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -612,7 +657,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
max-height: 160px;
|
max-height: 160px;
|
||||||
transition: border-color 0.13s;
|
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::placeholder { color: var(--text-faint); }
|
||||||
.prompt-textarea:disabled { opacity: 0.45; cursor: not-allowed; }
|
.prompt-textarea:disabled { opacity: 0.45; cursor: not-allowed; }
|
||||||
|
|
||||||
|
|
@ -620,7 +665,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
.overlay {
|
.overlay {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
inset: 0;
|
inset: 0;
|
||||||
background: rgba(0, 0, 0, 0.65);
|
background: var(--overlay-bg);
|
||||||
backdrop-filter: blur(6px);
|
backdrop-filter: blur(6px);
|
||||||
display: flex;
|
display: flex;
|
||||||
align-items: center;
|
align-items: center;
|
||||||
|
|
@ -685,7 +730,7 @@ input, select, textarea { font-family: inherit; }
|
||||||
}
|
}
|
||||||
|
|
||||||
.field input:focus,
|
.field input:focus,
|
||||||
.field select:focus { border-color: rgba(34, 211, 238, 0.35); }
|
.field select:focus { border-color: var(--accent-focus); }
|
||||||
|
|
||||||
.field select {
|
.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");
|
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;
|
gap: 10px;
|
||||||
justify-content: flex-end;
|
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; }
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,10 @@ export default defineConfig({
|
||||||
server: {
|
server: {
|
||||||
proxy: {
|
proxy: {
|
||||||
'/s/': 'http://localhost:8080',
|
'/s/': 'http://localhost:8080',
|
||||||
|
'/a/': 'http://localhost:8080',
|
||||||
|
'/m/': 'http://localhost:8080',
|
||||||
|
'/t/': 'http://localhost:8080',
|
||||||
|
'/sk/': 'http://localhost:8080',
|
||||||
'/backends': 'http://localhost:8080',
|
'/backends': 'http://localhost:8080',
|
||||||
'/openapi.json': 'http://localhost:8080',
|
'/openapi.json': 'http://localhost:8080',
|
||||||
},
|
},
|
||||||
|
|
|
||||||
Reference in New Issue