From 31b5e321a1a6522c86888a8b48a9556951fe19ee Mon Sep 17 00:00:00 2001 From: Andrew Gundersen Date: Mon, 2 Mar 2026 15:52:05 -0500 Subject: [PATCH] Add crimata-agent and redesign agentic OS UI - crimata-agent (TypeScript, port 7702): WebSocket server + HTTP /events endpoint, Claude tool-use loop (render/remove/call_api/show_cursor_response), session.start fires on first browser connection to seed the canvas - OS UI redesigned: light grey canvas (#e8e8e8), no dock bar, cursor dot, Cmd+Space input bubble above cursor, elements placed by agent - Canvas elements: app_icon (floating icons with running dot), window (no-chrome content cards), cursor_response (text + action pills) - Component system: HTML fragments fetched from /components/{app}/{name}.html, inline scripts re-executed after injection - contacts/crimata.json: added icon, defaultComponent, components[], api[] - dock: apps.h/c/main.c extended to parse and serve components + api from crimata.json; BUF_SIZE increased for richer JSON output - ui/components/contacts/list.html + card.html: first component pair - provision.sh: installs crimata-agent, adds WebSocket + /events + /components nginx locations, fixes /auth/ prefix stripping Co-Authored-By: Claude Sonnet 4.6 --- agent/package.json | 20 ++ agent/src/agent.ts | 155 ++++++++++++ agent/src/canvas.ts | 26 ++ agent/src/index.ts | 60 +++++ agent/src/tools.ts | 66 +++++ agent/src/types.ts | 42 ++++ agent/tsconfig.json | 14 ++ apps/contacts/crimata.json | 49 +++- dock/src/apps.c | 57 ++++- dock/src/apps.h | 7 +- dock/src/main.c | 32 ++- ui/components/contacts/card.html | 41 ++++ ui/components/contacts/list.html | 47 ++++ ui/index.html | 57 ++--- ui/js/os.js | 410 ++++++++++++++++++++----------- ui/styles/canvas.css | 15 +- ui/styles/cursor.css | 43 ++-- ui/styles/login.css | 58 ++--- ui/styles/window.css | 136 ++++++---- 19 files changed, 1030 insertions(+), 305 deletions(-) create mode 100644 agent/package.json create mode 100644 agent/src/agent.ts create mode 100644 agent/src/canvas.ts create mode 100644 agent/src/index.ts create mode 100644 agent/src/tools.ts create mode 100644 agent/src/types.ts create mode 100644 agent/tsconfig.json create mode 100644 ui/components/contacts/card.html create mode 100644 ui/components/contacts/list.html diff --git a/agent/package.json b/agent/package.json new file mode 100644 index 0000000..eb3a1f3 --- /dev/null +++ b/agent/package.json @@ -0,0 +1,20 @@ +{ + "name": "crimata-agent", + "version": "1.0.0", + "scripts": { + "build": "tsc", + "start": "node dist/index.js", + "dev": "ts-node src/index.ts" + }, + "dependencies": { + "@anthropic-ai/sdk": "^0.39.0", + "express": "^4.18.0", + "ws": "^8.18.0" + }, + "devDependencies": { + "@types/express": "^4.17.0", + "@types/node": "^20.0.0", + "@types/ws": "^8.5.0", + "typescript": "^5.0.0" + } +} diff --git a/agent/src/agent.ts b/agent/src/agent.ts new file mode 100644 index 0000000..478289a --- /dev/null +++ b/agent/src/agent.ts @@ -0,0 +1,155 @@ +import Anthropic from '@anthropic-ai/sdk' +import WebSocket from 'ws' +import { Canvas } from './canvas' +import { toolDefinitions } from './tools' +import { BusEvent, CanvasElement, InstalledApp, WSMessage } from './types' + +const SERVICE_PORTS: Record = { + auth: 7700, + dock: 7701, + agent: 7702, +} + +async function fetchApps(): Promise { + try { + const res = await fetch('http://localhost:7701/apps') + return await res.json() as InstalledApp[] + } catch { + return [] + } +} + +function appPort(appId: string, apps: InstalledApp[]): number { + if (SERVICE_PORTS[appId]) return SERVICE_PORTS[appId] + return apps.find(a => a.id === appId)?.port ?? 3000 +} + +function buildSystemPrompt(apps: InstalledApp[]): string { + const appList = apps.map(a => { + const components = a.components?.length ? a.components.join(', ') : 'none' + const apiList = a.api?.map(e => `${e.method} ${e.endpoint} — ${e.description}`).join('; ') ?? '' + return ` - ${a.id} (${a.name}, port ${a.port}): components=[${components}] api=[${apiList}]` + }).join('\n') + + return `You are the Crimata desktop agent — an agentic window manager for a personal Linux instance. +You control a spatial canvas UI by calling tools. Think of yourself as both the OS shell and a helpful assistant. + +Installed apps: +${appList || ' (none)'} + +Component naming: "app.component", e.g. "contacts.list", "contacts.card" +App icons use type "app_icon", windows use type "window". + +Layout rules: +- Place app icons spread naturally across the canvas (not all in one corner) +- Open windows adjacent to their app icon +- Keep the layout clean and uncluttered + +Behaviour: +- On session.start: render all installed app icons on the canvas, then open each app's defaultComponent +- On app_icon.clicked: render the app's default window next to the icon +- On cursor.input: interpret naturally — open apps, answer questions, rearrange canvas, call APIs +- On button.clicked or api events: decide whether to respond or stay silent +- Only respond when you have something useful to do — silence is fine` +} + +export async function handleEvent( + event: BusEvent, + canvas: Canvas, + ws: WebSocket | null, + apiKey: string, +): Promise { + const apps = await fetchApps() + const model = process.env.CLAUDE_MODEL || 'claude-haiku-4-5-20251001' + + const client = new Anthropic({ apiKey }) + + const canvasJson = JSON.stringify(canvas.getState(), null, 2) + const userMsg = `Canvas:\n${canvasJson}\n\nEvent: ${JSON.stringify(event)}` + + const messages: Anthropic.MessageParam[] = [ + { role: 'user', content: userMsg }, + ] + + // Agentic loop — keep going until Claude stops calling tools + while (true) { + const response = await client.messages.create({ + model, + max_tokens: 2048, + system: buildSystemPrompt(apps), + tools: toolDefinitions, + messages, + }) + + const toolUses = response.content.filter(b => b.type === 'tool_use') + if (toolUses.length === 0) break + + const toolResults: Anthropic.ToolResultBlockParam[] = [] + + for (const block of response.content) { + if (block.type !== 'tool_use') continue + + const input = block.input as Record + let result: unknown = { ok: true } + + switch (block.name) { + case 'render': { + const el = input as unknown as CanvasElement + canvas.render(el) + send(ws, { op: 'render', element: el }) + break + } + + case 'remove': { + const id = input.id as string + canvas.remove(id) + send(ws, { op: 'remove', id }) + break + } + + case 'call_api': { + try { + const port = appPort(input.app as string, apps) + const url = `http://localhost:${port}${input.endpoint as string}` + const res = await fetch(url, { + method: input.method as string, + headers: { 'Content-Type': 'application/json' }, + body: input.body ? JSON.stringify(input.body) : undefined, + }) + result = await res.json() + } catch (e: unknown) { + result = { error: String(e) } + } + break + } + + case 'show_cursor_response': { + send(ws, { + op: 'cursor_response', + text: input.text as string | undefined, + pills: input.pills as string[] | undefined, + position: input.position as { x: number; y: number }, + }) + break + } + } + + toolResults.push({ + type: 'tool_result', + tool_use_id: block.id, + content: JSON.stringify(result), + }) + } + + messages.push({ role: 'assistant', content: response.content }) + messages.push({ role: 'user', content: toolResults }) + + if (response.stop_reason !== 'tool_use') break + } +} + +function send(ws: WebSocket | null, msg: WSMessage): void { + if (ws && ws.readyState === WebSocket.OPEN) { + ws.send(JSON.stringify(msg)) + } +} diff --git a/agent/src/canvas.ts b/agent/src/canvas.ts new file mode 100644 index 0000000..a8b9d3d --- /dev/null +++ b/agent/src/canvas.ts @@ -0,0 +1,26 @@ +import { CanvasElement } from './types' + +export class Canvas { + private elements = new Map() + + render(element: CanvasElement): void { + this.elements.set(element.id, element) + } + + remove(id: string): void { + this.elements.delete(id) + } + + update(id: string, patch: Partial): void { + const el = this.elements.get(id) + if (el) this.elements.set(id, { ...el, ...patch }) + } + + getState(): CanvasElement[] { + return Array.from(this.elements.values()) + } + + clear(): void { + this.elements.clear() + } +} diff --git a/agent/src/index.ts b/agent/src/index.ts new file mode 100644 index 0000000..e044b3f --- /dev/null +++ b/agent/src/index.ts @@ -0,0 +1,60 @@ +import express from 'express' +import { WebSocketServer, WebSocket } from 'ws' +import { createServer } from 'http' +import { Canvas } from './canvas' +import { handleEvent } from './agent' +import { BusEvent } from './types' + +const PORT = 7702 +const API_KEY = process.env.ANTHROPIC_API_KEY ?? '' + +const app = express() +const server = createServer(app) +const wss = new WebSocketServer({ server, path: '/ws' }) +const canvas = new Canvas() + +let activeWs: WebSocket | null = null +let sessionStarted = false + +app.use(express.json()) + +app.get('/health', (_req, res) => res.json({ status: 'ok' })) + +// POST /events — server-side services emit events to the agent +app.post('/events', async (req, res) => { + const event: BusEvent = req.body + res.json({ ok: true }) + handleEvent(event, canvas, activeWs, API_KEY).catch(console.error) +}) + +wss.on('connection', (ws) => { + activeWs = ws + console.log('browser connected') + + // Send current canvas state immediately + ws.send(JSON.stringify({ op: 'canvas', elements: canvas.getState() })) + + // Fire session.start on first browser connection to initialise canvas + if (!sessionStarted) { + sessionStarted = true + handleEvent({ type: 'session.start' }, canvas, ws, API_KEY).catch(console.error) + } + + ws.on('message', async (raw) => { + try { + const event: BusEvent = JSON.parse(raw.toString()) + await handleEvent(event, canvas, ws, API_KEY) + } catch (e) { + console.error('ws message error:', e) + } + }) + + ws.on('close', () => { + if (activeWs === ws) activeWs = null + console.log('browser disconnected') + }) +}) + +server.listen(PORT, () => { + console.log(`crimata-agent listening on :${PORT}`) +}) diff --git a/agent/src/tools.ts b/agent/src/tools.ts new file mode 100644 index 0000000..2b8ec23 --- /dev/null +++ b/agent/src/tools.ts @@ -0,0 +1,66 @@ +import Anthropic from '@anthropic-ai/sdk' + +export const toolDefinitions: Anthropic.Tool[] = [ + { + name: 'render', + description: 'Place or update a component on the canvas. Use stable IDs so re-renders update in place.', + input_schema: { + type: 'object' as const, + properties: { + id: { type: 'string', description: 'Unique stable ID, e.g. "contacts-list", "mail-inbox"' }, + type: { type: 'string', enum: ['app_icon', 'window'] }, + component: { type: 'string', description: 'App component, e.g. "contacts.list", "contacts.card"' }, + props: { type: 'object', description: 'Data to pass to the component' }, + x: { type: 'number', description: 'Canvas X position in pixels' }, + y: { type: 'number', description: 'Canvas Y position in pixels' }, + running: { type: 'boolean', description: 'Whether the app is running (for app_icon type)' }, + }, + required: ['id', 'type', 'component', 'x', 'y'], + }, + }, + { + name: 'remove', + description: 'Remove a component from the canvas.', + input_schema: { + type: 'object' as const, + properties: { + id: { type: 'string' }, + }, + required: ['id'], + }, + }, + { + name: 'call_api', + description: 'Call a local app or service API on this instance. Returns the JSON response.', + input_schema: { + type: 'object' as const, + properties: { + app: { type: 'string', description: 'App ID or service: "contacts", "auth", "dock"' }, + endpoint: { type: 'string', description: 'Path, e.g. "/contacts" or "/contacts/123"' }, + method: { type: 'string', enum: ['GET', 'POST', 'PUT', 'PATCH', 'DELETE'] }, + body: { type: 'object', description: 'Request body for POST/PUT/PATCH' }, + }, + required: ['app', 'endpoint', 'method'], + }, + }, + { + name: 'show_cursor_response', + description: 'Show a text response or action pills near the cursor. Use for replying to user input.', + input_schema: { + type: 'object' as const, + properties: { + text: { type: 'string', description: 'Text to display' }, + pills: { type: 'array', items: { type: 'string' }, description: 'Action buttons to show' }, + position: { + type: 'object', + properties: { + x: { type: 'number' }, + y: { type: 'number' }, + }, + required: ['x', 'y'], + }, + }, + required: ['position'], + }, + }, +] diff --git a/agent/src/types.ts b/agent/src/types.ts new file mode 100644 index 0000000..f08f4fe --- /dev/null +++ b/agent/src/types.ts @@ -0,0 +1,42 @@ +export type CanvasElement = { + id: string + type: 'app_icon' | 'window' | 'cursor_response' + component: string // e.g. "contacts", "contacts.list", "generic.form" + props?: Record + x: number + y: number + running?: boolean +} + +export type BusEvent = { + type: string + data?: Record + position?: { x: number; y: number } + text?: string +} + +export type InstalledApp = { + id: string + name: string + icon: string + port: number + running: boolean + defaultComponent: string + components: string[] + api: ApiEndpoint[] +} + +export type ApiEndpoint = { + name: string + description: string + method: string + endpoint: string + params?: Record + body?: Record +} + +export type WSMessage = + | { op: 'render'; element: CanvasElement } + | { op: 'remove'; id: string } + | { op: 'canvas'; elements: CanvasElement[] } + | { op: 'cursor_response'; text?: string; pills?: string[]; position: { x: number; y: number } } diff --git a/agent/tsconfig.json b/agent/tsconfig.json new file mode 100644 index 0000000..69c4a3f --- /dev/null +++ b/agent/tsconfig.json @@ -0,0 +1,14 @@ +{ + "compilerOptions": { + "target": "ES2020", + "module": "commonjs", + "lib": ["ES2020"], + "outDir": "./dist", + "rootDir": "./src", + "strict": true, + "esModuleInterop": true, + "skipLibCheck": true, + "resolveJsonModule": true + }, + "include": ["src/**/*"] +} diff --git a/apps/contacts/crimata.json b/apps/contacts/crimata.json index d63cf4b..7589cd9 100644 --- a/apps/contacts/crimata.json +++ b/apps/contacts/crimata.json @@ -1,7 +1,54 @@ { "id": "contacts", "name": "Contacts", + "icon": "👤", "port": 3001, + "defaultComponent": "contacts.list", + "components": ["contacts.list", "contacts.card"], "db": true, - "migrate": "npm run migrate" + "migrate": "npm run migrate", + "api": [ + { + "name": "list_contacts", + "description": "Get all contacts ordered by name", + "method": "GET", + "endpoint": "/contacts" + }, + { + "name": "get_contact", + "description": "Get a single contact by ID", + "method": "GET", + "endpoint": "/contacts/:id" + }, + { + "name": "create_contact", + "description": "Create a new contact (name and domain required)", + "method": "POST", + "endpoint": "/contacts", + "body": { + "name": "string", + "domain": "string", + "avatar_url": "string?", + "notes": "string?" + } + }, + { + "name": "update_contact", + "description": "Partially update a contact by ID", + "method": "PATCH", + "endpoint": "/contacts/:id", + "body": { + "name": "string?", + "domain": "string?", + "avatar_url": "string?", + "notes": "string?" + } + }, + { + "name": "delete_contact", + "description": "Delete a contact by ID", + "method": "DELETE", + "endpoint": "/contacts/:id" + } + ] } diff --git a/dock/src/apps.c b/dock/src/apps.c index ce445ff..8d99c5e 100644 --- a/dock/src/apps.c +++ b/dock/src/apps.c @@ -5,9 +5,9 @@ #include "apps.h" #define MANIFEST_GLOB "/usr/lib/crimata-*/crimata.json" -#define MAX_FILE_SIZE 4096 +#define MAX_FILE_SIZE 16384 -/* Pull a string value out of a flat JSON object (same approach as auth) */ +/* Pull a string value out of a flat JSON object */ static int json_str(const char *json, const char *key, char *out, size_t out_len) { char search[128]; @@ -42,6 +42,51 @@ static int json_int(const char *json, const char *key, int *out) return 1; } +/* Extract a raw JSON array or object value (string-aware bracket matching) */ +static int json_raw_value(const char *json, const char *key, char *out, size_t out_len) +{ + char search[128]; + snprintf(search, sizeof(search), "\"%s\"", key); + const char *pos = strstr(json, search); + if (!pos) return 0; + + pos += strlen(search); + while (*pos == ' ' || *pos == ':') pos++; + if (*pos != '[' && *pos != '{') return 0; + + char open = *pos; + char close = (open == '[') ? ']' : '}'; + int depth = 0; + int in_str = 0; + size_t i = 0; + + while (*pos && i < out_len - 1) { + char c = *pos; + out[i++] = c; + + if (in_str) { + if (c == '\\' && *(pos + 1)) { + /* escaped char — copy next byte too */ + pos++; + if (i < out_len - 1) out[i++] = *pos; + } else if (c == '"') { + in_str = 0; + } + } else { + if (c == '"') in_str = 1; + else if (c == open) depth++; + else if (c == close) { + depth--; + if (depth == 0) { pos++; break; } + } + } + pos++; + } + + out[i] = '\0'; + return (depth == 0 && i > 0) ? 1 : 0; +} + static int parse_manifest(const char *path, app_t *app) { FILE *f = fopen(path, "r"); @@ -54,10 +99,14 @@ static int parse_manifest(const char *path, app_t *app) if (!json_str(buf, "id", app->id, sizeof(app->id))) return 0; if (!json_str(buf, "name", app->name, sizeof(app->name))) return 0; - json_str(buf, "icon", app->icon, sizeof(app->icon)); + + json_str(buf, "icon", app->icon, sizeof(app->icon)); + json_str(buf, "defaultComponent", app->default_component, sizeof(app->default_component)); json_int(buf, "port", &app->port); - app->running = 0; + json_raw_value(buf, "components", app->components_json, sizeof(app->components_json)); + json_raw_value(buf, "api", app->api_json, sizeof(app->api_json)); + app->running = 0; return 1; } diff --git a/dock/src/apps.h b/dock/src/apps.h index e52cee6..427b3b4 100644 --- a/dock/src/apps.h +++ b/dock/src/apps.h @@ -2,14 +2,17 @@ #define APPS_H #define MAX_APPS 32 -#define MAX_STR 128 +#define MAX_STR 256 typedef struct { char id[MAX_STR]; char name[MAX_STR]; char icon[MAX_STR]; int port; - int running; /* populated at request time via systemd check */ + int running; /* populated at request time via systemd check */ + char default_component[MAX_STR]; + char components_json[2048]; /* raw JSON array, e.g. ["contacts.list","contacts.card"] */ + char api_json[8192]; /* raw JSON array of ApiEndpoint objects */ } app_t; /* Scan /usr/lib/crimata-*/crimata.json — returns app count */ diff --git a/dock/src/main.c b/dock/src/main.c index bec8466..303c759 100644 --- a/dock/src/main.c +++ b/dock/src/main.c @@ -6,7 +6,7 @@ #include "systemd.h" #define PORT 7701 -#define BUF_SIZE 16384 +#define BUF_SIZE 65536 /* ── JSON response helper ─────────────────────────────────────────────────── */ @@ -42,12 +42,29 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn) pos += snprintf(buf + pos, sizeof(buf) - pos, "["); for (int i = 0; i < count; i++) { + const char *components = apps[i].components_json[0] ? apps[i].components_json : "[]"; + const char *api = apps[i].api_json[0] ? apps[i].api_json : "[]"; + pos += snprintf(buf + pos, sizeof(buf) - pos, - "%s{\"id\":\"%s\",\"name\":\"%s\",\"icon\":\"%s\"," - "\"port\":%d,\"running\":%s}", + "%s{" + "\"id\":\"%s\"," + "\"name\":\"%s\"," + "\"icon\":\"%s\"," + "\"port\":%d," + "\"running\":%s," + "\"defaultComponent\":\"%s\"," + "\"components\":%s," + "\"api\":%s" + "}", i > 0 ? "," : "", - apps[i].id, apps[i].name, apps[i].icon, - apps[i].port, apps[i].running ? "true" : "false"); + apps[i].id, + apps[i].name, + apps[i].icon, + apps[i].port, + apps[i].running ? "true" : "false", + apps[i].default_component, + components, + api); } pos += snprintf(buf + pos, sizeof(buf) - pos, "]"); @@ -60,7 +77,6 @@ static enum MHD_Result handle_list(struct MHD_Connection *conn) static enum MHD_Result handle_action(struct MHD_Connection *conn, const char *app_id, int start) { - /* Verify app exists */ app_t apps[MAX_APPS]; int count = apps_scan(apps); int found = 0; @@ -108,10 +124,10 @@ static enum MHD_Result handler(void *cls, char app_id[MAX_STR]; if (strcmp(method, "POST") == 0) { - if (sscanf(url, "/apps/%127[^/]/start", app_id) == 1) + if (sscanf(url, "/apps/%255[^/]/start", app_id) == 1) return handle_action(conn, app_id, 1); - if (sscanf(url, "/apps/%127[^/]/stop", app_id) == 1) + if (sscanf(url, "/apps/%255[^/]/stop", app_id) == 1) return handle_action(conn, app_id, 0); } diff --git a/ui/components/contacts/card.html b/ui/components/contacts/card.html new file mode 100644 index 0000000..0f8e329 --- /dev/null +++ b/ui/components/contacts/card.html @@ -0,0 +1,41 @@ +
+
?
+

—

+
+
+ + + + diff --git a/ui/components/contacts/list.html b/ui/components/contacts/list.html new file mode 100644 index 0000000..64ecbac --- /dev/null +++ b/ui/components/contacts/list.html @@ -0,0 +1,47 @@ +
+
+

Contacts

+ +
+
+
Loading…
+
+
+ + + + diff --git a/ui/index.html b/ui/index.html index 526c231..930be72 100644 --- a/ui/index.html +++ b/ui/index.html @@ -6,12 +6,11 @@ Crimata - - +
@@ -37,57 +36,29 @@
- -
- - -
+ +
+ + +
- -
+ +
- -
- -
-
- diff --git a/ui/js/os.js b/ui/js/os.js index cbbe104..08266ca 100644 --- a/ui/js/os.js +++ b/ui/js/os.js @@ -1,17 +1,264 @@ +/* ── Canvas / WebSocket layer ─────────────────────────────────────────────── + Manages the agent WebSocket connection and renders canvas elements. + This layer is pure vanilla JS — Alpine handles auth + cursor input. + ─────────────────────────────────────────────────────────────────────────── */ + +let ws = null +let canvasDiv = null +const elMap = {} // id → DOM element + +function connectWS() { + const proto = location.protocol === 'https:' ? 'wss' : 'ws' + ws = new WebSocket(`${proto}://${location.host}/ws`) + + ws.onopen = () => console.log('agent connected') + + ws.onmessage = (e) => { + const msg = JSON.parse(e.data) + switch (msg.op) { + case 'render': renderEl(msg.element); break + case 'remove': removeEl(msg.id); break + case 'canvas': msg.elements.forEach(renderEl); break + case 'cursor_response': showCursorResponse(msg); break + } + } + + ws.onclose = () => { + console.log('agent disconnected — reconnecting in 2s') + setTimeout(connectWS, 2000) + } +} + +function sendEvent(event) { + if (ws && ws.readyState === WebSocket.OPEN) + ws.send(JSON.stringify(event)) +} + +/* ── Element lifecycle ────────────────────────────────────────────────────── */ + +function removeEl(id) { + const div = elMap[id] + if (div) { div.remove(); delete elMap[id] } +} + +function renderEl(el) { + removeEl(el.id) + + const div = document.createElement('div') + div.dataset.elId = el.id + div.classList.add('canvas-element', el.type.replace(/_/g, '-')) + div.style.left = el.x + 'px' + div.style.top = el.y + 'px' + + if (el.type === 'app_icon') { + buildAppIcon(div, el) + } else if (el.type === 'window') { + buildWindow(div, el) + } + + canvasDiv.appendChild(div) + elMap[el.id] = div + makeDraggable(div, el) +} + +/* ── App icon ─────────────────────────────────────────────────────────────── */ + +function buildAppIcon(div, el) { + const icon = el.props?.icon || '📦' + const label = el.props?.label || el.props?.name || el.component + const dot = el.running ? '
' : '' + + div.innerHTML = ` +
${icon}
+
${label}
+ ${dot} + ` + + div.addEventListener('click', () => { + sendEvent({ + type: 'app_icon.clicked', + data: { id: el.id, component: el.component }, + position: { x: el.x, y: el.y }, + }) + }) +} + +/* ── Window (content card) ────────────────────────────────────────────────── */ + +function buildWindow(div, el) { + const content = document.createElement('div') + content.className = 'window-content' + content.innerHTML = '
Loading…
' + div.appendChild(content) + + // Apply explicit size if provided + if (el.props?.width) div.style.width = el.props.width + 'px' + if (el.props?.height) div.style.height = el.props.height + 'px' + + const parts = el.component.split('.') + const appId = parts[0] + const compName = parts[1] || parts[0] + const url = `/components/${appId}/${compName}.html` + + fetch(url) + .then(r => r.ok ? r.text() : Promise.reject(r.status)) + .then(html => { + content.innerHTML = html + // Make props available to component scripts via window scope + window.__componentProps = el.props || {} + // Re-execute inline scripts (innerHTML doesn't run scripts) + content.querySelectorAll('script').forEach(old => { + const s = document.createElement('script') + s.textContent = old.textContent + old.replaceWith(s) + }) + }) + .catch(() => { + content.innerHTML = `
${el.component}
` + }) + + // Forward data-event button clicks to agent + div.addEventListener('click', (e) => { + const btn = e.target.closest('button[data-event]') + if (btn) { + sendEvent({ + type: 'button.clicked', + data: { window: el.id, event: btn.dataset.event, value: btn.dataset.value }, + }) + } + }) +} + +/* ── Cursor response ──────────────────────────────────────────────────────── */ + +function showCursorResponse(msg) { + removeEl('cursor_response') + + const div = document.createElement('div') + div.dataset.elId = 'cursor_response' + div.classList.add('canvas-element', 'cursor-response') + div.style.left = msg.position.x + 'px' + div.style.top = msg.position.y + 'px' + + let html = '' + if (msg.text) html += `

${msg.text}

` + if (msg.pills?.length) { + html += '
' + msg.pills.forEach(p => { html += `` }) + html += '
' + } + div.innerHTML = html + + div.querySelectorAll('[data-pill]').forEach(btn => { + btn.addEventListener('click', () => { + sendEvent({ type: 'pill.clicked', data: { text: btn.dataset.pill } }) + removeEl('cursor_response') + }) + }) + + // Dismiss on outside click + const dismiss = (e) => { + if (!div.contains(e.target)) { + removeEl('cursor_response') + document.removeEventListener('click', dismiss) + } + } + setTimeout(() => document.addEventListener('click', dismiss), 50) + + canvasDiv.appendChild(div) + elMap['cursor_response'] = div +} + +/* ── Drag ─────────────────────────────────────────────────────────────────── */ + +function makeDraggable(div, el) { + let startX, startY, startL, startT + + const handle = div // drag from anywhere on element + + handle.addEventListener('mousedown', (e) => { + // Don't initiate drag from interactive elements inside windows + if (el.type === 'window' && ( + e.target.tagName === 'BUTTON' || + e.target.tagName === 'INPUT' || + e.target.tagName === 'A' || + e.target.tagName === 'SELECT' + )) return + + e.preventDefault() + startX = e.clientX + startY = e.clientY + startL = parseInt(div.style.left) || 0 + startT = parseInt(div.style.top) || 0 + + const onMove = (e) => { + div.style.left = (startL + e.clientX - startX) + 'px' + div.style.top = (startT + e.clientY - startY) + 'px' + } + const onUp = () => { + window.removeEventListener('mousemove', onMove) + window.removeEventListener('mouseup', onUp) + } + window.addEventListener('mousemove', onMove) + window.addEventListener('mouseup', onUp) + }) +} + +/* ── Alpine component (auth + cursor input) ───────────────────────────────── + Keeps Alpine scope minimal — just auth state and cursor input. + ─────────────────────────────────────────────────────────────────────────── */ + +const cursorDot = document.getElementById('cursor-dot') + document.addEventListener('alpine:init', () => { Alpine.data('os', () => ({ - // ── Auth state ──────────────────────────────────────────────────────── + // Auth authed: false, - currentUser: '', loginUsername: '', loginPassword: '', loginError: '', + // Cursor input + inputMode: false, + cursorQuery: '', + cursorX: 0, + cursorY: 0, + + trackCursor(e) { + this.cursorX = e.clientX + this.cursorY = e.clientY + if (cursorDot) { + cursorDot.style.left = e.clientX + 'px' + cursorDot.style.top = e.clientY + 'px' + } + }, + + async init() { + canvasDiv = document.getElementById('canvas') + await this.checkAuth() + if (this.authed) connectWS() + }, + + async checkAuth() { + const token = sessionStorage.getItem('auth_token') + if (!token) return + try { + const res = await fetch('/auth/me', { + headers: { Authorization: `Bearer ${token}` }, + }) + if (res.ok) { + this.authed = true + } else { + sessionStorage.removeItem('auth_token') + } + } catch (_) { /* auth service down */ } + }, + async submitLogin() { this.loginError = '' try { - const res = await fetch('/auth', { + const res = await fetch('/auth/auth', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ @@ -25,124 +272,18 @@ document.addEventListener('alpine:init', () => { return } sessionStorage.setItem('auth_token', data.token) - this.currentUser = data.username this.loginPassword = '' - this.authed = true - await this.loadApps() - const bio = this.installedApps.find(a => a.id === 'bio') - if (bio) this.openApp(bio) - } catch (e) { - this.loginError = 'Auth service unreachable' - } - }, - - async checkAuth() { - const token = sessionStorage.getItem('auth_token') - if (!token) return - - try { - const res = await fetch('/me', { - headers: { 'Authorization': `Bearer ${token}` }, - }) - if (res.ok) { - const data = await res.json() - this.currentUser = data.username - this.authed = true - } else { - sessionStorage.removeItem('auth_token') - } + this.authed = true + connectWS() } catch (_) { - /* auth service down — leave authed=false */ - } - }, - - // ── Canvas pan state ────────────────────────────────────────────────── - offset: { x: 0, y: 0 }, - isPanning: false, - panStart: { x: 0, y: 0 }, - - startPan(e) { - if (e.target !== document.getElementById('canvas') && - !e.target.closest('#canvas') || - e.target.closest('.window')) return - this.isPanning = true - this.panStart = { x: e.clientX - this.offset.x, y: e.clientY - this.offset.y } - document.getElementById('canvas').classList.add('panning') - }, - - pan(e) { - if (!this.isPanning) return - this.offset = { x: e.clientX - this.panStart.x, y: e.clientY - this.panStart.y } - }, - - endPan() { - this.isPanning = false - document.getElementById('canvas').classList.remove('panning') - }, - - // ── Windows ─────────────────────────────────────────────────────────── - windows: [], - zCounter: 100, - - openApp(app) { - // Bring to front if already open - const existing = this.windows.find(w => w.appId === app.id) - if (existing) { this.focusWindow(existing); return } - - this.windows.push({ - id: Date.now(), - appId: app.id, - title: app.name, - url: app.url, - x: 120 + Math.random() * 200, - y: 80 + Math.random() * 100, - w: 900, - h: 600, - z: ++this.zCounter, - }) - }, - - focusWindow(win) { - win.z = ++this.zCounter - }, - - closeWindow(win) { - this.windows = this.windows.filter(w => w.id !== win.id) - }, - - // ── Window drag ─────────────────────────────────────────────────────── - dragging: null, - dragStart: { x: 0, y: 0 }, - - startDrag(e, win) { - this.focusWindow(win) - this.dragging = win - this.dragStart = { x: e.clientX - win.x, y: e.clientY - win.y } - - const onMove = (e) => { - if (!this.dragging) return - this.dragging.x = e.clientX - this.dragStart.x - this.dragging.y = e.clientY - this.dragStart.y - } - const onUp = () => { - this.dragging = null - window.removeEventListener('mousemove', onMove) - window.removeEventListener('mouseup', onUp) + this.loginError = 'Auth service unreachable' } - window.addEventListener('mousemove', onMove) - window.addEventListener('mouseup', onUp) }, - // ── Cursor input mode ───────────────────────────────────────────────── - inputMode: false, - cursorQuery: '', - handleKeydown(e) { - // ⌘+Space or Ctrl+Space toggles input mode if ((e.metaKey || e.ctrlKey) && e.code === 'Space') { e.preventDefault() this.inputMode ? this.exitInputMode() : this.enterInputMode() - return } }, @@ -158,46 +299,15 @@ document.addEventListener('alpine:init', () => { }, submitQuery() { - const query = this.cursorQuery.trim().toLowerCase() - - // Check if it matches an installed app name - const app = this.installedApps.find(a => a.name.toLowerCase() === query) - if (app) { this.openApp(app); this.exitInputMode(); return } - - // Otherwise — send to LLM (placeholder) - console.log('LLM query:', query) + const text = this.cursorQuery.trim() + if (!text) { this.exitInputMode(); return } + sendEvent({ + type: 'cursor.input', + text, + position: { x: this.cursorX, y: this.cursorY }, + }) this.exitInputMode() }, - // ── Installed apps ──────────────────────────────────────────────────── - installedApps: [], - - async loadApps() { - try { - const res = await fetch('/dock/apps') - const data = await res.json() - this.installedApps = data.map(app => ({ - ...app, - url: `/apps/${app.id}`, - })) - } catch (e) { - console.error('dock unreachable:', e) - } - }, - - // ── Init ────────────────────────────────────────────────────────────── - async init() { - await this.checkAuth() - - if (this.authed) { - await this.loadApps() - // Refresh running status every 10s - setInterval(() => this.loadApps(), 10_000) - // Open bio full-screen on load - const bio = this.installedApps.find(a => a.id === 'bio') - if (bio) this.openApp(bio) - } - } - })) }) diff --git a/ui/styles/canvas.css b/ui/styles/canvas.css index 58782b8..cb55a7a 100644 --- a/ui/styles/canvas.css +++ b/ui/styles/canvas.css @@ -5,20 +5,17 @@ html, body { width: 100%; height: 100%; overflow: hidden; - background: #1a1a1a; + background: #e8e8e8; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; } #canvas { position: absolute; - top: 0; - left: 0; - width: 100%; - height: 100%; - will-change: transform; - cursor: grab; + inset: 0; + overflow: hidden; } -#canvas.panning { - cursor: grabbing; +.canvas-element { + position: absolute; + user-select: none; } diff --git a/ui/styles/cursor.css b/ui/styles/cursor.css index b40c15c..d2f1fe5 100644 --- a/ui/styles/cursor.css +++ b/ui/styles/cursor.css @@ -1,24 +1,39 @@ -#cursor-input { +/* Hide system cursor everywhere */ +* { cursor: none !important; } + +/* Dot cursor */ +#cursor-dot { position: fixed; - top: 50%; - left: 50%; + width: 8px; + height: 8px; + background: #1a1a1a; + border-radius: 50%; + pointer-events: none; transform: translate(-50%, -50%); - z-index: 99999; + z-index: 9999; + transition: width 0.1s, height 0.1s; +} + +/* Input bubble — anchored to cursor position via inline style */ +#cursor-bubble { + position: fixed; + transform: translate(-50%, calc(-100% - 14px)); + z-index: 9998; + pointer-events: auto; } -#cursor-input-field { - width: 480px; - padding: 14px 20px; - font-size: 1.1rem; +#cursor-input { + background: white; border: none; - border-radius: 12px; - background: rgba(255,255,255,0.95); - backdrop-filter: blur(20px); - box-shadow: 0 24px 80px rgba(0,0,0,0.4); + border-radius: 20px; + padding: 9px 18px; + font-size: 14px; + color: #1a1a1a; + box-shadow: 0 4px 24px rgba(0, 0, 0, 0.14); outline: none; - color: #111; + width: 280px; } -#cursor-input-field::placeholder { +#cursor-input::placeholder { color: #aaa; } diff --git a/ui/styles/login.css b/ui/styles/login.css index 1afd86c..54d3aec 100644 --- a/ui/styles/login.css +++ b/ui/styles/login.css @@ -5,79 +5,73 @@ display: flex; align-items: center; justify-content: center; - background: rgba(0, 0, 0, 0.6); - backdrop-filter: blur(24px); - -webkit-backdrop-filter: blur(24px); + background: rgba(232, 232, 232, 0.85); + backdrop-filter: blur(12px); + -webkit-backdrop-filter: blur(12px); } .login-card { - background: rgba(255, 255, 255, 0.1); - border: 1px solid rgba(255, 255, 255, 0.2); + background: white; border-radius: 20px; - padding: 40px 48px; - width: 360px; + padding: 40px 44px; + width: 340px; display: flex; flex-direction: column; - gap: 20px; - color: #fff; + gap: 14px; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.12); } .login-card h1 { - font-size: 1.4rem; + font-size: 1.3rem; font-weight: 600; - text-align: center; + color: #1a1a1a; letter-spacing: -0.02em; + text-align: center; + margin-bottom: 6px; } .login-card input { width: 100%; - padding: 12px 16px; + padding: 12px 14px; border-radius: 10px; - border: 1px solid rgba(255, 255, 255, 0.25); - background: rgba(255, 255, 255, 0.08); - color: #fff; + border: 1.5px solid #e0e0e0; + background: #fafafa; + color: #1a1a1a; font-size: 0.95rem; outline: none; transition: border-color 0.15s; + cursor: text; } .login-card input::placeholder { - color: rgba(255, 255, 255, 0.4); + color: #bbb; } .login-card input:focus { - border-color: rgba(255, 255, 255, 0.55); + border-color: #aaa; + background: white; } .login-card button { padding: 12px; border-radius: 10px; border: none; - background: rgba(255, 255, 255, 0.9); - color: #111; + background: #1a1a1a; + color: white; font-size: 0.95rem; font-weight: 600; cursor: pointer; transition: background 0.15s; + margin-top: 4px; } .login-card button:hover { - background: #fff; + background: #333; } .login-error { - font-size: 0.85rem; - color: #ff6b6b; + font-size: 0.83rem; + color: #e53e3e; text-align: center; min-height: 1.2em; } - -#dock-user { - font-size: 0.75rem; - color: rgba(255, 255, 255, 0.7); - display: flex; - align-items: center; - padding: 0 8px; - cursor: default; - user-select: none; -} diff --git a/ui/styles/window.css b/ui/styles/window.css index d077408..3347110 100644 --- a/ui/styles/window.css +++ b/ui/styles/window.css @@ -1,61 +1,113 @@ -.window { - position: absolute; - display: flex; - flex-direction: column; - background: #fff; - border-radius: 12px; - box-shadow: 0 24px 80px rgba(0,0,0,0.5); - overflow: hidden; - min-width: 300px; - min-height: 200px; -} +/* ── App icons ────────────────────────────────────────────────────────────── */ -.window-titlebar { +.canvas-element.app-icon { display: flex; + flex-direction: column; align-items: center; - justify-content: space-between; - padding: 0 12px; - height: 40px; - background: #f5f5f5; - border-bottom: 1px solid #e0e0e0; - cursor: move; - user-select: none; - flex-shrink: 0; -} - -.window-title { - font-size: 0.85rem; - font-weight: 600; - color: #333; + gap: 6px; + width: 72px; + cursor: pointer; } -.window-close { - background: none; - border: none; - cursor: pointer; - font-size: 0.8rem; - color: #999; - width: 24px; - height: 24px; - border-radius: 50%; +.app-icon .icon-glyph { + width: 56px; + height: 56px; + background: white; + border-radius: 14px; display: flex; align-items: center; justify-content: center; - transition: background 0.15s, color 0.15s; + font-size: 26px; + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.10); + transition: transform 0.12s ease, box-shadow 0.12s ease; +} + +.app-icon:hover .icon-glyph { + transform: scale(1.08); + box-shadow: 0 4px 16px rgba(0, 0, 0, 0.16); +} + +.app-icon .icon-label { + font-size: 11px; + font-weight: 500; + color: #444; + background: rgba(255, 255, 255, 0.72); + padding: 2px 8px; + border-radius: 10px; + backdrop-filter: blur(6px); + white-space: nowrap; + max-width: 88px; + overflow: hidden; + text-overflow: ellipsis; } -.window-close:hover { - background: #ff5f57; - color: #fff; +.app-icon .icon-dot { + width: 5px; + height: 5px; + background: #34c759; + border-radius: 50%; + margin-top: -3px; } -.window-content { - flex: 1; +/* ── Windows — no chrome, just content cards ──────────────────────────────── */ + +.canvas-element.window { + background: white; + border-radius: 16px; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.12); overflow: hidden; + min-width: 320px; + min-height: 240px; } -.window-content iframe { +.window .window-content { width: 100%; height: 100%; + overflow: auto; +} + +.window .loading { + padding: 24px; + color: #aaa; + font-size: 13px; +} + +/* ── Cursor response bubble ───────────────────────────────────────────────── */ + +.canvas-element.cursor-response { + background: white; + border-radius: 14px; + padding: 12px 16px; + box-shadow: 0 4px 20px rgba(0, 0, 0, 0.12); + max-width: 320px; + font-size: 14px; + color: #333; + line-height: 1.5; +} + +.cursor-response p { + margin: 0; +} + +.response-pills { + display: flex; + gap: 6px; + flex-wrap: wrap; + margin-top: 10px; +} + +.response-pills button { + background: #f0f0f0; border: none; + border-radius: 20px; + padding: 5px 14px; + font-size: 12px; + font-weight: 500; + color: #333; + cursor: pointer; + transition: background 0.12s; +} + +.response-pills button:hover { + background: #e0e0e0; } -- 2.43.0