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 @@
-