From 67f0ddbee1f87c98610c506ea5dc6e5ac4e72348 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Thu, 17 Sep 2026 20:21:03 -0700 Subject: [PATCH] Simplify Aesel mobile chrome, wrap notebook prose, and manage braincell models --- apple/aesel/Info.plist | 4 +- apple/aesel/README.md | 7 +- apple/aesel/Sources/AeselApp.swift | 3 + apple/aesel/Sources/AeselHomeView.swift | 3 +- apple/aesel/Sources/AeselModelPicker.swift | 25 +-- apple/aesel/Sources/ContentView.swift | 189 +++++++++----------- apple/aesel/Sources/Session.swift | 13 ++ easel/README.md | 18 +- easel/desktop/credit-label.cjs | 6 +- easel/desktop/donkey.css | 4 + easel/desktop/index.html | 2 +- easel/desktop/notebook-layout.js | 70 ++++++++ easel/desktop/package-lock.json | 4 +- easel/desktop/package.json | 2 +- easel/desktop/renderer.js | 9 +- easel/desktop/scripts/linux-download.html | 4 +- easel/desktop/transcript.js | 4 +- easel/package.json | 2 +- easel/phone/credits.mjs | 4 +- easel/phone/session.mjs | 18 +- easel/src/provider-picker.mjs | 3 +- easel/src/tui.mjs | 6 +- easel/test/notebook-layout.electron.cjs | 198 +++++++++++++++++++++ easel/test/phone-credits.test.mjs | 11 ++ easel/test/phone-session.test.mjs | 33 ++-- easel/test/provider-picker.test.mjs | 9 +- system/netlify/functions/easel-credits.mjs | 10 +- system/public/aesel/index.html | 4 +- system/tests/easel-credits.test.mjs | 51 ++++++ 29 files changed, 519 insertions(+), 197 deletions(-) create mode 100644 easel/desktop/notebook-layout.js create mode 100644 easel/test/notebook-layout.electron.cjs create mode 100644 system/tests/easel-credits.test.mjs diff --git a/apple/aesel/Info.plist b/apple/aesel/Info.plist index f38ec4b9f5..2618961c60 100644 --- a/apple/aesel/Info.plist +++ b/apple/aesel/Info.plist @@ -15,9 +15,9 @@ CFBundlePackageType APPL CFBundleShortVersionString - 0.7.4 + 0.7.5 CFBundleVersion - 2 + 1 UIAppFonts ac-easel-unifont.ttf UILaunchScreen diff --git a/apple/aesel/README.md b/apple/aesel/README.md index 3500ec944b..12afd35df3 100644 --- a/apple/aesel/README.md +++ b/apple/aesel/README.md @@ -68,6 +68,9 @@ source. Returning home leaves the current preview alive. Picture, Sound, Paper and Game Boy are desktop-only until their actual render and tool backends are ported; their chooser rows do not create pretend sessions. -The model label distinguishes the requested model from an identifier reported by -the inference stream. Account character colors come from AC's saved palette, +The editor keeps only the piece title, preview, notebook and message input visible. +The version button opens account, status, balance and thread controls. Braincell +models are automatic, including resumed threads with old manual choices. Dollar +values come from the allowance endpoint and distinguish free from purchased credit. +Account character colors come from AC's saved palette, including the `@` character, with the same fallback palette as desktop. diff --git a/apple/aesel/Sources/AeselApp.swift b/apple/aesel/Sources/AeselApp.swift index 08c6db1404..4b91a80f93 100644 --- a/apple/aesel/Sources/AeselApp.swift +++ b/apple/aesel/Sources/AeselApp.swift @@ -33,6 +33,9 @@ struct AeselApp: App { session.model = "openai/gpt-5.6-luna" session.status = "ready" session.braincells = 482000 + session.braincellDollars = 2.41 + session.freeDollars = 1 + session.purchasedDollars = 1.41 session.append(.you, "Make a little orange circle.") session.append(.ac, "The **orange** circle follows your pointer.\n\nTry a radius of `24` or a blue background.\n\n```js\nconst radius = 24;\nwipe(\"blue\");\n```\n\n[Open Aesthetic Computer](https://aesthetic.computer)") return diff --git a/apple/aesel/Sources/AeselHomeView.swift b/apple/aesel/Sources/AeselHomeView.swift index c3f58a3009..9a1dc62ba3 100644 --- a/apple/aesel/Sources/AeselHomeView.swift +++ b/apple/aesel/Sources/AeselHomeView.swift @@ -100,8 +100,7 @@ struct AeselHomeView: View { Spacer(minLength: 20) AeselDonkey(busy: session.busy, failed: session.health == .failed) .padding(.bottom, 10) - Rectangle().fill(Color(rgb: 0x9e7548)).frame(height: 1) - AeselWood().frame(height: 40).ignoresSafeArea(edges: .bottom) + } .buttonStyle(.plain) .foregroundStyle(Paint.ink) diff --git a/apple/aesel/Sources/AeselModelPicker.swift b/apple/aesel/Sources/AeselModelPicker.swift index a7675b0470..176ee96c00 100644 --- a/apple/aesel/Sources/AeselModelPicker.swift +++ b/apple/aesel/Sources/AeselModelPicker.swift @@ -1,31 +1,12 @@ import SwiftUI -/// Select the saved thread's model; the session owns validation and persistence. +/// Braincell routing is managed by AC; old callers also get a read-only label. struct AeselModelPicker: View { let session: Session let host: SessionHost var body: some View { - Menu { - ForEach(session.modelChoices) { choice in - Button { - host.setModel(model: choice.id) - } label: { - Label(choice.title + (choice.premium ? " · premium" : "") + "\n" + choice.id, - systemImage: choice.id == session.model ? "checkmark" : choice.premium ? "sparkles" : "circle") - } - } - } label: { - HStack(alignment: .firstTextBaseline, spacing: 5) { - Text(session.reportedModel.isEmpty ? session.model + " · requested" : session.reportedModel) - .lineLimit(2) - Text("▾") - } - .font(Paint.font(16)) - .foregroundStyle(Paint.dim) - } - .disabled(session.busy || session.modelChoices.isEmpty) - .accessibilityLabel("Choose model") - .accessibilityValue(session.model) + Text("Braincells · automatic model") + .font(Paint.font(16)).foregroundStyle(Paint.dim) } } diff --git a/apple/aesel/Sources/ContentView.swift b/apple/aesel/Sources/ContentView.swift index 41d2d042a2..e52c25f1b9 100644 --- a/apple/aesel/Sources/ContentView.swift +++ b/apple/aesel/Sources/ContentView.swift @@ -7,6 +7,7 @@ struct ContentView: View { @State private var draft = "" @State private var showHome = true @State private var showHelp = false + @State private var showSettings = false @State private var expandedPreview = false @State private var previewHidden = false @FocusState private var writing: Bool @@ -33,10 +34,14 @@ struct ContentView: View { .onChange(of: session.currentSessionID) { draft = ""; previewHidden = false; expandedPreview = false } .onAppear { #if DEBUG - if ProcessInfo.processInfo.environment["AESEL_NOTEBOOK_PREVIEW"] == "1" { showHome = false } + if ProcessInfo.processInfo.environment["AESEL_NOTEBOOK_PREVIEW"] == "1" { + showHome = false + showSettings = ProcessInfo.processInfo.environment["AESEL_SETTINGS_PREVIEW"] == "1" + } #endif } .sheet(isPresented: $showHelp) { help } + .sheet(isPresented: $showSettings) { settings } .sheet(isPresented: Binding(get: { session.showSignIn }, set: { session.showSignIn = $0 })) { VStack(spacing: 0) { HStack { @@ -69,24 +74,20 @@ struct ContentView: View { } private var title: some View { - HStack(alignment: .firstTextBaseline, spacing: 12) { + HStack(spacing: 12) { Button { - writing = false - expandedPreview = false - showHome = true - } label: { AeselWordmark(text: "Aesel") } - .buttonStyle(.plain) - .accessibilityLabel("aesel title screen") - Spacer(minLength: 0) - if session.signedIn { - AeselHandle(handle: session.handle, colors: session.handleColors) - } else { - Button("/login") { host.signIn() }.foregroundStyle(Paint.dim) - .accessibilityLabel("Sign in to Aesthetic Computer") + if let url = session.shareURL { openURL(url) } + } label: { + Text(session.route.isEmpty ? "new piece" : session.route) + .font(.system(size: 21, weight: .semibold, design: .rounded)) + .lineLimit(1).truncationMode(.middle) } + .buttonStyle(.plain) + .disabled(session.shareURL == nil) + .accessibilityLabel("Open piece in browser") + Spacer(minLength: 0) } .padding(.horizontal, 16).padding(.vertical, 8) - .overlay(alignment: .bottom) { Rectangle().fill(Paint.rule).frame(height: 1).padding(.horizontal, 16) } } private var preview: some View { @@ -111,27 +112,6 @@ struct ContentView: View { private var transcript: some View { VStack(spacing: 0) { - HStack(spacing: 12) { - Button { - if let url = session.shareURL { openURL(url) } - } label: { - Text(session.route.isEmpty ? "new piece" : session.route) - .font(.system(size: 21, weight: .semibold, design: .rounded)) - .lineLimit(1).truncationMode(.middle) - } - .buttonStyle(.plain) - .disabled(session.shareURL == nil) - .accessibilityLabel("Open piece in browser") - Spacer(minLength: 0) - if session.previewURL != nil { - Button { previewHidden.toggle(); writing = false } label: { - Image(systemName: previewHidden ? "eye.slash" : "eye") - .frame(width: 44, height: 44) - } - .accessibilityLabel(previewHidden ? "Show preview" : "Hide preview") - } - } - .padding(.horizontal, 16) AeselNotebook(session: session) { openURL($0) } if session.fatal != nil { Button("/retry") { host.restore() }.padding(8) @@ -140,81 +120,85 @@ struct ContentView: View { .frame(maxHeight: .infinity) } + private var appVersion: String { + Bundle.main.infoDictionary?["CFBundleShortVersionString"] as? String ?? "—" + } + private var shelf: some View { - VStack(spacing: 0) { - HStack(alignment: .bottom, spacing: 0) { - VStack(alignment: .leading, spacing: 5) { - HStack(spacing: 8) { - Text(session.status.uppercased()).foregroundStyle(session.busy ? Paint.ac : session.health == .failed ? Paint.bad : Paint.edit) - } - .font(Paint.font(18)) - if !session.model.isEmpty { - AeselModelPicker(session: session, host: host) - } + HStack(alignment: .bottom, spacing: 8) { + TextField("make something…", text: $draft, axis: .vertical) + .font(Paint.font(22)).lineLimit(1...4).textFieldStyle(.plain) + .tint(Paint.you).focused($writing).submitLabel(.send) + .onSubmit { send() }.autocorrectionDisabled() + .padding(.vertical, 10) + Button { session.busy ? host.stop() : send() } label: { + Text(session.busy ? "■" : "↵") + .font(Paint.font(28)).frame(width: 44, height: 44) + .foregroundStyle(Paint.you) + } + .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) + .accessibilityLabel(session.busy ? "Stop" : "Send") + Button { writing = false; showSettings = true; host.refreshCredits() } label: { + Text("v\(appVersion)").font(Paint.font(16)).foregroundStyle(Paint.dim) + .frame(minHeight: 44) + } + .accessibilityLabel("Version \(appVersion). Settings") + } + .buttonStyle(.plain).padding(.leading, 16).padding(.trailing, 8) + } + + private var settings: some View { + NavigationStack { + List { + Section { if session.signedIn { + AeselHandle(handle: session.handle, colors: session.handleColors) Button { host.refreshCredits() } label: { - HStack(spacing: 5) { - Image(systemName: "brain") + VStack(alignment: .leading, spacing: 4) { + if let dollars = session.braincellDollars { + Text(dollars, format: .currency(code: "USD")) + .font(.system(size: 28, weight: .medium, design: .rounded)) + } if let balance = session.braincells { Text("\(balance.formatted(.number.precision(.fractionLength(0)))) braincells") - } else { Text("— braincells") } + } else { Text(session.creditsStatus) } + if let free = session.freeDollars, let paid = session.purchasedDollars { + Text("\(free.formatted(.currency(code: "USD"))) daily · \(paid.formatted(.currency(code: "USD"))) purchased") + .font(.footnote).foregroundStyle(Paint.dim) + } } - .font(.system(size: 14, weight: .medium, design: .rounded)) - .foregroundStyle(Paint.dim) } - .buttonStyle(.plain) - .accessibilityLabel(session.braincells.map { "\(Int($0)) braincells. Refresh balance" } ?? session.creditsStatus) + .buttonStyle(.plain).accessibilityHint("Refresh balance") + } else { + Button("Sign in") { showSettings = false; host.signIn() } } + Text("Braincells · automatic model") + Text(session.status).foregroundStyle(Paint.dim) + } footer: { + Text("Dollar equivalent at $5 per million braincells. Daily allowance resets at midnight UTC.") } - .padding(.bottom, 10) - Spacer(minLength: 0) - AeselDonkey(busy: session.busy, failed: session.health == .failed) - .frame(height: 86, alignment: .bottom) - } - .padding(.horizontal, 16) - - VStack(spacing: 0) { - HStack(alignment: .center, spacing: 9) { - Text("›").foregroundStyle(Paint.you) - TextField("make something…", text: $draft, axis: .vertical) - .font(Paint.font(22)) - .lineLimit(1...4) - .textFieldStyle(.plain) - .tint(Paint.you) - .focused($writing) - .submitLabel(.send) - .onSubmit { send() } - .autocorrectionDisabled() - Button { session.busy ? host.stop() : send() } label: { - Text(session.busy ? "■" : "↵") - .font(Paint.font(28)) - .frame(width: 44, height: 44) - .foregroundStyle(Paint.you) + Section { + Button("New piece / threads") { showSettings = false; showHome = true } + Button("Publish") { host.publish() } + .disabled(!session.signedIn || session.busy) + Button("Open in browser") { if let url = session.shareURL { openURL(url) } } + .disabled(session.shareURL == nil) + if session.previewURL != nil { + Toggle("Show preview", isOn: Binding(get: { !previewHidden }, set: { previewHidden = !$0 })) } - .disabled(!session.busy && draft.trimmingCharacters(in: .whitespacesAndNewlines).isEmpty) - .accessibilityLabel(session.busy ? "Stop" : "Send") - } - .padding(.leading, 12) - .background(Paint.deep.opacity(0.8)) - .overlay { Rectangle().stroke(Paint.rule, lineWidth: 1) } - ScrollView(.horizontal, showsIndicators: false) { - HStack(spacing: 20) { - Button("/new") { writing = false; showHome = true } - Button("/publish") { host.publish() }.disabled(!session.signedIn || session.busy) - Button("/open") { if let url = session.shareURL { openURL(url) } } - .disabled(session.shareURL == nil) - Button(session.signedIn ? "/logout" : "/login") { session.signedIn ? host.signOut() : host.signIn() } - Button("/help") { showHelp = true } + Button("Help") { showSettings = false; showHelp = true } + if session.signedIn { + Button("Sign out") { showSettings = false; host.signOut() } } - .font(Paint.font(19)).foregroundStyle(Paint.dim) - .buttonStyle(.plain) - .frame(height: 44) } + Text("Aesel \(appVersion)").foregroundStyle(Paint.dim) } - .padding(.horizontal, 12).padding(.top, 9) - .background { AeselWood().ignoresSafeArea(edges: .bottom) } - .overlay(alignment: .top) { Rectangle().fill(Color(rgb: 0x9e7548)).frame(height: 1) } + .scrollContentBackground(.hidden) + .background { AeselCloth().ignoresSafeArea() } + .navigationTitle("Settings").navigationBarTitleDisplayMode(.inline) + .toolbar { ToolbarItem(placement: .confirmationAction) { Button("Done") { showSettings = false } } } } + .tint(Paint.you).presentationDetents([.medium, .large]) } private var help: some View { @@ -242,20 +226,13 @@ struct ContentView: View { case "/login": host.signIn() case "/logout": host.signOut() case "/open": if let url = session.shareURL { openURL(url) } + case "/settings": writing = false; showSettings = true; host.refreshCredits() case "/help": showHelp = true default: host.ask(text) } } - private func colour(_ kind: Entry.Kind) -> Color { - switch kind { - case .you: return Paint.you - case .ac: return Paint.ink - case .edit: return Paint.edit - case .bad: return Paint.bad - case .note: return Paint.dim - } - } + } /// Puts the hidden session webview in the hierarchy without drawing it. diff --git a/apple/aesel/Sources/Session.swift b/apple/aesel/Sources/Session.swift index 7b7464485b..c13249a35c 100644 --- a/apple/aesel/Sources/Session.swift +++ b/apple/aesel/Sources/Session.swift @@ -68,6 +68,9 @@ final class Session { var handle = "" var handleColors: [String] = [] var braincells: Double? + var braincellDollars: Double? + var freeDollars: Double? + var purchasedDollars: Double? var creditsStatus = "Sign in to view braincells" /// Set when the host page itself could not start — a missing dev server is /// the usual cause, and a blank screen is a bad way to say so. @@ -96,6 +99,10 @@ final class Session { switch type { case "credits": braincells = event["total"] as? Double + let dollars = event["dollars"] as? [String: Any] + braincellDollars = dollars?["total"] as? Double + freeDollars = dollars?["free"] as? Double + purchasedDollars = dollars?["purchased"] as? Double creditsStatus = event["status"] as? String ?? "Braincells unavailable" case "model": @@ -134,6 +141,9 @@ final class Session { case "signedIn": braincells = nil + braincellDollars = nil + freeDollars = nil + purchasedDollars = nil creditsStatus = "Loading braincells" signedIn = true handle = event["handle"] as? String ?? "" @@ -146,6 +156,9 @@ final class Session { case "signedOut": braincells = nil + braincellDollars = nil + freeDollars = nil + purchasedDollars = nil creditsStatus = "Sign in to view braincells" signedIn = false handle = "" diff --git a/easel/README.md b/easel/README.md index dd59736d44..691420c00f 100644 --- a/easel/README.md +++ b/easel/README.md @@ -63,14 +63,16 @@ piece, channel and QR. A new provider thread receives recent user/assistant context (up to 24,000 characters) and the current piece; provider thread IDs and tool history are not portable. A failed connection returns to the prior engine. `/new` explicitly starts a fresh conversation. `/backend` lists account options; -`/model` lists hosted choices or accepts a model name for your own vendor CLI. - -AC hosted keeps GLM as its default. `/model sonnet` and `/model gpt` select -premium models and consume the same handle allowance. Model IDs were checked -against the [OpenRouter catalog](https://openrouter.ai/compare/openai/gpt-5.4/anthropic/claude-sonnet-4.6). -The allowance measures weighted tokens, not dollars, and is not an atomic spend -reservation. Unavailable budget checks refuse inference. New hosted choices -require the matching Lith endpoint deployment. +`/model` accepts a model name for your own vendor CLI. + +AC braincell inference uses an automatic model. Saved manual model choices are +normalized when a hosted thread resumes. Desktop version settings show braincells +and their USD equivalent, separating the daily free allowance from purchased +credit. The equivalent uses the server's pack rate ($5 per million braincells), +not the underlying provider's token price. Claude and Codex keep their model controls. + +Desktop notebook text wraps beside the preview and returns to full width below it; +scrolling and preview resizing update the available space. `/about`, or clicking **AESEL**, opens the feature map. Click **@handle** to open your profile in a browser. Header targets highlight on hover in terminals that diff --git a/easel/desktop/credit-label.cjs b/easel/desktop/credit-label.cjs index ad22d251d2..4e027f23cd 100644 --- a/easel/desktop/credit-label.cjs +++ b/easel/desktop/credit-label.cjs @@ -11,6 +11,7 @@ function startCreditLabel({app, window, root, nativeRoot = process.env.AESEL_DEV if (busy || disposed) return; busy = true; offer = null; + let balance = {remaining:null,purchased:null,dollars:null}; let text = 'AC credits —', description = 'Daily AC allowance unavailable. This is not your Claude, Codex, or OpenRouter account balance.'; try { const account = await session(); @@ -22,18 +23,19 @@ function startCreditLabel({app, window, root, nativeRoot = process.env.AESEL_DEV const value = await response.json(); if (value.unit !== 'weighted_tokens' || !Number.isFinite(value.remaining) || value.remaining < 0 || value.handle !== '@'+account.handle) throw new Error('invalid allowance'); offer = value.offer; + balance = {remaining:value.remaining,purchased:value.purchased,dollars:value.dollars??null}; const paid = Number(value.purchased)||0; text = paid > 0 ? `${Math.floor(value.remaining).toLocaleString('en-US')} free · ${paid.toLocaleString('en-US')} paid` : `${Math.floor(value.remaining).toLocaleString('en-US')} AC credits`; description = `${value.handle}: ${Math.floor(value.remaining).toLocaleString('en-US')} of ${value.limit.toLocaleString('en-US')} daily AC credits remaining. One credit is one metered token (cached tokens are discounted). Resets at midnight UTC. Applies to the AC hosted engine only. ${paid.toLocaleString('en-US')} purchased Luna credits; these do not expire.`; } } catch { /* Unknown stays unknown; never invent a full balance. */ } if (disposed) { busy=false; return; } - if (text === lastText && lastPayload) { lastPayload={...lastPayload,description,offer}; send(lastPayload); busy=false; return; } + if (text === lastText && lastPayload) { lastPayload={...lastPayload,...balance,description,offer}; send(lastPayload); busy=false; return; } execFile(join(nativeRoot,'native','credit-label'),[text,'14'],{encoding:'buffer',timeout:5000,maxBuffer:1024*1024},(error,png)=>{ busy=false; if (disposed) return; lastText=text; - lastPayload={text,description,offer,image:error?null:`data:image/png;base64,${png.toString('base64')}`}; + lastPayload={text,...balance,description,offer,image:error?null:`data:image/png;base64,${png.toString('base64')}`}; send(lastPayload); }); } diff --git a/easel/desktop/donkey.css b/easel/desktop/donkey.css index 9a4b3ef68f..b8a07f2cf7 100644 --- a/easel/desktop/donkey.css +++ b/easel/desktop/donkey.css @@ -172,6 +172,10 @@ body.preview-fullscreen::before{display:none;} #conversation{left:0;right:0;bottom:8px;overflow-x:hidden;overflow-y:auto;scrollbar-width:auto;scrollbar-color:auto;overflow-anchor:none} #notebook-page{display:flex;flex-direction:column;box-sizing:border-box;padding:0 10px} #notebook-page>article{flex:none} +#notebook-content{display:flow-root;flex:none;min-width:0} +#notebook-preview-space{float:right;pointer-events:none} +#notebook-preview-space[hidden]{display:none} +#conversation>#qr-label{overflow:hidden} #notebook-page>#prose-prompt{position:relative;inset:auto;z-index:auto;flex:none;margin-top:auto;margin-right:100px;height:auto;overflow:visible;background:none;line-height:var(--notebook-line-height,24px)} #prompt-feedback{bottom:14px;right:110px} diff --git a/easel/desktop/index.html b/easel/desktop/index.html index d714afac43..26d34d28a1 100644 --- a/easel/desktop/index.html +++ b/easel/desktop/index.html @@ -5,4 +5,4 @@
- + diff --git a/easel/desktop/notebook-layout.js b/easel/desktop/notebook-layout.js new file mode 100644 index 0000000000..5128d9dbe0 --- /dev/null +++ b/easel/desktop/notebook-layout.js @@ -0,0 +1,70 @@ +// Reserve only the part of the scrolling page covered by the fixed preview. +// A shaped float lets ordinary prose use the full page again below it. +(() => { + const view = document.getElementById("conversation"), + content = document.getElementById("notebook-content"); + const preview = document.getElementById("artifact-shell"), + title = document.getElementById("qr-label"); + if (!view || !content || !preview) return; + const exclusion = document.createElement("div"); + exclusion.id = "notebook-preview-space"; + exclusion.setAttribute("aria-hidden", "true"); + content.prepend(exclusion); + let frame = 0; + const set = (name, value) => { + if (exclusion.style.getPropertyValue(name) !== value) + exclusion.style.setProperty(name, value); + }; + const layout = () => { + frame = 0; + const box = preview.getBoundingClientRect(), + page = content.getBoundingClientRect(); + const visible = + !preview.hidden && + !view.hidden && + !document.body.classList.contains("preview-fullscreen") && + box.width > 0 && + box.height > 0; + const gap = 16, + bottom = box.bottom - page.top + gap; + const width = Math.min( + page.width, + Math.max(0, page.right - box.left + gap), + ); + const reserve = page.width - width < 220 ? page.width : width; + exclusion.hidden = !visible || bottom <= 0 || width <= 0; + if (!exclusion.hidden) { + set("width", `${Math.ceil(reserve)}px`); + set("height", `${Math.ceil(bottom)}px`); + set( + "shape-outside", + `inset(${Math.max(0, Math.floor(box.top - page.top - gap))}px 0 0 0)`, + ); + } + if (title) { + const available = visible + ? Math.max(0, box.left - title.getBoundingClientRect().left - gap) + : view.clientWidth - 28; + title.style.maxWidth = `${available}px`; + } + }; + window.layoutNotebookPreview = () => { + if (!frame) frame = requestAnimationFrame(layout); + }; + const observer = new ResizeObserver(window.layoutNotebookPreview); + observer.observe(preview); + observer.observe(view); + new MutationObserver(window.layoutNotebookPreview).observe(preview, { + attributes: true, + attributeFilter: ["hidden", "style", "class"], + }); + new MutationObserver(window.layoutNotebookPreview).observe(document.body, { + attributes: true, + attributeFilter: ["class", "data-preview-mode", "data-preview-engaged"], + }); + view.addEventListener("scroll", window.layoutNotebookPreview, { + passive: true, + }); + window.addEventListener("resize", window.layoutNotebookPreview); + window.layoutNotebookPreview(); +})(); diff --git a/easel/desktop/package-lock.json b/easel/desktop/package-lock.json index f23887b843..7ef415eadc 100644 --- a/easel/desktop/package-lock.json +++ b/easel/desktop/package-lock.json @@ -1,12 +1,12 @@ { "name": "aesel-desktop", - "version": "0.7.4", + "version": "0.7.5", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "aesel-desktop", - "version": "0.7.4", + "version": "0.7.5", "dependencies": { "@xterm/addon-fit": "0.10.0", "@xterm/addon-webgl": "0.18.0", diff --git a/easel/desktop/package.json b/easel/desktop/package.json index 90c601d900..3bdb4b115f 100644 --- a/easel/desktop/package.json +++ b/easel/desktop/package.json @@ -1,7 +1,7 @@ { "name": "aesel-desktop", "productName": "aesel", - "version": "0.7.4", + "version": "0.7.5", "private": true, "main": "main.cjs", "scripts": { diff --git a/easel/desktop/renderer.js b/easel/desktop/renderer.js index ed035a2143..4611ea8376 100644 --- a/easel/desktop/renderer.js +++ b/easel/desktop/renderer.js @@ -634,12 +634,13 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could const header=document.createElement('header');const title=document.createElement('h2');title.textContent='Settings'; const dismiss=document.createElement('button');dismiss.type='button';dismiss.textContent='×';dismiss.setAttribute('aria-label','Close settings');dismiss.addEventListener('click',close);header.append(title,dismiss);menu.append(header); if(buildInfo){const status=document.createElement('p');status.className='build-status';status.dataset.status=buildInfo.status;status.title=buildInfo.revision?`Source commit ${buildInfo.revision}`:'';const channel=buildInfo.channel==='dev'?'Dev':buildInfo.channel==='release'?'Release':'Local';const state={current:'Up to date',ready:'Update ready',modified:'Local changes · sync paused',checking:'Checking…',syncing:'Downloading dev build…',downloading:'Downloading update…',unknown:'Unable to verify'}[buildInfo.status]||'Unable to verify';status.textContent=[channel,buildInfo.version,(buildInfo.tree||buildInfo.revision)?.slice(0,8),state].filter(Boolean).join(' · ');menu.append(status);item('Check for updates',()=>window.aesel.checkBuildUpdates());} - const current=document.createElement('p');current.textContent=[provider?.backend==='ac'?'AC':provider?.backend==='codex'?'Codex':'Claude',provider?.model].filter(Boolean).join(' · ');menu.append(current); + const current=document.createElement('p');current.textContent=[provider?.backend==='ac'?'Braincells · automatic model':provider?.backend==='codex'?'Codex':'Claude',provider?.backend==='ac'?'':provider?.model].filter(Boolean).join(' · ');menu.append(current); for(const [index,id,name] of [[0,'ac','AC'],[1,'claude','Claude'],[2,'codex','Codex']]){ const choice=item(name,()=>{close();if(provider?.backend!==id)window.aesel.input(`\x1b[99;${index}~`);},{selected:provider?.backend===id,disabled:!!provider?.busy}); const icon=new Image();icon.src=`assets/provider-${id}.svg`;icon.alt='';icon.className='provider-mark';choice.prepend(icon); } + if(provider?.backend!=='ac'){ const modelLabel=document.createElement('label');modelLabel.textContent='Model';modelLabel.className='settings-field'; const modelSelect=document.createElement('select');modelSelect.setAttribute('aria-label','Model');modelSelect.disabled=!!provider?.busy||!provider?.models?.length; for(const [index,model] of (provider?.models||[]).entries()){ @@ -650,10 +651,14 @@ boot().catch(error => { document.getElementById('terminal').textContent = `Could const backendIndex=['ac','claude','codex'].indexOf(provider?.backend); modelSelect.addEventListener('change',()=>{window.aesel.input(`\x1b[99;4;${backendIndex};${modelSelect.value}~`);}); modelLabel.append(modelSelect);menu.append(modelLabel); + } const stateLine=document.createElement('p');stateLine.className='inference-status';stateLine.textContent=[provider?.mode==='local'?'Local inference':'Remote inference',provider?.status,provider?.activity].filter(Boolean).join(' · ');menu.append(stateLine); if(provider?.notice){const notice=document.createElement('p');notice.className='session-notice';notice.textContent=provider.notice;menu.append(notice);} - if(provider?.backend==='ac'){const count=document.createElement('p');count.className='braincell-balance';const icon=new Image();icon.src='assets/braincell.svg';icon.alt='';icon.className='provider-mark';count.append(icon,`${n===null?'—':n.toLocaleString('en-US')} braincells`);menu.append(count);} + if(provider?.backend==='ac'){const count=document.createElement('p');count.className='braincell-balance';const icon=new Image();icon.src='assets/braincell.svg';icon.alt='';icon.className='provider-mark';const dollars=credits?.dollars;const usd=value=>new Intl.NumberFormat('en-US',{style:'currency',currency:'USD'}).format(value); + const valid=dollars?.currency==='USD'&&[dollars.total,dollars.free,dollars.purchased].every(value=>Number.isFinite(value)&&value>=0); + count.append(icon,`${valid?usd(dollars.total)+' · ':''}${n===null?'—':n.toLocaleString('en-US')} braincells`);menu.append(count); + if(valid){const detail=document.createElement('p');detail.className='braincell-value';detail.textContent=`${usd(dollars.free)} daily · ${usd(dollars.purchased)} purchased`;detail.title='Dollar equivalent at $5 per million braincells. Daily allowance resets at midnight UTC.';menu.append(detail);} } if(provider?.backend==='ac'&&credits?.offer){ const buy=item('Buy 1,000,000 braincells · $5',async()=>{ buy.disabled=true;buy.textContent='Opening checkout…'; diff --git a/easel/desktop/scripts/linux-download.html b/easel/desktop/scripts/linux-download.html index 1f97e8bd73..2cfc0c105d 100644 --- a/easel/desktop/scripts/linux-download.html +++ b/easel/desktop/scripts/linux-download.html @@ -12,8 +12,8 @@ sudo apt install ./Easel-0.7.0-linux-amd64.deb
chmod +x Easel-0.7.0-linux-x86_64.AppImage
 ./Easel-0.7.0-linux-x86_64.AppImage

macOS

-

Download for Apple silicon

-

ZIP archive · signed and notarized · macOS 12+

+

Download for Apple silicon

+

ZIP archive · signed and notarized · macOS 12+

If FUSE is unavailable, try --appimage-extract-and-run. On Ubuntu, use the .deb above to install the required sandbox. Do not disable the sandbox.

aesel requires sharing conversations and artifact revision references with authorized Aesthetic Computer staff for product improvement, including when using your own provider. Read and accept the disclosure before use. Hosted generation requires account access; bringing your own Claude or Codex requires its CLI and account.

Tested on Ubuntu 24.04 x86-64. ARM builds are not included. SHA-256 checksums · Aesthetic Computer

diff --git a/easel/desktop/transcript.js b/easel/desktop/transcript.js index 8af10bcf3e..f9955b5f9e 100644 --- a/easel/desktop/transcript.js +++ b/easel/desktop/transcript.js @@ -5,6 +5,7 @@ view.setAttribute('aria-live','off');document.body.append(view); const title=document.getElementById('qr-label');if(title)view.append(title); const paper=document.createElement('div');paper.id='notebook-page';view.append(paper); + const prose=document.createElement('div');prose.id='notebook-content';paper.append(prose); window.alignNotebookRuling=()=>{ const spacing=parseFloat(getComputedStyle(view).lineHeight)||24; const titleHeight=title&&!title.hidden?title.getBoundingClientRect().height:0; @@ -14,6 +15,7 @@ const clip=Math.ceil((clipped-.01)/spacing)*spacing; paper.style.clipPath='none'; document.documentElement.style.setProperty('--notebook-offset',`${origin%spacing}px`); + window.layoutNotebookPreview?.(); }; view.addEventListener('scroll',window.alignNotebookRuling,{passive:true}); new ResizeObserver(window.alignNotebookRuling).observe(view); @@ -63,7 +65,7 @@ if(entry.kind==='notice'&&/^(?:Engine|Model|Settings|Medium|Artifact) · .* · current piece and recent conversation carried over$/.test(entry.text))continue; if(entry.kind==='notice'&&/^(?:Desktop thread restored|Desktop (?:restart|update|home) queued|Ran: |Queued(?: \(\d+ queued\))? · )/.test(entry.text))continue; ids.add(entry.id);let node=nodes.get(entry.id); - if(!node){node=document.createElement('article');nodes.set(entry.id,node);paper.insertBefore(node,document.getElementById('prose-prompt'));} + if(!node){node=document.createElement('article');nodes.set(entry.id,node);prose.append(node);} node.dataset.kind=entry.kind;node.setAttribute('aria-label',entry.kind==='user'?'You':entry.kind==='assistant'?'Aesel':entry.kind); const handle=entry.kind==='user'&&entry.id!==newestUser?String(entry.handle||window.notebookHandle||'').replace(/^@/,''):''; if(node.raw!==entry.text||node.handle!==handle||node.palette!==window.notebookPalette){ diff --git a/easel/package.json b/easel/package.json index 0a9905ae7f..a2a0718279 100644 --- a/easel/package.json +++ b/easel/package.json @@ -1,6 +1,6 @@ { "name": "aesel", - "version": "0.7.4", + "version": "0.7.5", "private": true, "type": "module", "scripts": { diff --git a/easel/phone/credits.mjs b/easel/phone/credits.mjs index fdc1c776c6..1df4c2c835 100644 --- a/easel/phone/credits.mjs +++ b/easel/phone/credits.mjs @@ -22,7 +22,9 @@ export function createCredits({ token, emit, site, fetch: request = (...args) => const value = await response.json(); if (![value.remaining, value.purchased].every(n => Number.isFinite(n) && n >= 0)) throw new Error("Invalid allowance"); if (current !== generation || credential !== token()) return; - emit({ type: "credits", total: value.remaining + value.purchased, status: "ready" }); + const dollars = value.dollars; + const validDollars = dollars?.currency === "USD" && [dollars.total, dollars.free, dollars.purchased].every(n => Number.isFinite(n) && n >= 0); + emit({ type: "credits", total: value.remaining + value.purchased, ...(validDollars ? { dollars } : {}), status: "ready" }); } catch { if (current === generation && credential === token()) { emit({ type: "credits", total: null, status: "Braincells unavailable · tap to retry" }); diff --git a/easel/phone/session.mjs b/easel/phone/session.mjs index 0ccd8c8f10..5c5083e637 100644 --- a/easel/phone/session.mjs +++ b/easel/phone/session.mjs @@ -22,10 +22,7 @@ import { createCredits } from "./credits.mjs"; export const SITE = "https://aesthetic.computer"; export const AUTH_DOMAIN = "hi.aesthetic.computer"; -export const MODEL_CHOICES = [ - { id: "openai/gpt-5.6-luna", alias: "luna", title: "GPT-5.6 Luna", premium: false }, - { id: "anthropic/claude-opus-5", alias: "opus", title: "Claude Opus 5", premium: true }, -]; +export const MODEL_CHOICES = []; // Braincell routing is managed by AC. // Fetched rather than bundled, at the paths the bridge's own `bundledContext()` // builds, so editing a guide reaches the phone on reload. @@ -198,7 +195,7 @@ export function createSession({ storage = memoryStore(), emit = () => {} } = {}) state.title = item.title || item.slug; state.transcript = Array.isArray(item.events) ? item.events : []; state.engine = item.engine || null; - state.model = item.model || item.engine?.model || DEFAULT_AC_MODEL; + state.model = DEFAULT_AC_MODEL; mountPiece(item.slug, item.source || STARTER); state.published = Boolean(item.published && (!item.handle || item.handle === state.handle)); write({ threadID: state.id, published: state.published }); @@ -329,11 +326,10 @@ export function createSession({ storage = memoryStore(), emit = () => {} } = {}) function setModel(input) { if (state.busy || state.publishing) throw new Error("Wait for this turn and upload to finish before changing models."); - const choice = MODEL_CHOICES.find(item => item.id === input || item.alias === String(input).trim().toLowerCase()); - if (!choice) throw new Error("Choose /model luna or /model opus."); - state.model = choice.id; - if (state.server) state.server.model = choice.id; - if (state.engine) state.engine.model = choice.id; + if (input !== DEFAULT_AC_MODEL) throw new Error("Braincell models are managed automatically."); + state.model = DEFAULT_AC_MODEL; + if (state.server) state.server.model = state.model; + if (state.engine) state.engine.model = state.model; say("model", {requested: state.model, choices: MODEL_CHOICES}); saveCurrent(); return state.model; @@ -343,7 +339,7 @@ export function createSession({ storage = memoryStore(), emit = () => {} } = {}) const command = text.trim().match(/^\/model(?:\s+(.+))?$/i); if (command) { if (command[1]) setModel(command[1]); - else say("note", {text: `Requested model: ${state.model}. Choose /model luna or /model opus.`}); + else say("note", {text: "Braincell models are managed automatically."}); return; } if (!text.trim() || state.busy) return; diff --git a/easel/src/provider-picker.mjs b/easel/src/provider-picker.mjs index 7fe9e92860..f4b974abc3 100644 --- a/easel/src/provider-picker.mjs +++ b/easel/src/provider-picker.mjs @@ -21,6 +21,7 @@ export function codexModels({command='codex',args=['app-server','--listen','stdi } export function pickerModels(p) { const backend=BACKENDS[p.backend]; + if(p.backend==='ac')return [{id:backend.defaultModel,label:'Automatic'}]; const choices=p.backend==='ac'?Object.entries(backend.models).map(([label,id])=>({id,label})) :p.backend==='claude'?['fable','opus','sonnet','haiku'].map(id=>({id,label:id})) :[{id:'',label:'CLI default'},...(p.catalog||[]).filter(x=>!x.hidden).map(x=>({id:x.model,label:x.displayName||x.model}))]; @@ -51,7 +52,7 @@ export function pickerLines(p) { `Effort ${p.backend==='ac'?'provider managed':p.effort||'default'}`, 'Apply', ].map((line,i)=>(p.row===i?'› ':' ')+line),'',p.loading?'Loading Codex models…':p.error||'↑ ↓ / Tab field · ← → choice · Enter next/apply · Esc cancel', - 'Custom model: /model NAME · Settings apply to the next turn']; + p.backend==='ac'?'Braincell models are managed automatically':'Custom model: /model NAME · Settings apply to the next turn']; } export function drawerOptions(p) { diff --git a/easel/src/tui.mjs b/easel/src/tui.mjs index ba772040c0..58eb70b8dc 100755 --- a/easel/src/tui.mjs +++ b/easel/src/tui.mjs @@ -118,7 +118,7 @@ if(!savedProvider) { try { const previous=await readDesktopSession(localSessionP const providerChoice=chooseProviderPreferences({restored:desktopRestored, saved:savedProvider, explicit:{backend:option('--backend')||process.env.EASEL_BACKEND||undefined,model:option('--model')||undefined,effort:option('--effort')||undefined},fallback:process.env.EASEL_DESKTOP?'ac':DEFAULT_BACKEND}); let backend=backendFor(providerChoice.backend); -let model=providerChoice.model??backend.defaultModel; +let model=backend.id==='ac'?backend.defaultModel:providerChoice.model??backend.defaultModel; let effort=providerChoice.effort; async function rememberProvider(){try{await saveProviderPreferences({backend:backend.id,model,effort});}catch(error){addEntry('error',`Could not remember provider: ${error.message}`);}} let handoff = desktopRestored?.handoff || ""; @@ -1259,6 +1259,7 @@ function engineLabel() { // Provider thread IDs cannot cross engines; carry recent conversation and // keep the old connection available until the replacement connects. async function restartEngine(note, nextBackend = backend, nextModel = model, nextEffort = nextBackend === backend ? effort : "") { + if(nextBackend.id==='ac')nextModel=nextBackend.defaultModel; if (nextBackend.models && !Object.hasOwn(nextBackend.models, nextModel) && !Object.values(nextBackend.models).includes(nextModel)) { addEntry("error", "Unknown hosted model. Use /model to see available choices."); @@ -1329,6 +1330,7 @@ async function commandBackend(rest) { } async function commandModel(rest) { + if(backend.id==='ac')return openSettings(0); if (!rest) return openSettings(1); if (state.busy) { addEntry("error", "Interrupt the current turn before switching models."); @@ -1896,7 +1898,7 @@ function handleKey(input) { } const desktopModel=process.env.EASEL_DESKTOP && /^\x1b\[99;4;(\d+);(\d+)~$/.exec(input); if(desktopModel){ - if(state.busy||['ac','claude','codex'][Number(desktopModel[1])]!==backend.id)return; + if(state.busy||backend.id==='ac'||['ac','claude','codex'][Number(desktopModel[1])]!==backend.id)return; const choice=pickerModels({backend:backend.id,model,catalog:modelCatalog||[]})[Number(desktopModel[2])]; if(choice)return void restartEngine('Model',backend,choice.id); return; diff --git a/easel/test/notebook-layout.electron.cjs b/easel/test/notebook-layout.electron.cjs new file mode 100644 index 0000000000..8f0684e80a --- /dev/null +++ b/easel/test/notebook-layout.electron.cjs @@ -0,0 +1,198 @@ +// Run from the repo root with easel/desktop/node_modules/.bin/electron. +const { app, BrowserWindow, ipcMain } = require("electron"); +const fs = require("node:fs"); +const path = require("node:path"); +const assert = require("node:assert/strict"); +(async () => { + await app.whenReady(); + const root = process.cwd(); + const errors = []; + let opened = ""; + ipcMain.handle("native-prox-title", (_, value) => + JSON.parse( + require("node:child_process").execFileSync( + path.join(root, "easel/desktop/native/credit-label"), + [value.text, String(value.size), "--glyphs"], + { encoding: "utf8" }, + ), + ), + ); + ipcMain.on("open-piece", (_, url) => (opened = url)); + const win = new BrowserWindow({ + show: false, + width: 900, + height: 650, + webPreferences: { + preload: path.join(root, "easel/desktop/preload.cjs"), + contextIsolation: true, + sandbox: true, + backgroundThrottling: false, + }, + }); + win.webContents.on("console-message", (_, level, message) => { + if (level === 3) { + errors.push(message); + console.log("renderer:", message); + } + }); + await win.loadFile(path.join(root, "easel/desktop/index.html")); + const delay = (ms) => new Promise((r) => setTimeout(r, ms)); + await delay(600); + win.webContents.send("output", "\x1b]777;easel-phase:ready\x07"); + win.webContents.send("state", { + medium: "piece", + piece: "release-check", + handle: "jeffrey", + version: 3, + status: "complete", + url: "https://aesthetic.computer/blank", + }); + await delay(300); + await win.webContents.executeJavaScript( + `window.updateConversation({entries:[{id:'status',kind:'notice',text:'REMOTE INFERENCE'}, {id:'u1',kind:'user',text:'Make a small moving circle.'},{id:'a1',kind:'assistant',text:'The circle follows your pointer.\\n\\nTry changing its **size** or **color**.'}]})`, + ); + await delay(500); + assert.equal( + await win.webContents.executeJavaScript( + `document.querySelectorAll('#notebook-page article').length`, + ), + 2, + ); + await win.webContents.executeJavaScript( + `window.updateProviderFooter({backend:'ac',model:'Luna',status:'thinking',mode:'remote',activity:'Reading the piece',models:[],versions:[]});`, + ); + win.webContents.send( + "output", + "\x1b]777;easel-prompt:" + + JSON.stringify({ + text: "", + cursor: 0, + activity: "Reading the piece", + feedback: "Thinking", + hidden: false, + }) + + "\x07", + ); + await delay(100); + assert.equal( + await win.webContents.executeJavaScript( + `document.querySelector('#activity-caption')?.textContent||''`, + ), + "", + ); + win.webContents.send("credits", { + text: "200,000 AC credits", + description: "Balance", + remaining: 200000, + purchased: 1000000, + dollars: { currency: "USD", free: 1, purchased: 5, total: 6 }, + }); + await delay(50); + await win.webContents.executeJavaScript( + `document.getElementById('credit-label').click()`, + ); + assert.equal( + await win.webContents.executeJavaScript( + `document.querySelector('#provider-menu select')`, + ), + null, + ); + assert.match( + await win.webContents.executeJavaScript( + `document.querySelector('.braincell-balance').textContent`, + ), + /\$6.00/, + ); + assert.equal( + await win.webContents.executeJavaScript( + `document.querySelector('#provider-menu .inference-status').textContent`, + ), + "Remote inference · thinking · Reading the piece", + ); + await win.webContents.executeJavaScript( + `document.querySelector('#provider-menu header button').click()`, + ); + await win.webContents.executeJavaScript( + `document.getElementById('qr-label').click()`, + ); + await delay(80); + assert.equal(opened, "https://aesthetic.computer/blank"); + for (const [name, w, h] of [ + ["normal", 900, 650], + ["compact", 720, 540], + ]) { + win.setSize(w, h); + await delay(350); + const geometry = await win.webContents.executeJavaScript( + `(()=>{const r=document.getElementById('conversation').getBoundingClientRect();return {width:r.width,bottom:r.bottom,height:innerHeight,text:document.getElementById('notebook-page').innerText}})()`, + ); + assert(geometry.width > 200 && geometry.bottom <= geometry.height + 1); + assert(geometry.text.includes("circle follows")); + } + await win.webContents.executeJavaScript( + `window.updateConversation({entries:[{id:'long',kind:'assistant',text:${JSON.stringify("The circle moves through the purple sky and follows your pointer. ".repeat(90))}}]});document.getElementById('conversation').scrollTop=0`, + ); + await delay(350); + for (const [name, scroll] of [ + ["wrap", 0], + ["scroll", 220], + ["bottom", 1800], + ["expanded", 0], + ["resized", 160], + ]) { + if (name === "expanded") + await win.webContents.executeJavaScript( + `document.body.dataset.previewEngaged='true'`, + ); + if (name === "resized") + await win.webContents.executeJavaScript( + `document.body.dataset.previewEngaged='false';const shell=document.getElementById('artifact-shell');shell.dataset.resized='true';for(const [key,value] of Object.entries({'compact-width':280,'compact-height':210,right:30,top:70}))shell.style.setProperty('--resized-preview-'+key,value+'px')`, + ); + await win.webContents.executeJavaScript( + `document.getElementById('conversation').scrollTop=${scroll}`, + ); + await delay(250); + const measure = await win.webContents.executeJavaScript( + `(()=>{const box=document.getElementById('artifact-shell').getBoundingClientRect();const node=document.querySelector('article');const walk=document.createTreeWalker(node,NodeFilter.SHOW_TEXT);let overlaps=0,beside=0,below=0,text;while(text=walk.nextNode()){const range=document.createRange();for(let i=0;ibox.top){if(r.right>box.left&&r.leftbox.bottom&&r.right>box.left)below++;}}return {overlaps,beside,below,scroll:document.getElementById('conversation').scrollTop,box:box.toJSON()}})()`, + ); + console.log(name, measure); + assert.equal(measure.overlaps, 0); + if (name !== "expanded") assert(measure.beside > 0); + assert(measure.below > 0); + } + await win.webContents.executeJavaScript( + `document.getElementById('artifact-shell').hidden=true`, + ); + await delay(80); + assert.equal( + await win.webContents.executeJavaScript( + `document.getElementById('notebook-preview-space').hidden`, + ), + true, + ); + const hover = await win.webContents.executeJavaScript( + `(()=>{const el=document.getElementById('credit-label'),s=getComputedStyle(el);return {origin:s.transformOrigin,right:s.right,width:el.offsetWidth,height:el.offsetHeight}})()`, + ); + assert.equal(hover.right, "8px"); + assert.equal(hover.origin, hover.width / 2 + "px " + hover.height / 2 + "px"); + assert(hover.width > 15); + await win.webContents.executeJavaScript( + `document.getElementById('credit-label').dispatchEvent(new PointerEvent('pointerenter'))`, + ); + await delay(850); + console.log( + "hover", + await win.webContents.executeJavaScript( + `(()=>{const e=document.getElementById('credit-label'),r=e.getBoundingClientRect(),s=getComputedStyle(e);return {right:r.right,viewport:innerWidth,transform:s.transform,origin:s.transformOrigin}})()`, + ), + ); + assert.deepEqual(errors, []); + console.log( + "PASS notebook text, title navigation, compact layout, no renderer errors", + ); + win.destroy(); + app.quit(); +})().catch((e) => { + console.error(e); + app.exit(1); +}); diff --git a/easel/test/phone-credits.test.mjs b/easel/test/phone-credits.test.mjs index a87765c7c2..2b6e1ae32e 100644 --- a/easel/test/phone-credits.test.mjs +++ b/easel/test/phone-credits.test.mjs @@ -40,3 +40,14 @@ test('failed or malformed allowances clear the displayed balance', async () => { await credits.refresh(); assert.equal(events.at(-1).total, null); }); + +test('dollar values are forwarded only with a valid USD balance', async () => { + const events=[]; + const dollars={currency:'USD',total:6,free:1,purchased:5}; + let payload={remaining:200000,purchased:1000000,dollars}; + const credits=createCredits({token:()=> 'token',emit:event=>events.push(event),site:'https://example.test',fetch:async()=>Response.json(payload)}); + await credits.refresh();assert.deepEqual(events.at(-1).dollars,dollars); + payload={...payload,dollars:{...dollars,total:'6'}}; + await credits.refresh();assert.equal(events.at(-1).dollars,undefined); + credits.clear();assert.equal(events.at(-1).dollars,undefined); +}); diff --git a/easel/test/phone-session.test.mjs b/easel/test/phone-session.test.mjs index 8eb22038af..92b5bd3c3f 100644 --- a/easel/test/phone-session.test.mjs +++ b/easel/test/phone-session.test.mjs @@ -77,32 +77,21 @@ test('expired account tokens are cleared while saved drafts remain', async () => } finally { globalThis.fetch = originalFetch; } }); -test('model choices persist per thread and cannot change during a turn or upload', async () => { +test('braincell model stays automatic across old threads and model commands', async () => { const {DEFAULT_AC_MODEL} = await import('../src/ac-server.mjs'); const values = new Map(); const storage = {get:key=>values.get(key),set:(key,value)=>values.set(key,value)}; const session = createSession({storage}); await session.open(); - const initialID = session.state.id; assert.equal(session.state.model, DEFAULT_AC_MODEL); - await session.ask('/model opus'); - assert.equal(session.state.model, 'anthropic/claude-opus-5'); - assert.equal(JSON.parse(values.get('threads')).items[0].model, 'anthropic/claude-opus-5'); - session.state.busy = true; - assert.throws(()=>session.setModel('luna'), /Wait for this turn/); - assert.equal(session.state.model, 'anthropic/claude-opus-5'); - session.state.busy = false; - session.state.publishing = Promise.resolve(); - assert.throws(()=>session.setModel('luna'), /Wait for this turn/); - session.state.publishing = null; - await session.newSession('piece'); - assert.equal(session.state.model, DEFAULT_AC_MODEL); - await session.resumeSession(initialID); - assert.equal(session.state.model, 'anthropic/claude-opus-5'); - const relaunched = createSession({storage}); - await relaunched.open(); - assert.equal(relaunched.state.model, 'anthropic/claude-opus-5'); - relaunched.setModel('luna'); - assert.equal(relaunched.state.model, 'openai/gpt-5.6-luna'); - assert.throws(()=>relaunched.setModel('unknown'), /Choose/); + await assert.rejects(session.ask('/model opus'), /managed automatically/); + assert.throws(()=>session.setModel('unknown'), /managed automatically/); + // A pre-update thread may still have a manually chosen premium model. + const stored = JSON.parse(values.get('threads')); + stored.items[0].model = 'anthropic/claude-opus-5'; + if (stored.items[0].engine) stored.items[0].engine.model = stored.items[0].model; + values.set('threads', JSON.stringify(stored)); + const restored = createSession({storage}); + await restored.open(); + assert.equal(restored.state.model, DEFAULT_AC_MODEL); }); diff --git a/easel/test/provider-picker.test.mjs b/easel/test/provider-picker.test.mjs index 342a8724b8..156800e7be 100644 --- a/easel/test/provider-picker.test.mjs +++ b/easel/test/provider-picker.test.mjs @@ -1,6 +1,6 @@ import test from 'node:test'; import assert from 'node:assert/strict'; -import {pickerKey,pickerEfforts,pickerLines,drawerKey,drawerSelect} from '../src/provider-picker.mjs'; +import {pickerModels,pickerKey,pickerEfforts,pickerLines,drawerKey,drawerSelect} from '../src/provider-picker.mjs'; import {AppServer} from '../src/app-server.mjs'; import {renderFrame,headerAction,modelControls} from '../src/render.mjs'; const catalog=[{model:'example',displayName:'Example',isDefault:true,supportedReasoningEfforts:[{reasoningEffort:'low'},{reasoningEffort:'high'}]},{model:'other',supportedReasoningEfforts:[{reasoningEffort:'medium'}]}]; @@ -41,3 +41,10 @@ test('bottom controls and dropdown share click geometry and preserve transcript' assert.equal(drawerKey({...next,row:3,index:0},'\r').action,'apply'); const colored=renderFrame(state,80,24,true);assert.match(colored,/\x1b\[48;/); }); + +test('braincell picker cannot expose saved or custom model choices',()=>{ + for(const model of ['opus','anthropic/claude-opus-5','custom']){ + const p={backend:'ac',model,row:1}; + assert.deepEqual(pickerModels(p),[{id:'openai/gpt-5.6-luna',label:'Automatic'}]); + } +}); diff --git a/system/netlify/functions/easel-credits.mjs b/system/netlify/functions/easel-credits.mjs index 23a6e22cfc..fe28a16779 100644 --- a/system/netlify/functions/easel-credits.mjs +++ b/system/netlify/functions/easel-credits.mjs @@ -1,5 +1,5 @@ // Read the signed-in handle's existing daily allowance; never a vendor balance. -export function createHandler({ authorize, getHandleOrEmail, checkBudget, paidBalance = async () => 0, offer = null }) { +export function createHandler({ authorize, getHandleOrEmail, checkBudget, paidBalance = async () => 0, offer = null, creditPack = {amount: 500, credits: 1_000_000} }) { const reply = (statusCode, value) => ({ statusCode, headers: { "Content-Type": "application/json", "Cache-Control": "private, no-store", "Access-Control-Allow-Origin": "*", "Access-Control-Allow-Methods": "GET, OPTIONS", @@ -19,7 +19,11 @@ export function createHandler({ authorize, getHandleOrEmail, checkBudget, paidBa return reply(503, { error: "Allowance unavailable" }); } const purchased = await paidBalance(user.sub); - return reply(200, { purchased, offer, handle, unit: "weighted_tokens", remaining: budget.remaining, + if (!Number.isFinite(purchased) || purchased < 0) return reply(503, { error: "Allowance unavailable" }); + const valueUSD = cells => Math.round(cells * creditPack.amount / creditPack.credits * 1e6) / 1e8; + const dollars = { currency: "USD", free: valueUSD(budget.remaining), purchased: valueUSD(purchased), + total: valueUSD(budget.remaining + purchased) }; + return reply(200, { dollars, purchased, offer, handle, unit: "weighted_tokens", remaining: budget.remaining, used: budget.used, limit: budget.budget, day: budget.day, resetsAt: new Date(Date.parse(budget.day + "T00:00:00Z") + 86400000).toISOString() }); } catch { return reply(503, { error: "Allowance unavailable" }); } @@ -31,5 +35,5 @@ export async function handler(event) { import("../../backend/authorization.mjs"), import("../../backend/ai-budget.mjs"), import("../../backend/easel-paid-credits.mjs"), ]); - return createHandler({ authorize, getHandleOrEmail, checkBudget, paidBalance:user=>paid.withWallets(w=>paid.balance(user,w)), offer:process.env.AC_CREDITS_CHECKOUT_ENABLED === "true" ? paid.CREDIT_PACK : null })(event); + return createHandler({ authorize, getHandleOrEmail, checkBudget, paidBalance:user=>paid.withWallets(w=>paid.balance(user,w)), creditPack:paid.CREDIT_PACK, offer:process.env.AC_CREDITS_CHECKOUT_ENABLED === "true" ? paid.CREDIT_PACK : null })(event); } diff --git a/system/public/aesel/index.html b/system/public/aesel/index.html index e11f7c03bc..1dc3fe567c 100644 --- a/system/public/aesel/index.html +++ b/system/public/aesel/index.html @@ -14,7 +14,7 @@

Make art and software with AI.

- Download for Mac + Download for Mac
Apple silicon · macOS 12+ · Linux & more
@@ -24,7 +24,7 @@
-

Downloads

Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.

Mac Apple silicon · DMG↓Linux Ubuntu / Debian · x86-64↓AppImage Other Linux desktops · x86-64↓
Terminalcurl -fsSL https://aesthetic.computer/aesel.sh | sh

Requires Node.js 22+. Run ac after installing.

Mac 0.7.4 · Linux 0.7.0 (Easel)

+

Downloads

Sign in with Aesthetic Computer.
Use AC, Claude, or Codex.

Mac Apple silicon · DMG↓Linux Ubuntu / Debian · x86-64↓AppImage Other Linux desktops · x86-64↓
Terminalcurl -fsSL https://aesthetic.computer/aesel.sh | sh

Requires Node.js 22+. Run ac after installing.

Mac 0.7.5 · Linux 0.7.0 (Easel)

diff --git a/system/tests/easel-credits.test.mjs b/system/tests/easel-credits.test.mjs new file mode 100644 index 0000000000..df1a4f7725 --- /dev/null +++ b/system/tests/easel-credits.test.mjs @@ -0,0 +1,51 @@ +import test from "node:test"; +import assert from "node:assert/strict"; +import { createHandler } from "../netlify/functions/easel-credits.mjs"; +const event = { httpMethod: "GET", headers: { authorization: "Bearer test" } }; +const deps = { + authorize: async () => ({ sub: "user" }), + getHandleOrEmail: async () => "@tester", + checkBudget: async () => ({ + day: "2026-09-18", + used: 0, + budget: 200000, + remaining: 200000, + }), + paidBalance: async () => 1000000, +}; +test("braincell balance values free and purchased allowance at the actual pack rate", async () => { + const result = await createHandler(deps)(event); + assert.equal(result.statusCode, 200); + const value = JSON.parse(result.body); + assert.deepEqual(value.dollars, { + currency: "USD", + free: 1, + purchased: 5, + total: 6, + }); + assert.equal(value.offer, null); // The balance is valued even when checkout is disabled. + const alternate = JSON.parse( + ( + await createHandler({ + ...deps, + creditPack: { amount: 1000, credits: 1000000 }, + })(event) + ).body, + ); + assert.equal(alternate.dollars.total, 12); +}); +test("unknown allowances and malformed purchased balances never become dollar balances", async () => { + for (const changes of [ + { paidBalance: async () => NaN }, + { paidBalance: async () => -10 }, + { checkBudget: async () => ({ unknown: true }) }, + ]) { + const result = await createHandler({ ...deps, ...changes })(event); + assert.equal(result.statusCode, 503); + assert.equal(JSON.parse(result.body).dollars, undefined); + } + assert.equal( + (await createHandler(deps)({ ...event, headers: {} })).statusCode, + 401, + ); +}); -- 2.51.2