Monorepo for Aesthetic.Computer aesthetic.computer
4
fork

Configure Feed

Select the types of activity you want to include in your feed.

aa: rewrite as chat.mjs inheritor (laer-klokken pattern)

chat: add optional submitHandler option to TextInput callback so pieces
inheriting chat.mjs can route typed text somewhere other than the
chat-system server.

aa: drop the hand-rolled scroll/input/render and instead create a Chat
client with connecting=false, push bridge SSE events into
client.system.messages, and let chat.boot/paint/act/sim render. Custom
slate theme. Tool calls and tool results render as log lines; final
assistant text plays the message SFX.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

+219 -470
+196 -458
system/public/aesthetic.computer/disks/aa.mjs
··· 1 1 // aa, 26.04.20 2 - // Phone-side chat with AA — your remote Claude on the macbook, 2 + // Phone-side conversation with AA — your remote Claude on the macbook, 3 3 // reached via help.aesthetic.computer. @jeffrey only. 4 + // 5 + // Built on chat.mjs (UI) but talks to a private SSE bridge instead of the 6 + // chat-system server. Pattern lifted from laer-klokken.mjs. 4 7 5 - const { floor, max, min, abs } = Math; 8 + import { Chat } from "../lib/chat.mjs"; 9 + import * as chat from "./chat.mjs"; 6 10 7 11 const ENDPOINT = "https://help.aesthetic.computer"; 8 12 9 - // ───────── state ───────── 13 + let client; 10 14 let token = null; 11 - let userHandle = null; 12 - let userSub = null; 13 - 14 15 let isAdmin = false; 15 - let authChecked = false; 16 - let authError = null; 17 - 18 - // log entries: { role: "user"|"assistant"|"tool"|"toolResult"|"system"|"error", text } 19 - let log = []; 20 16 let pending = false; 21 - let pendingText = ""; 22 17 let abortCtrl = null; 18 + let userHandleRef = null; 23 19 24 - let input = null; 20 + let msgCounter = 0; 21 + const nextId = () => `aa-${Date.now()}-${msgCounter++}`; 25 22 26 - // ───────── scroll (chat.mjs convention: 0 = newest at bottom, +y = into older) ───────── 27 - let scroll = 0; 28 - let totalScrollHeight = 0; 29 - let chatHeight = 0; 30 - let layoutDirty = true; 31 - let cachedLines = []; // [{ text, color }] 32 - let lastScreenW = 0; 23 + // Cool slate theme — distinct from chat's defaults so AA reads as a separate space. 24 + const THEME = { 25 + background: [12, 14, 22], 26 + chromeBg: [20, 22, 32], 27 + lines: [80, 90, 130, 96], 28 + scrollbar: [120, 180, 240], 29 + messageText: [232, 232, 240], 30 + messageBox: [220, 220, 235], 31 + log: [150, 200, 255], 32 + logHover: [255, 240, 120], 33 + handle: [120, 200, 255], 34 + handleHover: [255, 240, 120], 35 + url: [180, 220, 255], 36 + urlHover: [255, 240, 120], 37 + prompt: [200, 230, 255], 38 + promptContent: [180, 220, 255], 39 + promptHover: [255, 240, 120], 40 + promptContentHover:[255, 240, 120], 41 + painting: [200, 200, 230], 42 + paintingHover: [255, 240, 120], 43 + kidlisp: [230, 180, 220], 44 + kidlispHover: [255, 240, 120], 45 + timestamp: [110, 120, 150], 46 + timestampHover: [255, 240, 120], 47 + heart: [220, 200, 240], 48 + }; 33 49 34 - let scrollVelocity = 0; 35 - let isFlinging = false; 36 - let isDragging = false; 37 - let dragStartPos = null; 38 - const SCROLL_FRICTION = 0.92; 39 - const SCROLL_MIN_VELOCITY = 0.5; 40 - const BOUNCE_STIFFNESS = 0.15; 41 - const BOUNCE_DAMPING = 0.7; 42 - const MAX_OVERSCROLL = 60; 43 - const DRAG_THRESHOLD = 5; 50 + async function boot({ api, debug, send, hud, handle, user, authorize }) { 51 + client = new Chat(debug, send); 52 + // We feed messages in from the bridge — never connect to a chat server. 53 + // chat.mjs gates paint on `connecting`, so flip it false up front. 54 + client.system.connecting = false; 44 55 45 - // ───────── layout constants ───────── 46 - const PAD = 6; 47 - const LINE_H = 12; 48 - const HEADER_H = 18; 49 - const INPUT_H = 64; // TextInput preview frame 56 + hud.label("aa"); 57 + userHandleRef = handle; 50 58 51 - const PALETTE = { 52 - bg: [10, 10, 14], 53 - bgLight: [248, 246, 240], 54 - fg: [232, 232, 240], 55 - fgLight: [22, 22, 28], 56 - divider: [60, 60, 80, 120], 57 - user: [110, 200, 255], 58 - assistant: [220, 220, 230], 59 - tool: [240, 200, 90], 60 - toolResult:[150, 180, 110], 61 - system: [140, 140, 160], 62 - error: [255, 100, 100], 63 - pending: [180, 180, 90], 64 - }; 59 + if (!user?.sub) { 60 + pushSystem("log in first to talk to aa."); 61 + } else { 62 + try { 63 + token = await authorize(); 64 + if (!token) { 65 + pushSystem("no auth0 token — refresh and retry."); 66 + } else { 67 + const probe = await fetch(`${ENDPOINT}/api/session`, { 68 + headers: { Authorization: `Bearer ${token}` }, 69 + }); 70 + if (probe.status === 200) { 71 + isAdmin = true; 72 + const data = await probe.json(); 73 + pushSystem( 74 + data.sessionId 75 + ? `resuming session ${data.sessionId.slice(0, 8)}…` 76 + : "fresh session.", 77 + ); 78 + } else if (probe.status === 403) { 79 + pushSystem("not admin — this piece is @jeffrey-only."); 80 + } else { 81 + pushSystem(`bridge said ${probe.status}.`); 82 + } 83 + } 84 + } catch (err) { 85 + pushSystem(`bridge unreachable: ${err.message}`); 86 + } 87 + } 65 88 66 - function colorFor(role) { 67 - if (role === "user") return PALETTE.user; 68 - if (role === "assistant") return PALETTE.assistant; 69 - if (role === "tool") return PALETTE.tool; 70 - if (role === "toolResult") return PALETTE.toolResult; 71 - if (role === "error") return PALETTE.error; 72 - return PALETTE.system; 89 + await chat.boot(api, client.system, { 90 + submitHandler: async (text) => { 91 + if (!isAdmin) { pushSystem("not authorized."); return; } 92 + if (text === "/reset") return reset(); 93 + if (text === "/clear") { 94 + client.system.messages.length = 0; 95 + invalidate(); 96 + return; 97 + } 98 + if (text === "/cancel" || text === "/stop") { 99 + if (abortCtrl) abortCtrl.abort(); 100 + return; 101 + } 102 + await sendToBridge(text); 103 + }, 104 + }); 105 + } 106 + 107 + function paint($) { 108 + chat.paint($, { otherChat: client.system, theme: THEME }); 73 109 } 74 110 75 - function prefixFor(role) { 76 - if (role === "user") return "› "; 77 - if (role === "tool") return "→ "; 78 - if (role === "toolResult") return " "; 79 - if (role === "error") return "! "; 80 - if (role === "assistant") return " "; 81 - return "· "; 111 + function act($) { 112 + chat.act($, client.system); 82 113 } 83 114 84 - // ───────── boot ───────── 85 - async function boot({ api, ui, screen, cursor, hud, handle, user, params, send }) { 86 - cursor("native"); 87 - hud.labelBack(); 115 + function sim($) { 116 + chat.sim($); 117 + } 88 118 89 - userHandle = handle(); 90 - userSub = user?.sub || null; 119 + function leave() { 120 + if (abortCtrl) abortCtrl.abort(); 121 + } 91 122 92 - log.push({ role: "system", text: "AA — your remote claude on the macbook." }); 93 - layoutDirty = true; 123 + function meta() { 124 + return { 125 + title: "AA", 126 + desc: "Talk to your macbook's claude from anywhere. @jeffrey only.", 127 + }; 128 + } 94 129 95 - if (!userSub) { 96 - authError = "log in first"; 97 - authChecked = true; 98 - return; 99 - } 130 + // ───────── message plumbing ───────── 100 131 101 - try { 102 - token = await api.authorize(); 103 - if (!token) { 104 - authError = "no token"; 105 - authChecked = true; 106 - return; 107 - } 108 - const probe = await fetch(`${ENDPOINT}/api/session`, { 109 - headers: { Authorization: `Bearer ${token}` }, 110 - }); 111 - if (probe.status === 200) { 112 - const data = await probe.json(); 113 - isAdmin = true; 114 - authChecked = true; 115 - log.push({ 116 - role: "system", 117 - text: data.sessionId 118 - ? `resuming session ${data.sessionId.slice(0, 8)}…` 119 - : "fresh session.", 120 - }); 121 - layoutDirty = true; 122 - } else if (probe.status === 403) { 123 - authError = "not admin (this piece is @jeffrey-only)"; 124 - authChecked = true; 125 - } else { 126 - authError = `bridge said ${probe.status}`; 127 - authChecked = true; 128 - } 129 - } catch (err) { 130 - authError = `bridge unreachable: ${err.message}`; 131 - authChecked = true; 132 - } 132 + function pushMessage(from, text, { sound = false } = {}) { 133 + const msg = { from, text, id: nextId(), sub: from }; 134 + client.system.messages.push(msg); 135 + if (client.system.messages.length > 500) client.system.messages.shift(); 136 + // chat.mjs's receiver hook sets `messagesNeedLayout = true` whenever 137 + // extra.layoutChanged is set; type === "message" also plays the SFX. 138 + client.system.receiver?.( 139 + msg.id, 140 + sound ? "message" : "layout-only", 141 + sound ? msg : null, 142 + { layoutChanged: true }, 143 + ); 144 + return msg; 145 + } 133 146 134 - if (!isAdmin) return; 147 + function pushSystem(text) { 148 + return pushMessage("log", text); 149 + } 135 150 136 - input = new ui.TextInput( 137 - api, 138 - "ask aa…", 139 - async (text) => { 140 - text = (text || "").replace(/\s+$/, ""); 141 - if (!text) return; 142 - if (text === "/reset") { 143 - await reset(); 144 - } else if (text === "/clear") { 145 - log = [{ role: "system", text: "cleared." }]; 146 - layoutDirty = true; 147 - } else if (text === "/cancel" || text === "/stop") { 148 - if (abortCtrl) abortCtrl.abort(); 149 - } else { 150 - await send(text); 151 - } 152 - input.text = ""; 153 - input.showBlink = false; 154 - input.mute = true; 155 - send({ type: "keyboard:close" }); 156 - }, 157 - { 158 - scheme: { 159 - text: 255, 160 - background: [0, 180], 161 - block: 255, 162 - highlight: 0, 163 - guideline: [255, 128], 164 - }, 165 - hideGutter: false, 166 - closeOnEmptyEnter: true, 167 - }, 168 - ); 151 + function invalidate() { 152 + client.system.receiver?.(nextId(), "layout-only", null, { layoutChanged: true }); 169 153 } 170 154 171 - // ───────── network ───────── 172 155 async function reset() { 173 156 try { 174 157 await fetch(`${ENDPOINT}/api/reset`, { 175 158 method: "POST", 176 159 headers: { Authorization: `Bearer ${token}` }, 177 160 }); 178 - pushLog({ role: "system", text: "session reset." }); 161 + pushSystem("session reset."); 179 162 } catch (err) { 180 - pushLog({ role: "error", text: `reset failed: ${err.message}` }); 163 + pushSystem(`reset failed: ${err.message}`); 181 164 } 182 165 } 183 166 184 - async function send(text) { 185 - if (pending) { 186 - pushLog({ role: "system", text: "still thinking — type /cancel to stop." }); 187 - return; 188 - } 189 - pushLog({ role: "user", text }); 167 + async function sendToBridge(text) { 168 + if (pending) { pushSystem("still thinking — type /cancel to stop."); return; } 169 + 170 + const me = userHandleRef?.() || "@jeffrey"; 171 + pushMessage(me, text); 190 172 pending = true; 191 - pendingText = ""; 192 - scroll = 0; // jump to bottom on user turn 193 - scrollVelocity = 0; 194 - isFlinging = false; 195 173 abortCtrl = new AbortController(); 174 + 175 + let pendingText = ""; 196 176 197 177 try { 198 178 const res = await fetch(`${ENDPOINT}/api/chat`, { ··· 205 185 signal: abortCtrl.signal, 206 186 }); 207 187 if (!res.ok) { 208 - const errText = await res.text().catch(() => `${res.status}`); 209 - pushLog({ role: "error", text: `${res.status}: ${errText.slice(0, 200)}` }); 188 + const body = await res.text().catch(() => `${res.status}`); 189 + pushSystem(`! ${res.status}: ${body.slice(0, 200)}`); 210 190 return; 211 191 } 212 - await consumeSSE(res); 213 - } catch (err) { 214 - if (err.name === "AbortError") { 215 - pushLog({ role: "system", text: "cancelled." }); 216 - } else { 217 - pushLog({ role: "error", text: err.message }); 192 + 193 + const reader = res.body.getReader(); 194 + const decoder = new TextDecoder(); 195 + let buf = ""; 196 + while (true) { 197 + const { done, value } = await reader.read(); 198 + if (done) break; 199 + buf += decoder.decode(value, { stream: true }); 200 + let idx; 201 + while ((idx = buf.indexOf("\n\n")) !== -1) { 202 + const block = buf.slice(0, idx); 203 + buf = buf.slice(idx + 2); 204 + const evt = parseSSE(block); 205 + if (!evt) continue; 206 + 207 + if (evt.event === "claude") { 208 + const ev = evt.data; 209 + if (ev.type === "assistant" && ev.message?.content) { 210 + for (const b of ev.message.content) { 211 + if (b.type === "text" && b.text) { 212 + pendingText += b.text; 213 + } else if (b.type === "tool_use") { 214 + pushMessage("log", `→ ${b.name} ${summarizeInput(b.input)}`); 215 + } 216 + } 217 + } else if (ev.type === "user" && ev.message?.content) { 218 + for (const b of ev.message.content) { 219 + if (b.type === "tool_result") { 220 + const t = stringifyResult(b.content); 221 + if (t) pushMessage("log", truncate(t, 600)); 222 + } 223 + } 224 + } else if (ev.type === "result" && ev.result) { 225 + pendingText = ev.result; 226 + } 227 + } else if (evt.event === "stderr") { 228 + const t = (evt.data.text || "").trim(); 229 + if (t && !t.startsWith("Warning:")) pushMessage("log", t); 230 + } else if (evt.event === "error") { 231 + pushMessage("log", `! ${evt.data.message || "error"}`); 232 + } else if (evt.event === "done") { 233 + if (pendingText) { 234 + pushMessage("@aa", pendingText, { sound: true }); 235 + pendingText = ""; 236 + } 237 + } 238 + } 218 239 } 240 + } catch (err) { 241 + if (err.name === "AbortError") pushSystem("cancelled."); 242 + else pushSystem(`error: ${err.message}`); 219 243 } finally { 220 244 pending = false; 221 - pendingText = ""; 222 245 abortCtrl = null; 223 - layoutDirty = true; 224 246 } 225 247 } 226 248 227 - async function consumeSSE(res) { 228 - const reader = res.body.getReader(); 229 - const decoder = new TextDecoder(); 230 - let buf = ""; 231 - 232 - while (true) { 233 - const { done, value } = await reader.read(); 234 - if (done) break; 235 - buf += decoder.decode(value, { stream: true }); 236 - let idx; 237 - while ((idx = buf.indexOf("\n\n")) !== -1) { 238 - const block = buf.slice(0, idx); 239 - buf = buf.slice(idx + 2); 240 - handleSSEBlock(block); 241 - } 242 - } 243 - } 249 + // ───────── small helpers ───────── 244 250 245 - function handleSSEBlock(block) { 251 + function parseSSE(block) { 246 252 let event = "message"; 247 253 let data = ""; 248 254 for (const line of block.split("\n")) { 249 255 if (line.startsWith("event:")) event = line.slice(6).trim(); 250 256 else if (line.startsWith("data:")) data += line.slice(5).trim(); 251 257 } 252 - if (!data) return; 253 - let payload; 254 - try { payload = JSON.parse(data); } catch { return; } 255 - 256 - if (event === "claude") handleClaudeEvent(payload); 257 - else if (event === "stderr") { 258 - const t = (payload.text || "").trim(); 259 - if (t && !t.startsWith("Warning:")) pushLog({ role: "system", text: t }); 260 - } else if (event === "error") { 261 - pushLog({ role: "error", text: payload.message || "error" }); 262 - } else if (event === "done") { 263 - if (pendingText) { 264 - pushLog({ role: "assistant", text: pendingText }); 265 - pendingText = ""; 266 - } 267 - } 268 - layoutDirty = true; 269 - } 270 - 271 - function handleClaudeEvent(ev) { 272 - if (ev.type === "assistant" && ev.message?.content) { 273 - for (const block of ev.message.content) { 274 - if (block.type === "text" && block.text) { 275 - pendingText += block.text; 276 - } else if (block.type === "tool_use") { 277 - const summary = `${block.name}${block.input ? " " + summarizeToolInput(block.input) : ""}`; 278 - pushLog({ role: "tool", text: summary }); 279 - } 280 - } 281 - } else if (ev.type === "user" && ev.message?.content) { 282 - // Surface tool results from the user-role echo events 283 - for (const block of ev.message.content) { 284 - if (block.type === "tool_result") { 285 - const out = stringifyToolResult(block.content); 286 - if (out) pushLog({ role: "toolResult", text: truncate(out, 600) }); 287 - } 288 - } 289 - } else if (ev.type === "result" && ev.result) { 290 - pendingText = ev.result; 291 - } 258 + if (!data) return null; 259 + try { return { event, data: JSON.parse(data) }; } catch { return null; } 292 260 } 293 261 294 - function summarizeToolInput(input) { 262 + function summarizeInput(input) { 263 + if (!input) return ""; 295 264 if (typeof input === "string") return truncate(input, 80); 296 265 const k = input.command || input.file_path || input.path || input.pattern || input.query || input.url; 297 266 if (k) return truncate(String(k), 80); 298 267 return truncate(JSON.stringify(input), 80); 299 268 } 300 269 301 - function stringifyToolResult(content) { 270 + function stringifyResult(content) { 302 271 if (!content) return ""; 303 272 if (typeof content === "string") return content; 304 273 if (Array.isArray(content)) { 305 - return content 306 - .map((c) => (typeof c === "string" ? c : c?.text || "")) 307 - .filter(Boolean) 308 - .join("\n"); 274 + return content.map((c) => (typeof c === "string" ? c : c?.text || "")).filter(Boolean).join("\n"); 309 275 } 310 276 return String(content); 311 277 } ··· 315 281 return s.length > n ? s.slice(0, n - 1) + "…" : s; 316 282 } 317 283 318 - function pushLog(entry) { 319 - log.push(entry); 320 - layoutDirty = true; 321 - } 322 - 323 - // ───────── layout ───────── 324 - function rewrap(charW, innerW) { 325 - const maxChars = max(8, floor(innerW / charW)); 326 - cachedLines = []; 327 - for (const entry of log) { 328 - const color = colorFor(entry.role); 329 - const prefix = prefixFor(entry.role); 330 - const lines = wrapText(prefix + entry.text, maxChars); 331 - for (const ln of lines) cachedLines.push({ text: ln, color }); 332 - cachedLines.push({ text: "", color }); // gutter 333 - } 334 - } 335 - 336 - function wrapText(text, maxChars) { 337 - const out = []; 338 - for (const para of String(text).split("\n")) { 339 - if (!para) { out.push(""); continue; } 340 - let i = 0; 341 - while (i < para.length) { 342 - out.push(para.slice(i, i + maxChars)); 343 - i += maxChars; 344 - } 345 - } 346 - return out; 347 - } 348 - 349 - // ───────── paint ───────── 350 - function paint($) { 351 - const { wipe, ink, write, screen, dark, mask, unmask, needsPaint } = $; 352 - const w = screen.width; 353 - const h = screen.height; 354 - 355 - wipe(...(dark ? PALETTE.bg : PALETTE.bgLight)); 356 - 357 - // Header 358 - const fg = dark ? PALETTE.fg : PALETTE.fgLight; 359 - ink(...fg).write("AA", { x: PAD, y: PAD }); 360 - const status = pending ? "thinking…" : (isAdmin ? "ready" : (authError || "checking…")); 361 - const statusColor = pending ? PALETTE.pending : (isAdmin ? PALETTE.user : PALETTE.error); 362 - ink(...statusColor).write(status, { x: PAD + 32, y: PAD }); 363 - 364 - if (userHandle) { 365 - ink(...PALETTE.system).write(userHandle, { x: w - userHandle.length * 6 - PAD, y: PAD }); 366 - } 367 - ink(...PALETTE.divider).box(0, HEADER_H - 2, w, 1, "fill"); 368 - 369 - // Scroll region bounds 370 - const scrollTop = HEADER_H; 371 - const scrollBottom = h - INPUT_H - PAD; 372 - chatHeight = scrollBottom - scrollTop; 373 - const charW = 6; 374 - const innerW = w - PAD * 2; 375 - 376 - // Re-layout when log/screen changes 377 - if (layoutDirty || w !== lastScreenW) { 378 - rewrap(charW, innerW); 379 - lastScreenW = w; 380 - layoutDirty = false; 381 - } 382 - 383 - // Append live partial assistant text (not cached — changes each frame) 384 - let liveLines = []; 385 - if (pending) { 386 - const tail = pendingText 387 - ? wrapText(" " + pendingText + "▌", floor(innerW / charW)) 388 - : [" …"]; 389 - liveLines = tail.map((t) => ({ 390 - text: t, 391 - color: pendingText ? PALETTE.assistant : PALETTE.pending, 392 - })); 393 - } 394 - 395 - const allLines = liveLines.length ? cachedLines.concat(liveLines) : cachedLines; 396 - totalScrollHeight = allLines.length * LINE_H; 397 - 398 - // Inertial scroll physics 399 - if (isFlinging) { 400 - const maxS = max(0, totalScrollHeight - chatHeight + 5); 401 - const outOfBounds = scroll < 0 || scroll > maxS; 402 - if (outOfBounds) { 403 - const target = scroll < 0 ? 0 : maxS; 404 - const displacement = scroll - target; 405 - scrollVelocity -= displacement * BOUNCE_STIFFNESS; 406 - scrollVelocity *= BOUNCE_DAMPING; 407 - scroll += scrollVelocity; 408 - if (abs(displacement) < 0.5 && abs(scrollVelocity) < SCROLL_MIN_VELOCITY) { 409 - scroll = target; 410 - scrollVelocity = 0; 411 - isFlinging = false; 412 - } 413 - } else { 414 - scrollVelocity *= SCROLL_FRICTION; 415 - scroll += scrollVelocity; 416 - if (scroll < 0 || scroll > maxS) scrollVelocity *= 0.5; 417 - if (abs(scrollVelocity) < SCROLL_MIN_VELOCITY) { 418 - scrollVelocity = 0; 419 - isFlinging = false; 420 - } 421 - } 422 - needsPaint(); 423 - } 424 - 425 - // Mask scroll region so messages don't bleed into header or input 426 - mask({ x: 0, y: scrollTop, width: w, height: chatHeight }); 427 - 428 - // Bottom-anchored render: bottom of last line sits at scrollBottom + scroll 429 - // (positive scroll → content slides down, revealing older above) 430 - const startY = scrollBottom - totalScrollHeight + scroll; 431 - for (let i = 0; i < allLines.length; i++) { 432 - const y = startY + i * LINE_H; 433 - if (y < scrollTop - LINE_H || y > scrollBottom) continue; 434 - const ln = allLines[i]; 435 - if (!ln.text) continue; 436 - ink(...ln.color).write(ln.text, { x: PAD, y }); 437 - } 438 - 439 - // Scrollbar (only if content overflows) 440 - const maxS = max(0, totalScrollHeight - chatHeight); 441 - if (maxS > 0) { 442 - const segH = max(8, (chatHeight / totalScrollHeight) * chatHeight); 443 - // scroll=0 → bar at bottom, scroll=maxS → bar at top 444 - const barY = scrollTop + (1 - scroll / maxS) * (chatHeight - segH); 445 - ink(...PALETTE.system, 140).box(w - 3, barY, 2, segH); 446 - } 447 - 448 - unmask(); 449 - 450 - // Input frame 451 - if (input) { 452 - const frameY = h - INPUT_H; 453 - ink(...PALETTE.divider).box(0, frameY, w, 1, "fill"); 454 - input.paint($, false, { x: 0, y: frameY + 1, width: w, height: INPUT_H - 1 }); 455 - } else if (authChecked && !isAdmin) { 456 - const msg = authError || "not authorized"; 457 - ink(...PALETTE.error).write(msg, { center: "x", y: h - 16, screen }); 458 - } 459 - 460 - if (pending) needsPaint(); 461 - } 462 - 463 - // ───────── act ───────── 464 - function act({ api, event: e, screen }) { 465 - // Touch begin: capture for drag-vs-tap discrimination 466 - if (e.is("touch")) { 467 - dragStartPos = { x: e.x, y: e.y }; 468 - isDragging = false; 469 - isFlinging = false; 470 - scrollVelocity = 0; 471 - } 472 - 473 - // Drag in scrollback area → scroll 474 - if (e.is("draw")) { 475 - const inInputFrame = e.y >= screen.height - INPUT_H; 476 - if (!inInputFrame) { 477 - if (dragStartPos && !isDragging) { 478 - const dx = abs(e.x - dragStartPos.x); 479 - const dy = abs(e.y - dragStartPos.y); 480 - if (dx > DRAG_THRESHOLD || dy > DRAG_THRESHOLD) isDragging = true; 481 - } 482 - if (isDragging) { 483 - // Drag down → see older (scroll +); drag up → see newer 484 - const dy = e.delta?.y ?? 0; 485 - scroll += dy; 486 - scrollVelocity = dy; 487 - boundScrollSoft(); 488 - } 489 - } 490 - } 491 - 492 - // Lift: maybe start fling 493 - if (e.is("lift")) { 494 - if (isDragging && abs(scrollVelocity) > SCROLL_MIN_VELOCITY) { 495 - isFlinging = true; 496 - } else if (scroll < 0 || scroll > maxScroll()) { 497 - // Released into overscroll → bounce back 498 - isFlinging = true; 499 - } 500 - isDragging = false; 501 - dragStartPos = null; 502 - } 503 - 504 - // Wheel 505 - if (e.is("scroll")) { 506 - isFlinging = false; 507 - scroll += e.y; // positive wheel down → reveal older 508 - boundScroll(); 509 - } 510 - 511 - // Keyboard scroll 512 - if (e.is("keyboard:down:pageup")) { scroll += LINE_H * 5; boundScroll(); } 513 - if (e.is("keyboard:down:pagedown")) { scroll -= LINE_H * 5; boundScroll(); } 514 - if (e.is("keyboard:down:home")) { scroll = maxScroll(); boundScroll(); } 515 - if (e.is("keyboard:down:end")) { scroll = 0; } 516 - 517 - // Reframe → re-layout 518 - if (e.is("reframed")) layoutDirty = true; 519 - 520 - if (input) input.act(api); 521 - } 522 - 523 - function boundScroll() { 524 - if (scroll < 0) scroll = 0; 525 - const m = maxScroll(); 526 - if (scroll > m) scroll = m; 527 - } 528 - 529 - function boundScrollSoft() { 530 - const m = maxScroll(); 531 - if (scroll < -MAX_OVERSCROLL) scroll = -MAX_OVERSCROLL; 532 - if (scroll > m + MAX_OVERSCROLL) scroll = m + MAX_OVERSCROLL; 533 - } 534 - 535 - function maxScroll() { 536 - return max(0, totalScrollHeight - chatHeight + 5); 537 - } 538 - 539 - function meta() { 540 - return { 541 - title: "AA", 542 - desc: "Talk to your macbook's claude from anywhere. @jeffrey only.", 543 - }; 544 - } 545 - 546 - export { boot, paint, act, meta }; 284 + export { boot, paint, act, sim, leave, meta };
+23 -12
system/public/aesthetic.computer/disks/chat.mjs
··· 542 542 api, 543 543 "...", // This empty call-to-action prompt will not be shown. 544 544 async (text) => { 545 - const currentHandle = handle(); 545 + text = text.replace(/\s+$/, ""); // Trim trailing whitespace. 546 546 547 - if (!currentHandle) { 548 - notice("NO HANDLE", ["red", "yellow"]); 547 + // Pieces inheriting chat.mjs (e.g. aa.mjs) may pass a custom submit 548 + // handler so they can route the typed text somewhere other than the 549 + // chat-system server. If provided, it owns the send. 550 + if (options?.submitHandler) { 551 + try { 552 + await options.submitHandler(text, { token, sub: user?.sub, font: userSelectedFont }); 553 + } catch (err) { 554 + console.error("submitHandler failed:", err); 555 + } 549 556 } else { 550 - text = text.replace(/\s+$/, ""); // Trim trailing whitespace. 551 - // Send the chat message with user's selected font 552 - client.server.send(`chat:message`, { 553 - text, 554 - token, 555 - sub: user.sub, 556 - font: userSelectedFont, // 🔤 Include selected font 557 - }); 558 - notice("SENT"); 557 + const currentHandle = handle(); 558 + if (!currentHandle) { 559 + notice("NO HANDLE", ["red", "yellow"]); 560 + } else { 561 + // Send the chat message with user's selected font 562 + client.server.send(`chat:message`, { 563 + text, 564 + token, 565 + sub: user.sub, 566 + font: userSelectedFont, // 🔤 Include selected font 567 + }); 568 + notice("SENT"); 569 + } 559 570 } 560 571 561 572 // Clear text, hide cursor block, and close keyboard after sending message.