From 8c745007db4cf99c71dc4c5502d2801a80af1107 Mon Sep 17 00:00:00 2001 From: "prompt.ac/@jeffrey" Date: Tue, 6 Oct 2026 13:07:47 -0700 Subject: [PATCH] Add a No Paint hint field that steers cloud moves from the status bar Paint is now a plain run button; the bar holds a compact hint capsule and braincell balance. The inference endpoint accepts an optional bounded hint, folds it into the move identity, and lets it replace the seeded operation in the move prompt. Co-Authored-By: Claude Opus 5.5 --- .../ac_account.mjs | 3 +- .../nopaint-ai-moves-2026-10-05/ac_run.py | 8 ++- .../native/Sources/NoPaint/NoPaint.swift | 66 +++++++++++++++---- .../Tests/NoPaintTests/StatusTests.swift | 6 +- .../nopaint-ai-moves-2026-10-05/play.py | 12 ++-- system/backend/nopaint-move-prompt.mjs | 4 +- system/backend/nopaint-provider.mjs | 6 +- system/tests/nopaint-inference.test.mjs | 8 +++ 8 files changed, 86 insertions(+), 27 deletions(-) diff --git a/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs b/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs index a201dc766d..4544ec3fca 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs +++ b/experiments/nopaint-ai-moves-2026-10-05/ac_account.mjs @@ -87,7 +87,8 @@ export function createAccountBridge({session=new ACSession(), site=SITE, fetch=g if (input.account_id !== id) throw Error('Your AC account changed. Sign in again before generating.'); const image = (await readFile(input.before)).toString('base64'); return request('/api/nopaint-inference', token, {requestId:input.requestId, - image, seed:input.seed, strength:input.strength, quote:input.quote, model:input.model}); + image, seed:input.seed, strength:input.strength, quote:input.quote, model:input.model, + ...(input.hint ? {hint:input.hint} : {})}); } throw Error('Unknown AC account action'); }; diff --git a/experiments/nopaint-ai-moves-2026-10-05/ac_run.py b/experiments/nopaint-ai-moves-2026-10-05/ac_run.py index b026ef5fe6..9ec21a571a 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/ac_run.py +++ b/experiments/nopaint-ai-moves-2026-10-05/ac_run.py @@ -57,7 +57,7 @@ class Account: def move(pipe, embed, before, folder, step, strength, seed, cold=False, observe=None, - cancel=None, account=None, identity=None, engine="ac-klein"): + cancel=None, account=None, identity=None, engine="ac-klein", hint=None): identity = identity or account.snapshot() offer = next((item for item in identity.get("models", []) if item["id"] == engine), None) or (identity.get("remote") if engine == "ac-klein" else None) or {} if not identity.get("connected") or not offer.get("available"): @@ -71,12 +71,14 @@ def move(pipe, embed, before, folder, step, strength, seed, cold=False, observe= row = {"status": "running", "request_id": request_id, "handle": identity["handle"], "quoted_braincells": offer["braincells"], "identity": {"model": offer["model"], "input_sha256": sha(before), - "seed": seed, "strength": strength, "quote": offer["quote"], "size": [256, 256]}} + "seed": seed, "strength": strength, "quote": offer["quote"], "size": [256, 256], + **({"hint": hint} if hint else {})}} write(receipt, row) started = time.monotonic() request = {"action": "move", "before": str(before), "requestId": request_id, "seed": seed, "strength": strength, "quote": offer["quote"], - "account_id": identity["account_id"], "model": engine} + "account_id": identity["account_id"], "model": engine, + **({"hint": hint} if hint else {})} try: result = bridge(request, timeout=260, cancel=cancel) check_cancel(cancel) diff --git a/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift b/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift index 41832ecfd2..3557c52a92 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift +++ b/experiments/nopaint-ai-moves-2026-10-05/native/Sources/NoPaint/NoPaint.swift @@ -109,6 +109,7 @@ struct StatusIssue { @MainActor final class GameStore: ObservableObject { @Published var state: GameState? + @Published var hint = "" @Published var image: NSImage? @Published var imagePath = "" @Published var imageSequence = "" @@ -187,7 +188,7 @@ struct StatusIssue { guard pollTask == nil else { return } keyMonitor = NSEvent.addLocalMonitorForEvents(matching: [.keyDown, .keyUp]) { [weak self] event in guard let self, NSApp.keyWindow?.identifier?.rawValue == "NoPaintMain", - !self.showModels, !self.showHistory, + !self.showModels, !self.showHistory, !(NSApp.keyWindow?.firstResponder is NSTextView), event.modifierFlags.intersection([.command,.control,.option]).isEmpty else { return event } let key = event.charactersIgnoringModifiers?.lowercased() if key == " " { self.peek(event.type == .keyDown); return nil } @@ -259,10 +260,20 @@ struct StatusIssue { if state.engine.hasPrefix("ac-"), state.models.first(where: { $0.id == state.engine })?.braincells == nil { return ["Not enabled", "Choose a model"] } return ["Unavailable", "Choose a model below"] } - let time = generationTime(date) let amount = state?.quote?.braincells ?? state?.cost?.braincells - let cost = amount.map { "\($0.formatted()) braincells" } ?? "braincells pending" - return [time, cost] + return [generationTime(date), amount.map(Self.braincellCost) ?? "braincells pending"] + } + + // Only AC cloud models read the hint; local engines paint without words. + var hintable: Bool { state?.engine.hasPrefix("ac-") == true } + + // AC sells 1,000,000 braincells for $5, so the dollar figure is that pack's rate. + static let braincellsPerUSD = 200_000.0 + static func braincells(_ amount: Int) -> String { "🧠 " + amount.formatted(.number.notation(.compactName)) } + static func braincellCost(_ amount: Int) -> String { + guard amount > 0 else { return "free" } + let usd = Double(amount) / braincellsPerUSD + return braincells(amount) + " · " + String(format: usd < 0.01 ? "$%.4f" : "$%.2f", usd) } func generationTime(_ date: Date) -> String { @@ -346,6 +357,7 @@ struct StatusIssue { if let candidate = state.candidate { body["candidate"] = candidate.id } if let generation = state.generation { body["generation"] = generation } if action == "paint", let quote = state.quote { body["quote"] = quote.id } + if action == "paint", hintable, !hint.trimmingCharacters(in: .whitespaces).isEmpty { body["hint"] = hint } body.merge(extra) { _, new in new } Task { defer { sending = false } @@ -530,13 +542,17 @@ struct ContentView: View { let identity: [StatusBand.Field] if account?.connected == true { let balance = (account?.remaining ?? 0) + (account?.purchased ?? 0) - identity = [.init(text: account?.handle ?? "", weight: .medium), .init(text: "\(balance.formatted()) \(account?.stale == true ? "last known" : "left")", numeric: true)] + identity = [.init(text: account?.handle ?? "", weight: .medium), .init(text: (account?.stale == true ? "~" : "") + GameStore.braincells(balance), numeric: true)] } else { identity = [.init(text: account?.reconnecting == true ? "Reconnecting…" : account?.working == true ? "Signing in…" : "Sign in", weight: .medium)] } let serviceText = game.statusIssue?.title ?? (game.cloudUnavailable ? "AC cloud unavailable" : nil) let service: [StatusBand.Field] = serviceText.map { [.init(text: $0, size: 11, weight: .medium)] } ?? [] let price = game.state?.quote?.braincells ?? model?.paintPrice - let capacity: [StatusBand.Field] = price.map { [.init(text: "\(game.paintsLeft($0)) paints left", size: 11, numeric: true)] } ?? [] - return StatusBand.Content(groups: [modelFields, service, identity, capacity], context: game.upscaling ? "Local · 0 Braincells" : model?.location) + let capacity: [StatusBand.Field] = price.map { [.init(text: "×\(game.paintsLeft($0))", size: 11, numeric: true)] } ?? [] + let details = game.generationStatus(date) + // The bar speaks up only when Paint can't run or something else is working. + let move: [StatusBand.Field] = details.first == game.generationTime(date) ? [] + : [.init(text: details.joined(separator: " "), size: 11, weight: .medium)] + return StatusBand.Content(groups: [modelFields, move, service, identity, capacity], context: game.upscaling ? "Local · 0 Braincells" : model?.location) } func canvasSide(_ size: CGSize, status: StatusBand.Content, compact: Bool) -> CGFloat { let reserved: CGFloat = compact ? 52 : 76 @@ -565,16 +581,15 @@ struct ContentView: View { .onHover { game.hoverNo($0) } .help("Hover to preview going back. Click to go back one move or cancel an unfinished move. (N)") Button { game.act("paint") } label: { - VStack(spacing: max(3, min(8, buttonHeight / 16))) { - let details = game.generationStatus(time.date) - Text(details.first ?? "— s").font(.system(size: buttonFont, weight: .semibold).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.5) - if details.count > 1 { Text(details[1]).font(.system(size: max(10, min(15, side / 18))).monospacedDigit()).lineLimit(1).minimumScaleFactor(0.7) } - }.padding(.horizontal, 5).frame(width: side / 2, height: buttonHeight).contentShape(Rectangle()) + Text("Paint").frame(width: side / 2, height: buttonHeight).contentShape(Rectangle()) }.buttonStyle(PaintButton(dark: true, height: buttonHeight, fontSize: buttonFont)).disabled(!game.canPaint || game.pendingDone) .accessibilityLabel("Paint: confirm one generation").accessibilityValue(game.generationStatus(time.date).joined(separator: ", ")) .help(game.busy ? "Generating this confirmed move" : game.state?.quote == nil ? (game.state?.account.remote_status ?? "Choose an available model below.") : "Confirm this move and Braincell cost. ~ means estimated time; — means not measured yet. (P)") }.frame(width: side) - StatusBand(game: game, content: label).frame(width: geometry.size.width, height: bandHeight) + HStack(spacing: 0) { + HintField(game: game).frame(width: min(240, geometry.size.width * 0.36)) + StatusBand(game: game, content: label) + }.frame(width: geometry.size.width, height: bandHeight) .background(Color.primary.opacity(0.06)) .overlay(Rectangle().strokeBorder(Color.primary.opacity(0.35), lineWidth: 1).allowsHitTesting(false)) }.frame(width: geometry.size.width).frame(maxHeight: .infinity, alignment: .top) @@ -587,6 +602,31 @@ struct ContentView: View { } } +struct HintField: View { + @ObservedObject var game: GameStore + @FocusState private var focused: Bool + var body: some View { + HStack(spacing: 5) { + Image(systemName: "sparkle").font(.system(size: 10, weight: .semibold)) + .foregroundStyle(focused ? Color(red: 0.85, green: 0.24, blue: 0.44) : .secondary) + TextField("", text: $game.hint, prompt: Text(game.hintable ? "hint" : "hint · cloud only")) + .textFieldStyle(.plain).font(.system(size: 12, weight: .medium)).focused($focused) + .onSubmit { if game.canPaint && !game.pendingDone { game.act("paint") }; focused = false } + .onExitCommand { focused = false } + if !game.hint.isEmpty { + Button { game.hint = "" } label: { Image(systemName: "xmark.circle.fill").font(.system(size: 10)) } + .buttonStyle(.plain).foregroundStyle(.tertiary).help("Clear hint") + } + } + .padding(.horizontal, 9).frame(height: 22) + .background(Capsule().fill(Color.primary.opacity(focused ? 0.1 : 0.05))) + .overlay(Capsule().strokeBorder(Color.primary.opacity(focused ? 0.4 : 0.18), lineWidth: 1)) + .padding(.leading, 6).padding(.trailing, 2) + .disabled(!game.hintable) + .help("A word or phrase to steer the next cloud Paint along with your image. Return paints.") + } +} + struct ModelBrowserView: View { @ObservedObject var game: GameStore @State private var search = "" diff --git a/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift b/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift index d1e008609a..b36b19a177 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift +++ b/experiments/nopaint-ai-moves-2026-10-05/native/Tests/NoPaintTests/StatusTests.swift @@ -15,7 +15,7 @@ import SwiftUI func testConnectionDetailsStayOutOfPaintAndDoNotResizeTheCanvas() async throws { let game=GameStore();game.state=try state("Timed out") game.imagePath="/image/input.png";game.imageSettled=true - XCTAssertEqual(game.generationStatus(Date()),["~4.0s","0 braincells"]) + XCTAssertEqual(game.generationStatus(Date()),["~4.0s","free"]) XCTAssertTrue(game.canPaint) XCTAssertFalse(game.canDone) XCTAssertEqual(game.statusIssue?.title,"AC offline") @@ -23,10 +23,10 @@ import SwiftUI let before=view.canvasSide(size,status:view.status(Date()),compact:false) game.state=try state(String(repeating:"Long network error details. ",count:80)) XCTAssertEqual(view.canvasSide(size,status:view.status(Date()),compact:false),before) - XCTAssertEqual(game.generationStatus(Date()),["~4.0s","0 braincells"]) + XCTAssertEqual(game.generationStatus(Date()),["~4.0s","free"]) game.actionError="An action failed with a detailed explanation" XCTAssertEqual(game.statusIssue?.title,"Action failed") - XCTAssertEqual(game.generationStatus(Date()),["~4.0s","0 braincells"]) + XCTAssertEqual(game.generationStatus(Date()),["~4.0s","free"]) } func testSmallSlabTileKeepsOneStatusLineAndRoomForThePainting() async throws { diff --git a/experiments/nopaint-ai-moves-2026-10-05/play.py b/experiments/nopaint-ai-moves-2026-10-05/play.py index a7fd8bff06..c49bcacf4d 100644 --- a/experiments/nopaint-ai-moves-2026-10-05/play.py +++ b/experiments/nopaint-ai-moves-2026-10-05/play.py @@ -336,7 +336,7 @@ class Game: amount = row.get("quoted_braincells", quoted) return {"braincells": amount, "status": "quoted" if amount is not None else "pending"} - def begin(self, quote_id): + def begin(self, quote_id, hint=None): """Only Paint can confirm the exact displayed offer and start work.""" if self.busy or not self.quote or quote_id != self.quote["id"]: raise Conflict("The move changed. Review the current cost before painting.") @@ -360,7 +360,7 @@ class Game: self.job = {"number": self.attempt, "engine": self.engine, "before": self.history[-1], "strength": quote["strength"], "mask": self.mask, "seed": quote["seed"], "cancel": threading.Event(), - "started": False, "cost": self.cost.copy()} + "started": False, "cost": self.cost.copy(), "hint": hint} if self.engine.startswith("ac-"): self.job["account"] = self.quote_identity self.quote = self.quote_identity = None @@ -399,7 +399,7 @@ class Game: output = self.move(self.pipe, self.embed, *args, cold=number == 1, observe=observe, cancel=job["cancel"], **region.kwargs, - **({"account": self.account, "identity": job["account"], "engine": job["engine"]} if job["engine"].startswith("ac-") else {})) + **({"account": self.account, "identity": job["account"], "engine": job["engine"], "hint": job["hint"]} if job["engine"].startswith("ac-") else {})) output = region.finish(output) with self.lock: check_cancel(job["cancel"]) @@ -549,7 +549,11 @@ class Game: self.mask = save_mask(data.get("mask"), self.folder / "masks") self.mask_bits = encode_mask(self.mask) if self.mask else None elif action == "paint": - self.begin(data.get("quote")) + # A hint steers cloud moves; local engines ignore it. + hint = data.get("hint") + if hint is not None and (not isinstance(hint, str) or len(hint) > 200): + raise ValueError("Keep the hint under 200 characters.") + self.begin(data.get("quote"), (hint or "").strip() or None) return self.state() elif action == "no": if self.busy: diff --git a/system/backend/nopaint-move-prompt.mjs b/system/backend/nopaint-move-prompt.mjs index 2a255272b4..75e9f03c6b 100644 --- a/system/backend/nopaint-move-prompt.mjs +++ b/system/backend/nopaint-move-prompt.mjs @@ -19,7 +19,7 @@ const operations = [ 'Turn the affected region into a dense stipple pattern of small dots, sampled from its original colors and brightness.', ]; -export function movePrompt({seed, strength, model}) { +export function movePrompt({seed, strength, model, hint}) { // Integer mixing spreads neighboring seeds across the operation palette. let value=(seed ^ 0x9e3779b9) >>> 0; value=Math.imul(value ^ (value >>> 16),0x21f0aaad) >>> 0; @@ -28,6 +28,8 @@ export function movePrompt({seed, strength, model}) { const region = strength===.75 ? 'the entire image' : strength===.5 ? ['the left half','the right half','the upper half','the lower half'][(value >>> 8)%4] + ' of the image' : ['upper-left','upper-right','lower-left','lower-right'][(value >>> 8)%4] + ' quarter of the image'; + // A painter's hint replaces the seeded operation but keeps the same frame. + if (hint) return `Transform this input bitmap by one move, guided by the painter's hint: "${hint}". The affected region is ${region}. Apply a clearly visible transformation, not a subtle touch-up. Keep pixels outside the affected region unchanged. Retain the existing rendering style and source material unless the hint asks otherwise. Do not add a border. Return one complete transformed image at the original composition and aspect ratio.`; let operation=value%operations.length; // These editors copied the input under exact strip/permutation instructions // in live checks. Give them visual edits rather than pixel arithmetic. diff --git a/system/backend/nopaint-provider.mjs b/system/backend/nopaint-provider.mjs index c28b2ef396..1a2cdc6e4a 100644 --- a/system/backend/nopaint-provider.mjs +++ b/system/backend/nopaint-provider.mjs @@ -18,12 +18,14 @@ export function moveInput(body) { if (!body || typeof body !== 'object' || Array.isArray(body)) throw fail(400, 'Expected a move'); if (!/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(body.requestId || '')) throw fail(400, 'Move ID must be a UUID'); if (![.25,.5,.75].includes(body.strength) || !Number.isInteger(body.seed) || body.seed < 0 || body.seed >= 2**31) throw fail(400, 'Invalid move settings'); + if (body.hint !== undefined && (typeof body.hint !== 'string' || body.hint.length > 200)) throw fail(400, 'Hint must be text under 200 characters'); + const hint = body.hint?.replace(/\s+/g,' ').trim() || undefined; try { imageInputBound({messages:[{type:'image',source:{type:'base64',media_type:'image/png',data:body.image}}]}); } catch { throw fail(400, 'Expected a complete PNG image'); } const png = Buffer.from(body.image,'base64'); if (png.readUInt32BE(16)!==256 || png.readUInt32BE(20)!==256 || png[24]!==8 || ![2,6].includes(png[25])) throw fail(400, 'Expected a 256×256 RGB PNG'); - const hash = createHash('sha256').update(png).update(JSON.stringify([body.seed,body.strength,body.quote])).digest('hex'); - return {...body, hash}; + const hash = createHash('sha256').update(png).update(JSON.stringify([body.seed,body.strength,body.quote,hint ?? null])).digest('hex'); + return {...body, hint, hash}; } export function createNoPaintProvider({key, fetch=globalThis.fetch, sleep=ms=>new Promise(r=>setTimeout(r,ms))}) { diff --git a/system/tests/nopaint-inference.test.mjs b/system/tests/nopaint-inference.test.mjs index 439037f38e..e147b8e06d 100644 --- a/system/tests/nopaint-inference.test.mjs +++ b/system/tests/nopaint-inference.test.mjs @@ -31,6 +31,14 @@ test('only bounded complete 256 RGB images and valid settings reach generation', const other=Buffer.from(image,'base64');other.writeUInt32BE(1024,16); assert.throws(()=>moveInput({...input,image:other.toString('base64')}),{status:400}); }); +test('a hint is bounded, changes the move identity, and steers the prompt',async()=>{ + const {movePrompt}=await import('../backend/nopaint-move-prompt.mjs'); + const input=JSON.parse(event().body), plain=moveInput(input), hinted=moveInput({...input,hint:' more moss '}); + assert.equal(hinted.hint,'more moss');assert.notEqual(hinted.hash,plain.hash); + for(const hint of [7,'x'.repeat(201)]) assert.throws(()=>moveInput({...input,hint}),{status:400}); + assert.match(movePrompt(hinted),/painter's hint: "more moss"/); + assert.doesNotMatch(movePrompt(plain),/hint/); +}); test('identity and price come from the server; replay never charges or generates twice',async()=>{ const {handler,calls}=fixture(); const input=event({handle:'@victim',user:'victim',braincells:1}); -- 2.51.2