feat: add companion API and Android APK shell

This commit is contained in:
Hermes Agent
2026-07-09 04:43:00 +00:00
parent bd0cf34aa3
commit 7eb9648eb7
92 changed files with 7907 additions and 877 deletions
+37
View File
@@ -0,0 +1,37 @@
import { createContext, type ReactNode, useContext, useMemo, useState } from 'react';
import { CompanionClient, loadCompanionSettings, saveCompanionSettings, type CompanionSettings } from './companionClient.js';
type CompanionContextValue = {
settings: CompanionSettings;
client: CompanionClient;
updateSettings: (settings: CompanionSettings) => void;
};
const CompanionContext = createContext<CompanionContextValue | undefined>(undefined);
export function CompanionProvider({ children }: { children: ReactNode }) {
const [settings, setSettings] = useState(loadCompanionSettings);
const client = useMemo(() => new CompanionClient(settings), [settings]);
function updateSettings(nextSettings: CompanionSettings): void {
const normalizedSettings = {
baseUrl: nextSettings.baseUrl.trim(),
accessKey: nextSettings.accessKey.trim(),
};
saveCompanionSettings(normalizedSettings);
setSettings(normalizedSettings);
}
return <CompanionContext.Provider value={{ settings, client, updateSettings }}>{children}</CompanionContext.Provider>;
}
export function useCompanion(): CompanionContextValue {
const value = useContext(CompanionContext);
if (!value) {
throw new Error('useCompanion must be used inside CompanionProvider');
}
return value;
}
+117
View File
@@ -0,0 +1,117 @@
import type {
AuthValidateResponse,
ChatResponse,
FileListResponse,
FileMetadataResponse,
FileReadResponse,
FileWriteResponse,
HealthResponse,
TerminalRunResponse,
} from '@hermes-mobile/shared';
export const DEFAULT_COMPANION_URL = 'http://10.0.2.2:8787';
export const COMPANION_URL_STORAGE_KEY = 'hermes-mobile.companionUrl';
export const ACCESS_KEY_STORAGE_KEY = 'hermes-mobile.accessKey';
export type CompanionSettings = {
baseUrl: string;
accessKey: string;
};
type RequestOptions = {
method?: 'GET' | 'POST';
body?: unknown;
query?: Record<string, string | undefined>;
};
function trimTrailingSlash(value: string): string {
return value.replace(/\/+$/, '');
}
export function loadCompanionSettings(): CompanionSettings {
return {
baseUrl: localStorage.getItem(COMPANION_URL_STORAGE_KEY) || DEFAULT_COMPANION_URL,
accessKey: localStorage.getItem(ACCESS_KEY_STORAGE_KEY) || '',
};
}
export function saveCompanionSettings(settings: CompanionSettings): void {
localStorage.setItem(COMPANION_URL_STORAGE_KEY, trimTrailingSlash(settings.baseUrl || DEFAULT_COMPANION_URL));
localStorage.setItem(ACCESS_KEY_STORAGE_KEY, settings.accessKey);
}
export class CompanionClient {
constructor(private readonly settings: CompanionSettings) {}
private async request<T>(path: string, options: RequestOptions = {}): Promise<T> {
const url = new URL(`${trimTrailingSlash(this.settings.baseUrl)}${path}`);
for (const [key, value] of Object.entries(options.query ?? {})) {
if (value !== undefined) {
url.searchParams.set(key, value);
}
}
const init: RequestInit = {
method: options.method ?? 'GET',
headers: {
...(options.body === undefined ? {} : { 'Content-Type': 'application/json' }),
...(this.settings.accessKey ? { Authorization: `Bearer ${this.settings.accessKey}` } : {}),
},
};
if (options.body !== undefined) {
init.body = JSON.stringify(options.body);
}
const response = await fetch(url, init);
const text = await response.text();
let payload: unknown;
try {
payload = text ? (JSON.parse(text) as unknown) : undefined;
} catch {
payload = { error: text };
}
if (!response.ok) {
const message = typeof payload === 'object' && payload !== null && 'error' in payload ? String(payload.error) : response.statusText;
throw new Error(message || `Request failed with ${response.status}`);
}
return payload as T;
}
health(): Promise<HealthResponse> {
return this.request<HealthResponse>('/api/health');
}
validate(): Promise<AuthValidateResponse> {
return this.request<AuthValidateResponse>('/api/auth/validate');
}
chat(prompt: string): Promise<ChatResponse> {
return this.request<ChatResponse>('/api/chat', { method: 'POST', body: { prompt } });
}
listFiles(path: string): Promise<FileListResponse> {
return this.request<FileListResponse>('/api/files', { query: { path } });
}
readFile(path: string): Promise<FileReadResponse> {
return this.request<FileReadResponse>('/api/files/read', { query: { path } });
}
writeFile(path: string, content: string): Promise<FileWriteResponse> {
return this.request<FileWriteResponse>('/api/files/write', { method: 'POST', body: { path, content } });
}
metadata(path: string): Promise<FileMetadataResponse> {
return this.request<FileMetadataResponse>('/api/files/metadata', { query: { path } });
}
runCommand(command: string, cwd: string, timeoutMs = 10000): Promise<TerminalRunResponse> {
return this.request<TerminalRunResponse>('/api/terminal/run', { method: 'POST', body: { command, cwd, timeoutMs } });
}
}
+11 -11
View File
@@ -1,22 +1,22 @@
import { type ReactNode, useState } from 'react';
import { AppShell } from '../components/app-shell/AppShell.js';
import { ActivityScreen } from '../screens/ActivityScreen/ActivityScreen.js';
import { AskScreen } from '../screens/AskScreen/AskScreen.js';
import { CronScreen } from '../screens/CronScreen/CronScreen.js';
import { ChatScreen } from '../screens/AskScreen/ChatScreen.js';
import { FilesScreen } from '../screens/FilesScreen/FilesScreen.js';
import { SettingsScreen } from '../screens/SettingsScreen/SettingsScreen.js';
import { TerminalScreen } from '../screens/TerminalScreen/TerminalScreen.js';
import type { ScreenId } from './navigation.js';
const screens: Record<ScreenId, ReactNode> = {
ask: <AskScreen />,
activity: <ActivityScreen />,
cron: <CronScreen />,
files: <FilesScreen />,
settings: <SettingsScreen />,
};
export function App() {
const [activeScreen, setActiveScreen] = useState<ScreenId>('ask');
const [activeScreen, setActiveScreen] = useState<ScreenId>('chat');
const screens: Record<ScreenId, ReactNode> = {
chat: <ChatScreen />,
files: <FilesScreen />,
terminal: <TerminalScreen />,
activity: <ActivityScreen />,
settings: <SettingsScreen />,
};
return (
<AppShell activeScreen={activeScreen} onNavigate={setActiveScreen}>
+4 -4
View File
@@ -1,10 +1,10 @@
import { Activity, CalendarClock, Files, MessageCircle, Settings } from 'lucide-react';
import { Activity, Files, MessageCircle, Settings, TerminalSquare } from 'lucide-react';
export const navItems = [
{ id: 'ask', label: 'Ask', icon: MessageCircle },
{ id: 'activity', label: 'Activity', icon: Activity },
{ id: 'cron', label: 'Cron', icon: CalendarClock },
{ id: 'chat', label: 'Chat', icon: MessageCircle },
{ id: 'files', label: 'Files', icon: Files },
{ id: 'terminal', label: 'Terminal', icon: TerminalSquare },
{ id: 'activity', label: 'Status', icon: Activity },
{ id: 'settings', label: 'Settings', icon: Settings },
] as const;
@@ -1,4 +1,5 @@
import type { ReactNode } from 'react';
import { useEffect, useState, type ReactNode } from 'react';
import { useCompanion } from '../../api/CompanionContext.js';
import type { ScreenId } from '../../app/navigation.js';
import { BottomNav } from '../bottom-nav/BottomNav.js';
@@ -9,6 +10,26 @@ type AppShellProps = {
};
export function AppShell({ activeScreen, children, onNavigate }: AppShellProps) {
const { client } = useCompanion();
const [online, setOnline] = useState(false);
useEffect(() => {
let cancelled = false;
client
.health()
.then(() => {
if (!cancelled) setOnline(true);
})
.catch(() => {
if (!cancelled) setOnline(false);
});
return () => {
cancelled = true;
};
}, [client]);
return (
<div className="app-shell">
<header className="app-header">
@@ -16,9 +37,9 @@ export function AppShell({ activeScreen, children, onNavigate }: AppShellProps)
<p className="eyebrow">Hermes Mobile</p>
<h1>Agent command center</h1>
</div>
<div className="status-pill">
<div className={`status-pill ${online ? 'status-pill--online' : ''}`}>
<span className="status-pill__dot" />
Companion pending
{online ? 'Online' : 'Offline'}
</div>
</header>
<main className="app-main">{children}</main>
@@ -16,7 +16,7 @@ export function BottomNav({ activeScreen, onNavigate }: BottomNavProps) {
return (
<button
aria-current={active ? 'page' : undefined}
className="bottom-nav__item"
className={`bottom-nav__item${active ? ' bottom-nav__item--active' : ''}`}
key={item.id}
onClick={() => onNavigate(item.id)}
type="button"
+4 -1
View File
@@ -1,10 +1,13 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import { CompanionProvider } from './api/CompanionContext.js';
import { App } from './app/App.js';
import './styles/globals.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
<CompanionProvider>
<App />
</CompanionProvider>
</StrictMode>,
);
@@ -1,21 +1,43 @@
const events = [
['task.created', 'Task lifecycle events will appear here.'],
['tool_call.started', 'Tool cards will expand with raw output.'],
['assistant.final', 'Final answers stay attached to sessions.'],
] as const;
import { useState } from 'react';
import type { HealthResponse } from '@hermes-mobile/shared';
import { useCompanion } from '../../api/CompanionContext.js';
export function ActivityScreen() {
const { client, settings } = useCompanion();
const [health, setHealth] = useState<HealthResponse | null>(null);
const [status, setStatus] = useState('Tap refresh to check companion status.');
async function refresh(): Promise<void> {
setStatus('Checking companion…');
try {
const response = await client.health();
setHealth(response);
setStatus('Companion is reachable.');
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : 'Health check failed');
setHealth(null);
}
}
return (
<section className="screen stack-screen">
<h2>Activity timeline</h2>
<div className="timeline-list">
{events.map(([type, description]) => (
<article className="timeline-card" key={type}>
<span>{type}</span>
<p>{description}</p>
</article>
))}
<div className="screen-heading">
<div>
<p className="eyebrow">Activity</p>
<h2>Status</h2>
</div>
<button className="small-button" onClick={() => void refresh()} type="button">Refresh</button>
</div>
<article className="panel-card">
<dl className="status-grid">
<dt>Companion URL</dt><dd>{settings.baseUrl}</dd>
<dt>Status</dt><dd>{status}</dd>
<dt>Hermes CLI</dt><dd>{health?.checks.hermes.cliAvailable ? `Found at ${health.checks.hermes.cliPath}` : 'Unknown / unavailable'}</dd>
<dt>Workspace</dt><dd>{health?.config.workspaceRoot ?? 'Unknown'}</dd>
<dt>Uptime</dt><dd>{health ? `${Math.round(health.uptimeSeconds)}s` : 'Unknown'}</dd>
</dl>
</article>
</section>
);
}
@@ -1,28 +0,0 @@
import { Mic, Paperclip, SendHorizontal } from 'lucide-react';
export function AskScreen() {
return (
<section className="screen ask-screen">
<div className="hero-card">
<p className="eyebrow">Quick ask</p>
<h2>Send Hermes a task from your phone.</h2>
<p>Text prompting lands first; voice, uploads, streaming activity, and approvals build on this shell.</p>
</div>
<form className="composer" onSubmit={(event) => event.preventDefault()}>
<textarea aria-label="Prompt" placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} />
<div className="composer__actions">
<button className="icon-button" type="button" aria-label="Attach file">
<Paperclip size={20} />
</button>
<button className="icon-button" type="button" aria-label="Record voice note">
<Mic size={20} />
</button>
<button className="send-button" type="submit">
Send <SendHorizontal size={18} />
</button>
</div>
</form>
</section>
);
}
@@ -0,0 +1,73 @@
import { FormEvent, useState } from 'react';
import { SendHorizontal } from 'lucide-react';
import { useCompanion } from '../../api/CompanionContext.js';
type ChatMessage = {
role: 'user' | 'assistant';
content: string;
};
export function ChatScreen() {
const { client } = useCompanion();
const [prompt, setPrompt] = useState('');
const [messages, setMessages] = useState<ChatMessage[]>([]);
const [busy, setBusy] = useState(false);
const [error, setError] = useState('');
async function submitPrompt(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!prompt.trim() || busy) {
return;
}
const nextPrompt = prompt.trim();
setPrompt('');
setBusy(true);
setError('');
setMessages((currentMessages) => [...currentMessages, { role: 'user', content: nextPrompt }]);
try {
const response = await client.chat(nextPrompt);
setMessages((currentMessages) => [...currentMessages, { role: 'assistant', content: response.reply }]);
if (response.stderr) {
setError(response.stderr);
}
} catch (caughtError) {
setError(caughtError instanceof Error ? caughtError.message : 'Chat request failed');
} finally {
setBusy(false);
}
}
return (
<section className="screen chat-screen">
<div className="hero-card">
<p className="eyebrow">Chat</p>
<h2>Send Hermes a task from your phone.</h2>
<p>Prompts are sent to the companion server, which invokes the local Hermes CLI when available.</p>
</div>
<div className="message-list" aria-live="polite">
{messages.length === 0 ? <p className="empty-note">No messages yet. Ask Hermes to inspect, edit, or summarize something.</p> : null}
{messages.map((message, index) => (
<article className={`message message--${message.role}`} key={`${message.role}-${index}`}>
<span>{message.role}</span>
<p>{message.content}</p>
</article>
))}
</div>
{error ? <p className="error-card">{error}</p> : null}
<form className="composer" onSubmit={submitPrompt}>
<textarea aria-label="Prompt" onChange={(event) => setPrompt(event.target.value)} placeholder="Ask Hermes to check a service, edit code, or summarize logs…" rows={5} value={prompt} />
<div className="composer__actions">
<button className="send-button" disabled={busy || !prompt.trim()} type="submit">
{busy ? 'Sending…' : 'Send'} <SendHorizontal size={18} />
</button>
</div>
</form>
</section>
);
}
@@ -1,10 +1,118 @@
import { useEffect, useState } from 'react';
import type { FileEntry, FileReadResponse } from '@hermes-mobile/shared';
import { useCompanion } from '../../api/CompanionContext.js';
function parentPath(path: string): string {
if (path === '.') return '.';
const parts = path.split('/').filter(Boolean);
parts.pop();
return parts.length ? parts.join('/') : '.';
}
export function FilesScreen() {
const { client } = useCompanion();
const [path, setPath] = useState('.');
const [entries, setEntries] = useState<FileEntry[]>([]);
const [activeFile, setActiveFile] = useState<FileReadResponse | null>(null);
const [draft, setDraft] = useState('');
const [status, setStatus] = useState('');
const [busy, setBusy] = useState(false);
async function loadDirectory(nextPath = path): Promise<void> {
setBusy(true);
setStatus('');
try {
const response = await client.listFiles(nextPath);
setPath(response.path);
setEntries(response.entries);
setActiveFile(null);
setDraft('');
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to list files');
} finally {
setBusy(false);
}
}
async function openEntry(entry: FileEntry): Promise<void> {
if (entry.type === 'directory') {
await loadDirectory(entry.path);
return;
}
setBusy(true);
setStatus('');
try {
const response = await client.readFile(entry.path);
setActiveFile(response);
setDraft(response.content);
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to read file');
} finally {
setBusy(false);
}
}
async function saveFile(): Promise<void> {
if (!activeFile) return;
setBusy(true);
try {
const response = await client.writeFile(activeFile.path, draft);
setStatus(`Saved ${response.bytesWritten} bytes to ${response.path}`);
await openEntry({ name: activeFile.path, path: activeFile.path, type: 'file', size: 0, modifiedAt: new Date().toISOString() });
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : 'Unable to save file');
} finally {
setBusy(false);
}
}
useEffect(() => {
void loadDirectory('.');
}, [client]);
return (
<section className="screen stack-screen">
<h2>Files</h2>
<article className="empty-card">
<p>Uploads and generated artifacts will live here for reuse, download, and sharing.</p>
<div className="screen-heading">
<div>
<p className="eyebrow">Workspace</p>
<h2>Files</h2>
</div>
<button className="small-button" onClick={() => void loadDirectory()} type="button">Refresh</button>
</div>
<article className="panel-card">
<div className="path-bar">
<button disabled={path === '.' || busy} onClick={() => void loadDirectory(parentPath(path))} type="button">Up</button>
<span>{path}</span>
</div>
<div className="file-list">
{entries.map((entry) => (
<button className="file-row" key={entry.path} onClick={() => void openEntry(entry)} type="button">
<span>{entry.type === 'directory' ? '📁' : '📄'} {entry.name}</span>
<small>{entry.type === 'file' ? `${entry.size} B` : 'dir'}</small>
</button>
))}
</div>
</article>
{activeFile ? (
<article className="panel-card editor-card">
<div className="screen-heading">
<div>
<p className="eyebrow">Editing</p>
<h3>{activeFile.path}</h3>
</div>
<button className="small-button" disabled={busy} onClick={() => void saveFile()} type="button">Save</button>
</div>
<textarea aria-label="File content" onChange={(event) => setDraft(event.target.value)} value={draft} />
</article>
) : null}
{status ? <p className="error-card">{status}</p> : null}
</section>
);
}
@@ -1,12 +1,47 @@
import { FormEvent, useState } from 'react';
import { useCompanion } from '../../api/CompanionContext.js';
import { CompanionClient, DEFAULT_COMPANION_URL } from '../../api/companionClient.js';
export function SettingsScreen() {
const { client, settings, updateSettings } = useCompanion();
const [baseUrl, setBaseUrl] = useState(settings.baseUrl);
const [accessKey, setAccessKey] = useState(settings.accessKey);
const [status, setStatus] = useState('Run companion setup, paste the access key, then test the connection.');
function save(event: FormEvent<HTMLFormElement>): void {
event.preventDefault();
updateSettings({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
setStatus('Settings saved.');
}
async function testConnection(): Promise<void> {
updateSettings({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
setStatus('Testing connection…');
try {
const testClient = new CompanionClient({ baseUrl: baseUrl || DEFAULT_COMPANION_URL, accessKey });
await testClient.health();
const response = await testClient.validate();
setStatus(response.message);
} catch (caughtError) {
setStatus(caughtError instanceof Error ? caughtError.message : 'Connection failed');
}
}
return (
<section className="screen stack-screen">
<h2>Settings</h2>
<article className="settings-card">
<form className="settings-card" onSubmit={save}>
<label htmlFor="server-url">Companion server URL</label>
<input id="server-url" placeholder="https://hermes.local:8787" type="url" />
<p>Pairing, health checks, notification setup, and theme controls will expand from here.</p>
</article>
<input id="server-url" onChange={(event) => setBaseUrl(event.target.value)} placeholder={DEFAULT_COMPANION_URL} type="url" value={baseUrl} />
<label htmlFor="access-key">Access key</label>
<input id="access-key" onChange={(event) => setAccessKey(event.target.value)} placeholder="hm_…" type="password" value={accessKey} />
<div className="composer__actions">
<button className="small-button" type="submit">Save</button>
<button className="send-button" onClick={() => void testConnection()} type="button">Test connection</button>
</div>
<p>{status}</p>
</form>
</section>
);
}
@@ -0,0 +1,50 @@
import { FormEvent, useState } from 'react';
import { useCompanion } from '../../api/CompanionContext.js';
export function TerminalScreen() {
const { client } = useCompanion();
const [cwd, setCwd] = useState('.');
const [command, setCommand] = useState('pwd && ls');
const [output, setOutput] = useState('');
const [busy, setBusy] = useState(false);
async function runCommand(event: FormEvent<HTMLFormElement>): Promise<void> {
event.preventDefault();
if (!command.trim() || busy) return;
setBusy(true);
try {
const response = await client.runCommand(command, cwd);
setCwd(response.cwd);
setOutput([
`$ ${response.command}`,
response.stdout,
response.stderr ? `stderr:\n${response.stderr}` : '',
`exitCode=${response.exitCode ?? 'null'} timedOut=${response.timedOut}`,
].filter(Boolean).join('\n'));
} catch (caughtError) {
setOutput(caughtError instanceof Error ? caughtError.message : 'Command failed');
} finally {
setBusy(false);
}
}
return (
<section className="screen stack-screen">
<div className="screen-heading">
<div>
<p className="eyebrow">Remote shell</p>
<h2>Terminal</h2>
</div>
</div>
<form className="panel-card terminal-form" onSubmit={runCommand}>
<label htmlFor="cwd">Working directory</label>
<input id="cwd" onChange={(event) => setCwd(event.target.value)} value={cwd} />
<label htmlFor="command">Command</label>
<textarea id="command" onChange={(event) => setCommand(event.target.value)} rows={4} value={command} />
<button className="send-button" disabled={busy || !command.trim()} type="submit">{busy ? 'Running…' : 'Run command'}</button>
</form>
<pre className="terminal-output">{output || 'Command output appears here.'}</pre>
</section>
);
}
+58 -134
View File
@@ -8,9 +8,7 @@
-webkit-font-smoothing: antialiased;
}
* {
box-sizing: border-box;
}
* { box-sizing: border-box; }
body {
min-width: 320px;
@@ -22,44 +20,32 @@ body {
#11131f;
}
button,
input,
textarea {
font: inherit;
}
button {
cursor: pointer;
}
button, input, textarea { font: inherit; }
button { cursor: pointer; }
button:disabled { cursor: not-allowed; opacity: 0.55; }
.app-shell {
display: grid;
grid-template-rows: auto 1fr auto;
width: min(100%, 32rem);
width: min(100%, 34rem);
min-height: 100vh;
margin: 0 auto;
padding: max(1rem, env(safe-area-inset-top)) 1rem max(0.75rem, env(safe-area-inset-bottom));
}
.app-header {
.app-header, .screen-heading, .composer__actions, .path-bar {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
padding: 0.75rem 0 1rem;
align-items: center;
gap: 0.75rem;
}
.app-header h1,
.screen h2,
.hero-card h2 {
margin: 0;
line-height: 1.05;
}
.app-header, .screen-heading, .path-bar { justify-content: space-between; }
.app-header { align-items: flex-start; padding: 0.75rem 0 1rem; }
.app-header h1 {
max-width: 12rem;
font-size: clamp(1.5rem, 7vw, 2.35rem);
}
.app-header h1, .screen h2, .hero-card h2, .editor-card h3 { margin: 0; line-height: 1.05; }
.app-header h1 { max-width: 12rem; font-size: clamp(1.5rem, 7vw, 2.35rem); }
.screen h2 { font-size: 2rem; }
.editor-card h3 { max-width: 14rem; overflow-wrap: anywhere; }
.eyebrow {
margin: 0 0 0.4rem;
@@ -88,25 +74,19 @@ button {
width: 0.5rem;
height: 0.5rem;
border-radius: 999px;
background: #ffca6a;
box-shadow: 0 0 1rem rgba(255, 202, 106, 0.9);
background: #ff6a6a;
box-shadow: 0 0 1rem rgba(255, 106, 106, 0.9);
}
.app-main {
overflow: auto;
padding: 0.5rem 0 1rem;
.status-pill--online .status-pill__dot {
background: #7cffb1;
box-shadow: 0 0 1rem rgba(124, 255, 177, 0.9);
}
.screen {
display: grid;
gap: 1rem;
}
.app-main { overflow: auto; padding: 0.5rem 0 1rem; }
.screen { display: grid; gap: 1rem; }
.hero-card,
.composer,
.timeline-card,
.empty-card,
.settings-card {
.hero-card, .composer, .panel-card, .message, .error-card, .settings-card, .terminal-output {
border: 1px solid rgba(255, 255, 255, 0.12);
border-radius: 1.5rem;
background: rgba(21, 24, 39, 0.78);
@@ -114,116 +94,63 @@ button {
backdrop-filter: blur(18px);
}
.hero-card {
padding: 1.25rem;
}
.hero-card, .panel-card, .settings-card, .error-card { padding: 1rem; }
.hero-card h2 { font-size: clamp(2rem, 11vw, 3.65rem); letter-spacing: -0.06em; }
.hero-card p:last-child, .panel-card p, .settings-card p, .empty-note { margin: 0.8rem 0 0; color: rgba(255, 248, 236, 0.72); line-height: 1.5; }
.hero-card h2 {
font-size: clamp(2rem, 11vw, 3.65rem);
letter-spacing: -0.06em;
}
.hero-card p:last-child,
.timeline-card p,
.empty-card p,
.settings-card p {
margin: 0.8rem 0 0;
color: rgba(255, 248, 236, 0.72);
line-height: 1.5;
}
.composer {
display: grid;
gap: 0.85rem;
padding: 0.75rem;
}
.composer textarea,
.settings-card input {
.composer, .terminal-form, .settings-card { display: grid; gap: 0.85rem; padding: 0.75rem; }
.composer textarea, .settings-card input, .terminal-form input, .terminal-form textarea, .editor-card textarea {
width: 100%;
border: 0;
border-radius: 1rem;
outline: 0;
background: rgba(255, 255, 255, 0.08);
color: #fff8ec;
padding: 0.9rem 1rem;
}
.composer textarea {
min-height: 9rem;
resize: vertical;
padding: 1rem;
}
.composer textarea { min-height: 9rem; resize: vertical; }
.editor-card textarea { min-height: 18rem; resize: vertical; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.85rem; }
textarea::placeholder, input::placeholder { color: rgba(255, 248, 236, 0.42); }
.composer textarea::placeholder,
.settings-card input::placeholder {
color: rgba(255, 248, 236, 0.42);
}
.composer__actions {
display: flex;
align-items: center;
gap: 0.65rem;
}
.icon-button,
.send-button {
.send-button, .small-button, .path-bar button {
display: inline-flex;
align-items: center;
justify-content: center;
min-height: 3rem;
min-height: 2.7rem;
border: 0;
border-radius: 999px;
}
.icon-button {
width: 3rem;
background: rgba(255, 255, 255, 0.1);
color: #fff8ec;
}
.send-button {
gap: 0.45rem;
margin-left: auto;
padding: 0 1.05rem;
background: #ffca6a;
color: #17110a;
font-weight: 900;
}
.stack-screen h2 {
font-size: 2rem;
}
.send-button { gap: 0.45rem; margin-left: auto; padding: 0 1.05rem; background: #ffca6a; color: #17110a; }
.small-button, .path-bar button { padding: 0 0.9rem; background: rgba(255, 255, 255, 0.1); color: #fff8ec; }
.timeline-list {
display: grid;
.message-list, .file-list { display: grid; gap: 0.65rem; }
.message { padding: 0.85rem; }
.message span, .status-grid dt, .terminal-form label { color: #89e5ff; font-size: 0.78rem; font-weight: 800; text-transform: uppercase; }
.message p { margin: 0.35rem 0 0; white-space: pre-wrap; line-height: 1.45; }
.message--user { background: rgba(255, 202, 106, 0.14); }
.error-card { color: #ffd0d0; }
.path-bar span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: rgba(255, 248, 236, 0.72); }
.file-row {
display: flex;
justify-content: space-between;
gap: 0.75rem;
width: 100%;
border: 0;
border-radius: 1rem;
padding: 0.85rem;
background: rgba(255, 255, 255, 0.07);
color: #fff8ec;
text-align: left;
}
.file-row small { color: rgba(255, 248, 236, 0.58); }
.timeline-card,
.empty-card,
.settings-card {
padding: 1rem;
}
.timeline-card span {
color: #89e5ff;
font-family: "SFMono-Regular", Consolas, monospace;
font-size: 0.78rem;
font-weight: 800;
}
.settings-card {
display: grid;
gap: 0.75rem;
}
.settings-card label {
font-weight: 800;
}
.settings-card input {
padding: 0.9rem 1rem;
}
.status-grid { display: grid; grid-template-columns: auto 1fr; gap: 0.75rem; margin: 0; }
.status-grid dd { margin: 0; overflow-wrap: anywhere; color: rgba(255, 248, 236, 0.82); }
.terminal-output { min-height: 12rem; margin: 0; padding: 1rem; overflow: auto; white-space: pre-wrap; color: #d7f8ff; font-family: "SFMono-Regular", Consolas, monospace; font-size: 0.82rem; }
.bottom-nav {
display: grid;
@@ -250,7 +177,4 @@ button {
font-weight: 800;
}
.bottom-nav__item[aria-current='page'] {
background: rgba(255, 202, 106, 0.16);
color: #ffca6a;
}
.bottom-nav__item--active, .bottom-nav__item[aria-current='page'] { background: rgba(255, 202, 106, 0.16); color: #ffca6a; }