diff --git a/GUIDE.md b/GUIDE.md index 73f1a2a..0ef936c 100644 --- a/GUIDE.md +++ b/GUIDE.md @@ -24,8 +24,7 @@ Lode is **right ⌘** (configurable in `~/.config/lodestar/lodestar.json`). | `lode ;` / `lode ⇧;` | Click hints on the focused window — `⇧;` chains clicks (sticky) | | `lode /` | Select text by typing it — lowercase searches, `⇧letter` anchors | | `lode [` / `lode ]` | Move the focused window to the prev/next display (`⇧` = arrive beside) | -| `lode Z` / `lode ⇧Z` | Undo / redo the layout (summons, besides, flips, breaths) | -| `lode X` / `lode ⇧X` | Back / forward — walk the attention timeline (previous destinations) | +| `lode ←` / `lode →` | Undo / redo the layout (summons, besides, flips, breaths) | | `lode ⇧1…9` | Slide the focused window to that position (insert-and-shift, 9 = last) | | `lode 0` / `lode ⇧0` | The focused window fills the display, rest parked — `⇧0` joins beside | | `⌫` inside breaths | Arm delete — the next path typed is deleted, not visited | @@ -36,7 +35,7 @@ Lode is **right ⌘** (configurable in `~/.config/lodestar/lodestar.json`). ## Chains are sticky, on purpose -Once a traversal starts (`lode W`, `` lode ` ``, …) it waits **indefinitely** — the gesture is identical whether you finish it in 80ms or after a phone call. The glass guide panel stays on screen the whole time showing your prefix and every legal continuation (with app icons); a wrong letter keeps you in place with a note rather than ejecting you. Only a completion or `esc` ends the chain. While a chain is active, stray keystrokes are swallowed (they never leak into the focused app). And holding lode by itself for half a second peeks the top-level guide — the system teaches its own map. +Once a traversal starts (`lode W`, `lode '`, …) it waits **indefinitely** — the gesture is identical whether you finish it in 80ms or after a phone call. The glass guide panel stays on screen the whole time showing your prefix and every legal continuation (with app icons); a wrong letter keeps you in place with a note rather than ejecting you. Only a completion or `esc` ends the chain. While a chain is active, stray keystrokes are swallowed (they never leak into the focused app). And holding lode by itself for half a second peeks the top-level guide — the system teaches its own map. ## Launcher ranking & teaching @@ -58,7 +57,7 @@ Results are ranked by fuzzy match quality **plus frecency** — every summon (gr **They are left alone.** A window born outside Lodestar — a launcher, the Dock, `⌘N`, a certificate prompt, a file reveal — floats untouched above your layout, exactly as macOS would have it, and never hides what you were reading. System and accessibility floaters (notch apps, Control Center, overlay slivers) belong to accessory processes the model never even tracks. -**`lode 0` makes the focused window fill the display** when you want it managed: the summon treatment on demand, everything already in the layout parked, `⇧0` to join beside instead. It also **enrols** the window, so from then on it answers to `lode 1…9`, breaths, orientation flips, and `lode Z` like anything Lodestar summoned. That is the only way an unmanaged window becomes managed — Lodestar never decides on its own that a new window is a destination, and nothing is ever parked except what a placement displaced. +**`lode 0` makes the focused window fill the display** when you want it managed: the summon treatment on demand, everything already in the layout parked, `⇧0` to join beside instead. It also **enrols** the window, so from then on it answers to `lode 1…9`, breaths, orientation flips, and `lode ←` like anything Lodestar summoned. That is the only way an unmanaged window becomes managed — Lodestar never decides on its own that a new window is a destination, and nothing is ever parked except what a placement displaced. ## The clipboard @@ -203,6 +202,6 @@ it. A yaml that fails to parse is never converted. - **`lodestar --check`** — full validation from the CLI, exit code included: `~/Applications/lodestar.app/Contents/MacOS/lodestar --check`. - **`keys:`** overlays the built-in ANSI keycode table for non-ANSI layouts (`keycode: name`). - **Crash self-healing**: the LaunchAgent restarts Lodestar after a crash (10s throttle) but never after a clean Quit. -- **Back (`lode X`)** pairs with undo: Z rewinds arrangements, X rewinds attention. History records every focus change (however it happened), back-jumps summon with the standard placement rules, a fresh navigation truncates the forward branch, and dead windows are skipped. +- **Every letter belongs to the graph.** As of 0.17 no letter is reserved: orientation moved to `\` in 0.14.2, the attention timeline was retired, and layout undo moved to `lode ←` / `lode →`, where walking a history is what the arrows already mean. A verb that sits on a letter costs an address forever, and an address is the scarcer thing. - Menu harvesting and scroll-pane discovery run off the main thread — a hung app can no longer freeze Lodestar's UI; the panel opens instantly and rows arrive when ready. - **Logs are logfmt** (`15:04 INFO summon target=Slack candidates=[8688]`) — greppable and machine-parseable — rotating at 5MB with two predecessors kept (`.1`, `.2`), bounded ~15MB, history preserved. diff --git a/Sources/LodestarCore/Analysis/Advisor.swift b/Sources/LodestarCore/Analysis/Advisor.swift index 50ac859..f1d974d 100644 --- a/Sources/LodestarCore/Analysis/Advisor.swift +++ b/Sources/LodestarCore/Analysis/Advisor.swift @@ -12,8 +12,10 @@ import Foundation /// chip actionable: the coach's verb is exactly one line, and anything /// that cannot be one line stays in the report. public enum ConfigEdit: Codable, Equatable { - /// Bind an app at a chain (bind and shorten both land here). - case bindApp(chain: [String], app: String) + /// Bind a graph target at a chain (bind and shorten both land here). + /// `target` is what the config line writes — an app name, or + /// `brave:xonar` for a browser profile. Never a display label. + case bindTarget(chain: [String], target: String) /// Remove a binding (retire). case removeChain(chain: [String]) /// One route line: pattern → profile registry key. @@ -46,28 +48,53 @@ public struct Recommendation: Codable, Equatable { /// Posterior probability the change saves net time over the horizon. public var probability: Double public var evidence: [String] + /// What to call the target on screen, when that differs from what the + /// edit writes: a browser profile shows as "Brave (Xonar)" and commits + /// "brave:xonar". Nil means the edit's own target already reads as a + /// name a person would recognise. + public var display: String? /// The write this recommendation proposes, when it is one config line. /// Nil means report-only: the chip never offers what it cannot commit. public var edit: ConfigEdit? public init(kind: Kind, target: String, detail: String, secondsPerWeek: Double, - probability: Double, evidence: [String], edit: ConfigEdit? = nil) { + probability: Double, evidence: [String], display: String? = nil, + edit: ConfigEdit? = nil) { self.kind = kind self.target = target self.detail = detail self.secondsPerWeek = secondsPerWeek self.probability = probability self.evidence = evidence + self.display = display self.edit = edit } } public enum Advisor { + /// One bound address, as the advisor needs to see it. + public struct Leaf { + public let chain: [String] + /// What a person calls it — mnemonic letters and chip text come + /// from here. + public let label: String + /// What a config line writes to bind the same target. For a browser + /// profile the two differ ("Brave (Xonar)" against "brave:xonar"), + /// and an edit that commits the label silently binds plain Brave. + public let value: String + + public init(chain: [String], label: String, value: String) { + self.chain = chain + self.label = label + self.value = value + } + } + public struct Context { public var observations: Observations public var events: [ObservationEvent] - /// Bound chains and their target labels, from the live graph. - public var leaves: [(chain: [String], label: String)] + /// Bound chains and their targets, from the live graph. + public var leaves: [Leaf] /// The web route table, so an already-covered host stays quiet. public var webRoutes: [String: String] /// Observed profile identity ("brave:Work") → registry key ("work"), @@ -76,7 +103,7 @@ public enum Advisor { public var now: Date public init(observations: Observations, events: [ObservationEvent], - leaves: [(chain: [String], label: String)], + leaves: [Leaf], webRoutes: [String: String] = [:], profileKeys: [String: String] = [:], now: Date = Date()) { self.observations = observations @@ -96,6 +123,21 @@ public enum Advisor { static let horizonHalfLife = 12.0 static let probabilityGate = 0.9 + /// A stable seed for the Monte Carlo. `hashValue` cannot do this job: + /// Swift randomises it per process, so seeding from it made every run + /// draw a different sample — the precise opposite of what `Random` + /// promises below, and enough to make a candidate sitting near the + /// gate appear and vanish between runs. FNV-1a over the bytes is + /// stable, and total (`abs()` on `Int.min` would have trapped). + static func seed(_ text: String) -> UInt64 { + var hash: UInt64 = 0xcbf2_9ce4_8422_2325 + for byte in text.utf8 { + hash ^= UInt64(byte) + hash = hash &* 0x0000_0100_0000_01B3 + } + return hash + } + /// Deterministic LCG so the Monte Carlo is replayable in tests. struct Random { private var state: UInt64 @@ -138,13 +180,11 @@ public enum Advisor { // MARK: - Shared - /// Letters the grammar itself owns: X walks the timeline, Z undoes. - /// (O flipped orientation until 0.14.2, when the verb moved to \ and - /// the letter went back to being an address.) A graph slot here would - /// be shadowed by the verb, and the coach must never offer what the - /// grammar reserves — found the hard way when Zoom's mnemonic Z - /// nearly cleared the gates. - static let reservedLetters: Set = ["x", "z"] + /// Letters the grammar itself owns — the coach must never offer what + /// the grammar reserves, found the hard way when Zoom's mnemonic Z + /// nearly cleared the gates. Read from `Gestures`, never copied: this + /// constant drifted out of date behind two separate key moves. + static let reservedLetters: Set = Gestures.reservedLetters static func freeLetters(_ context: Context) -> Set { let taken = Set(context.leaves.compactMap { $0.chain.first }) @@ -156,12 +196,21 @@ public enum Advisor { /// then what the user actually types into the launcher to find it. static func mnemonicLetters(app: String, record: Observations.AppRecord?) -> [String] { var letters: [String] = [] - for word in app.lowercased().split(separator: " ") { - if let first = word.first, first.isLetter { letters.append(String(first)) } + // Split on anything that is not a letter or digit, and take each + // word's first *letter* rather than its first character. Splitting + // on spaces alone dropped every parenthesised word — and Lodestar + // writes those itself (`Graph.Target.label` renders a browser + // profile as "Brave (Xonar)"), so the distinguishing half of every + // profile name was invisible here. The same slip hid "1Password" + // behind its digit. + for word in app.lowercased().split(whereSeparator: { !$0.isLetter && !$0.isNumber }) { + if let first = word.first(where: { $0.isLetter }) { letters.append(String(first)) } } if let prefixes = record?.prefixes { for (prefix, _) in prefixes.sorted(by: { $0.value > $1.value }) { - if let first = prefix.first, first.isLetter { letters.append(String(first)) } + if let first = prefix.first(where: { $0.isLetter }) { + letters.append(String(first)) + } } } var seen = Set() @@ -217,7 +266,7 @@ public enum Advisor { perUseSavedSD: max(0.1, launcherSD), usesPerWeek: demand.perWeek, usesPerWeekSE: demand.se, oneOffCost: learningCost, - seed: UInt64(abs(app.hashValue))) + seed: seed(app)) guard value.probability >= probabilityGate else { continue } out.append((Recommendation( kind: .bind, target: app, @@ -229,7 +278,7 @@ public enum Advisor { String(format: "launcher costs %.2fs; lode %@ predicted %.2fs", launcherSeconds, slot.uppercased(), chainSeconds), String(format: "learning bill ≈ %.0fs, from your own curves", learningCost), - ], edit: .bindApp(chain: [slot], app: app)), 1 - value.probability)) + ], edit: .bindTarget(chain: [slot], target: app)), 1 - value.probability)) } return out } @@ -289,10 +338,29 @@ public enum Advisor { let proposed = latency.chainSeconds([slot]) let saved = current - proposed guard saved > 0.05 else { continue } - let value = netBenefit(perUseSavedMean: saved, perUseSavedSD: latency.residualSD * saved, + // The saving is a difference of two *means*, so what matters is + // how well each mean is known — not how much a single keystroke + // varies. Pricing it as `residualSD * saved` made + // P(saves time) = Φ(1 / residualSD) for every candidate alike: + // a constant, unmoved by the size of the saving or by the + // evidence behind it, and at this user's spread it sat at 0.85 + // against a 0.9 gate, so no shorten could ever be offered. + // The chain being replaced has been completed this many times, + // so its mean tightens as √n; the proposed chain has never been + // typed at all, so it keeps the full population spread. + // + // Count *completions*, not `latency.fluency[key]?.n`: that is one + // sample per keystroke gap, so a two-letter chain would claim √2 + // more evidence than it has — and the gaps inside one completion + // are correlated anyway, so they are not independent draws. + let currentSD = current * latency.residualSD + / Double(max(1, record.completions)).squareRoot() + let proposedSD = proposed * latency.residualSD + let savedSD = (currentSD * currentSD + proposedSD * proposedSD).squareRoot() + let value = netBenefit(perUseSavedMean: saved, perUseSavedSD: savedSD, usesPerWeek: weekly, usesPerWeekSE: weekly.squareRoot(), oneOffCost: learningCost, - seed: UInt64(abs(key.hashValue))) + seed: seed(key)) guard value.probability >= probabilityGate else { continue } let shown = "lode " + leaf.chain.map { $0.uppercased() }.joined(separator: " ") out.append((Recommendation( @@ -302,7 +370,8 @@ public enum Advisor { secondsPerWeek: value.secondsPerWeek, probability: value.probability, evidence: [String(format: "%.2fs now vs %.2fs shortened, %d completions", current, proposed, record.completions)], - edit: .bindApp(chain: [slot], app: leaf.label)), + display: leaf.label, + edit: .bindTarget(chain: [slot], target: leaf.value)), 1 - value.probability)) } return out diff --git a/Sources/LodestarCore/Analysis/Coach.swift b/Sources/LodestarCore/Analysis/Coach.swift index 67dbfbc..6376676 100644 --- a/Sources/LodestarCore/Analysis/Coach.swift +++ b/Sources/LodestarCore/Analysis/Coach.swift @@ -130,7 +130,9 @@ public enum Coach { switch rec.kind { case .bind, .nudge: return .app(rec.target) case .shorten: - if case .bindApp(_, let app)? = rec.edit { return .app(app.lowercased()) } + if case .bindTarget(_, let target)? = rec.edit { + return .app((rec.display ?? target).lowercased()) + } return nil case .route: return .host(rec.target) case .rebind, .retire, .breath: return nil @@ -145,9 +147,12 @@ public enum Coach { var evidence = rec.detail switch rec.kind { case .bind, .shorten: - if case .bindApp(let chain, let app)? = rec.edit { + if case .bindTarget(let chain, let target)? = rec.edit { let shown = chain.map { $0.uppercased() }.joined(separator: " ") - headline = "lode \(shown) → \(app)" + // The label, never the config value: a chip saying + // "lode X → brave:xonar" quotes the machinery at someone + // who only ever asked for their browser. + headline = "lode \(shown) → \(rec.display ?? target)" } else { headline = rec.target } diff --git a/Sources/LodestarCore/Config.swift b/Sources/LodestarCore/Config.swift index f41f927..9018463 100644 --- a/Sources/LodestarCore/Config.swift +++ b/Sources/LodestarCore/Config.swift @@ -97,10 +97,10 @@ public struct Config { /// rolls back still finds its config. Retired at 1.0. public static let yamlFile = directory.appendingPathComponent("lodestar.yaml") - /// Top-level chain letters the primitives own; the graph may not use them. - /// First letters the grammar keeps for verbs: Z undo, X timeline. - /// O rejoined the graph in 0.14.2 when orientation moved to \. - public static let reservedTopLevel: Set = ["z", "x"] + /// Top-level chain letters the primitives own; the graph may not use + /// them. Defined once in `Gestures` so a key move cannot leave a stale + /// copy here — which is exactly what happened twice. + public static let reservedTopLevel: Set = Gestures.reservedLetters /// The schema: one table driving reload validation and the JSON Schema /// editors read. Keep in lockstep with `parse` below. diff --git a/Sources/LodestarCore/ConfigDefaults.swift b/Sources/LodestarCore/ConfigDefaults.swift index c4f1c91..d63730d 100644 --- a/Sources/LodestarCore/ConfigDefaults.swift +++ b/Sources/LodestarCore/ConfigDefaults.swift @@ -27,6 +27,13 @@ public enum ConfigDefaults { if gestures["launcher"] == nil { gestures["launcher"] = searcher } out["gestures"] = .table(gestures) } + // The attention timeline was retired in 0.17. Every config written + // before then carries its switch, and a verb that no longer exists + // must not make an otherwise-valid file report an unknown key. + if case .table(var gestures)? = out["gestures"], + gestures.removeValue(forKey: "back-forward") != nil { + out["gestures"] = .table(gestures) + } return out } diff --git a/Sources/LodestarCore/Engine.swift b/Sources/LodestarCore/Engine.swift index 0fd9e09..907ed66 100644 --- a/Sources/LodestarCore/Engine.swift +++ b/Sources/LodestarCore/Engine.swift @@ -63,8 +63,6 @@ public enum EngineEffect: Equatable { case flipOrientation case undoLayout case redoLayout - case goBack - case goForward case indexJump(Int) case reorder(Int) case moveDisplay(direction: Int, beside: Bool) @@ -318,10 +316,10 @@ public struct EngineCore { // The key wearing the vertical bar flips the layout — moved // off O so the letter can go back to being an address. effects.append(.flipOrientation) - case "z": - effects.append(shift ? .redoLayout : .undoLayout) - case "x": - effects.append(shift ? .goForward : .goBack) + case "left": + effects.append(.undoLayout) + case "right": + effects.append(.redoLayout) case "[": effects.append(.moveDisplay(direction: -1, beside: shift)) case "]": diff --git a/Sources/LodestarCore/FocusHistory.swift b/Sources/LodestarCore/FocusHistory.swift deleted file mode 100644 index 5a7b2df..0000000 --- a/Sources/LodestarCore/FocusHistory.swift +++ /dev/null @@ -1,66 +0,0 @@ -import CoreGraphics -import Foundation - -/// The attention timeline: every focus change appends here, and back/forward -/// walk it with browser semantics — a fresh navigation while you're back in -/// history truncates the forward branch. Dead windows are skipped when -/// walking; a jump performed BY back/forward moves the cursor instead of -/// recording, so walking never rewrites the history it walks. -public final class FocusHistory { - public private(set) var entries: [CGWindowID] = [] - public private(set) var cursor: Int = -1 - private var expectedJump: CGWindowID? - private let capacity: Int - - public init(capacity: Int = 50) { - self.capacity = capacity - } - - /// Feed every focus change here. - public func recordFocus(_ id: CGWindowID) { - if expectedJump == id { - // Our own back/forward jump landing — the cursor already points - // at it; don't truncate, don't append. - expectedJump = nil - return - } - expectedJump = nil - if entries.indices.contains(cursor), entries[cursor] == id { return } - if cursor < entries.count - 1 { - entries.removeSubrange((cursor + 1)...) - } - entries.append(id) - if entries.count > capacity { - entries.removeFirst(entries.count - capacity) - } - cursor = entries.count - 1 - } - - /// The previous still-alive destination, moving the cursor to it. - public func stepBack(isAlive: (CGWindowID) -> Bool) -> CGWindowID? { - var index = cursor - 1 - while index >= 0 { - if isAlive(entries[index]) { - cursor = index - expectedJump = entries[index] - return entries[index] - } - index -= 1 - } - return nil - } - - /// The next still-alive destination toward the present. - public func stepForward(isAlive: (CGWindowID) -> Bool) -> CGWindowID? { - var index = cursor + 1 - while index < entries.count { - if isAlive(entries[index]) { - cursor = index - expectedJump = entries[index] - return entries[index] - } - index += 1 - } - return nil - } -} diff --git a/Sources/LodestarCore/Gestures.swift b/Sources/LodestarCore/Gestures.swift index b0bba61..026cc55 100644 --- a/Sources/LodestarCore/Gestures.swift +++ b/Sources/LodestarCore/Gestures.swift @@ -11,10 +11,22 @@ public enum Gestures { public let about: String } + /// The top-level letters the grammar keeps for its own verbs: a graph + /// address here would be shadowed forever. **This is the one place that + /// decides**; `Config.reservedTopLevel` and the advisor both read it, + /// because each of the last three key moves left a copy behind. + /// + /// Empty as of 0.17, and meant to stay that way — the whole alphabet + /// belongs to the graph. O rejoined it in 0.14.2 when orientation moved + /// to `\`, X when the attention timeline was retired, and Z when undo + /// moved to the arrows. Every verb now lives on a key the graph cannot + /// want. + public static let reservedLetters: Set = [] + /// All letters a graph chain can start on: the alphabet minus the - /// reserved verbs o, x, and z. + /// letters the verbs own. public static let graphLetters: [String] = - "abcdefghijklmnopqrstuvwxyz".map(String.init).filter { !["o", "x", "z"].contains($0) } + "abcdefghijklmnopqrstuvwxyz".map(String.init).filter { !reservedLetters.contains($0) } /// Ordered as the config template lists them. public static let roster: [Verb] = [ @@ -28,9 +40,8 @@ public enum Gestures { Verb(name: "breaths", keys: ["'"], about: "lode ', saved layouts"), Verb(name: "maximize", keys: ["0"], about: "lode 0 fill the display with the focused window, ⇧0 beside"), Verb(name: "index-jump", keys: (1...9).map(String.init), about: "lode 1…9 jump, ⇧1…9 slide"), - Verb(name: "flip-orientation", keys: ["o"], about: "lode O, flip the layout axis"), - Verb(name: "layout-undo", keys: ["z"], about: "lode Z undo, ⇧Z redo"), - Verb(name: "back-forward", keys: ["x"], about: "lode X back, ⇧X forward"), + Verb(name: "flip-orientation", keys: ["\\"], about: "lode \\, flip the layout axis"), + Verb(name: "layout-undo", keys: ["left", "right"], about: "lode ← undo, lode → redo"), Verb(name: "display-move", keys: ["[", "]"], about: "lode [ and ], move across displays"), Verb(name: "cheat-sheet", keys: ["/"], about: "lode ?, the gesture reference"), ] diff --git a/Sources/LodestarCore/Graph.swift b/Sources/LodestarCore/Graph.swift index 3932550..fc69093 100644 --- a/Sources/LodestarCore/Graph.swift +++ b/Sources/LodestarCore/Graph.swift @@ -12,6 +12,21 @@ public enum GraphTarget: Equatable { return "\(profile.browser.label) (\(profile.display))" } } + + /// What a config line writes to bind this target: a plain app name, or + /// `brave:xonar` for a browser profile. + /// + /// Deliberately distinct from `label`. "Brave (Xonar)" is nothing anyone + /// has installed, so anything that commits the label instead lands in + /// `AppIndex.entry(named:)`, misses on the exact match, and fuzzy-ranks + /// its way to plain Brave — binding the wrong thing without a word. + public var configValue: String { + switch self { + case .app(let name): return name + case .browserProfile(let key, let profile): + return "\(profile.browser.rawValue):\(key)" + } + } } /// The chain trie. Built from config, so it is acyclic by construction; the diff --git a/Sources/LodestarCore/ObservationStore.swift b/Sources/LodestarCore/ObservationStore.swift index 78254b4..65e9740 100644 --- a/Sources/LodestarCore/ObservationStore.swift +++ b/Sources/LodestarCore/ObservationStore.swift @@ -168,7 +168,7 @@ public final class ObservationStore { event.rec = rec.kind.rawValue event.app = rec.target event.seconds = rec.secondsPerWeek - if case .bindApp(let chain, _)? = rec.edit { + if case .bindTarget(let chain, _)? = rec.edit { event.address = Observations.key(chain) } else if case .removeChain(let chain)? = rec.edit { event.address = Observations.key(chain) diff --git a/Sources/lodestar/Actions.swift b/Sources/lodestar/Actions.swift index cd797ca..2e4f669 100644 --- a/Sources/lodestar/Actions.swift +++ b/Sources/lodestar/Actions.swift @@ -19,7 +19,6 @@ final class Actions { private let hud: HUD private var intents = IntentQueue() - private let history = FocusHistory() init(model: WindowModel, parking: ParkingLot, layout: LayoutController, appIndex: AppIndex, store: StateStore, hud: HUD) { @@ -34,7 +33,6 @@ final class Actions { func attach() { model.onFocus = { [weak self] id in guard let self else { return } - self.history.recordFocus(id) // The transition structure: which app follows which, by any // road — Lodestar's or the system's. App names only; the // observation layer decays and caps the matrix. @@ -284,28 +282,6 @@ final class Actions { raise(focused) } - /// lode X / ⇧X: walk the attention timeline. A back-jump is a summon - /// of the previous destination using the standard placement rules. - func goBack() { - guard let id = history.stepBack(isAlive: { self.model.verify($0) }), - let window = model.window(id) else { - hud.flash("✕ nothing further back") - return - } - Log.info("back", ["to": id, "app": window.appName]) - place(window, beside: false) - } - - func goForward() { - guard let id = history.stepForward(isAlive: { self.model.verify($0) }), - let window = model.window(id) else { - hud.flash("✕ nothing forward") - return - } - Log.info("forward", ["to": id, "app": window.appName]) - place(window, beside: false) - } - /// lode ⇧digit: slide the focused window into that position on its /// display (insert-and-shift; 9 = last). func reorderFocused(toDigit digit: Int) { diff --git a/Sources/lodestar/CoachController.swift b/Sources/lodestar/CoachController.swift index e0b4a97..56b5535 100644 --- a/Sources/lodestar/CoachController.swift +++ b/Sources/lodestar/CoachController.swift @@ -62,7 +62,7 @@ final class CoachController { // Wired by the app delegate. /// Everything a recommendation pass needs, gathered on the main thread. var contextInputs: () -> (observations: Observations, - leaves: [(chain: [String], label: String)], + leaves: [Advisor.Leaf], webRoutes: [String: String], profileKeys: [String: String], logFile: URL)? = { nil } diff --git a/Sources/lodestar/ConfigDoctor.swift b/Sources/lodestar/ConfigDoctor.swift index e2a6a27..2c27987 100644 --- a/Sources/lodestar/ConfigDoctor.swift +++ b/Sources/lodestar/ConfigDoctor.swift @@ -458,7 +458,8 @@ func runObservations(clear: Bool, engine: Bool) -> Never { let bound = config.graph.leaves() let context = Advisor.Context( observations: o, events: events, - leaves: bound.map { (chain: $0.chain, label: $0.target.label) }, + leaves: bound.map { Advisor.Leaf(chain: $0.chain, label: $0.target.label, + value: $0.target.configValue) }, webRoutes: config.webRoutes ) diff --git a/Sources/lodestar/HotkeyEngine.swift b/Sources/lodestar/HotkeyEngine.swift index 1fbc79d..979e7e0 100644 --- a/Sources/lodestar/HotkeyEngine.swift +++ b/Sources/lodestar/HotkeyEngine.swift @@ -288,7 +288,6 @@ final class HotkeyEngine { case .maximizeFocused: return "maximize" case .flipOrientation: return "orientation" case .undoLayout, .redoLayout: return "undo" - case .goBack, .goForward: return "timeline" case .moveDisplay: return "displays" default: return nil } @@ -485,10 +484,6 @@ final class HotkeyEngine { actions.undoLayout() case .redoLayout: actions.redoLayout() - case .goBack: - actions.goBack() - case .goForward: - actions.goForward() case .indexJump(let digit): actions.indexJump(digit) case .reorder(let digit): @@ -623,8 +618,7 @@ final class HotkeyEngine { GuideRow(key: ",", label: "scroll mode — j/k · h/l · d/u · gg/G"), GuideRow(key: ";", label: "click hints — ⇧; chains · ⇧label right-clicks"), GuideRow(key: "/", label: "select text — type what you see · ⇧letter anchors"), - GuideRow(key: "Z", label: "undo layout · ⇧Z redo"), - GuideRow(key: "X", label: "back · ⇧X forward — the attention timeline"), + GuideRow(key: "← →", label: "undo · redo the layout"), GuideRow(key: "⇧1…9", label: "slide the focused window to that position"), GuideRow(key: "[ ]", label: "move window to prev/next display — ⇧ beside"), GuideRow(key: "'", label: "breaths — ' ' updates latest"), diff --git a/Sources/lodestar/main.swift b/Sources/lodestar/main.swift index 18b9205..dcbf20b 100644 --- a/Sources/lodestar/main.swift +++ b/Sources/lodestar/main.swift @@ -200,7 +200,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { }, uniquingKeysWith: { first, _ in first }) return (observations: self.observationStore.observations, leaves: self.config.graph.leaves().map { - (chain: $0.chain, label: $0.target.label) + Advisor.Leaf(chain: $0.chain, label: $0.target.label, + value: $0.target.configValue) }, webRoutes: self.config.webRoutes, profileKeys: identityToKey, @@ -209,11 +210,8 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { coach.applyEdit = { [weak self] edit in guard let self else { return "lodestar is shutting down" } switch edit { - case .bindApp(let chain, let app): - guard let entry = self.appIndex.entry(named: app) else { - return "\(app) is not installed any more" - } - return self.addAppToGraph(chain, entry: entry) + case .bindTarget(let chain, let target): + return self.addTargetToGraph(chain, target: target) case .removeChain(let chain): return self.removeChainFromGraph(chain) case .addRoute(let pattern, let profileKey): @@ -373,7 +371,7 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { detail: "a rehearsal · this is how a real finding will arrive", secondsPerWeek: 42, probability: 0.97, evidence: ["synthetic, for the dress rehearsal"], - edit: .bindApp(chain: [slot], app: entry.name)) + edit: .bindTarget(chain: [slot], target: entry.name)) coach.armDemo(rec) Log.info("coach", ["demo": "armed", "slot": slot, "app": entry.name]) return @@ -849,8 +847,11 @@ final class AppDelegate: NSObject, NSApplicationDelegate, NSMenuDelegate { /// the live trie, so sugar keys and merged branches are all visible. private func chainProblem(_ letters: [String]) -> String? { guard let first = letters.first else { return nil } + // Empty as of 0.17, so this never fires today. It stays as a guard + // in case a verb ever moves back onto a letter — which is exactly + // why the message no longer names particular letters. if Config.reservedTopLevel.contains(first) { - return "\(first.uppercased()) is reserved — X and Z are fixed verbs" + return "\(first.uppercased()) is reserved for a fixed verb" } for depth in 1...letters.count { let prefix = Array(letters[0.. String? { + let lowered = target.lowercased() + guard let browser = ChromiumBrowser.allCases.first(where: { + lowered.hasPrefix("\($0.rawValue):") + }) else { + guard let entry = appIndex.entry(named: target) else { + return "\(target) is not installed any more" + } + return addAppToGraph(letters, entry: entry) + } + let key = String(lowered.dropFirst(browser.rawValue.count + 1)) + .trimmingCharacters(in: .whitespaces) + guard let profile = config.browserProfiles[key], profile.browser == browser else { + return "\(target) is not a profile you have declared" + } + if let problem = chainProblem(letters) { return problem } + let shown = letters.map { $0.uppercased() }.joined(separator: " ") + let name = GraphTarget.browserProfile(key: key, profile: profile).label + return rewriteConfig(flash: "✓ lode \(shown) → \(name)") { + try GraphJsonEditor.addingPath(letters, target: "\(browser.rawValue):\(key)", in: $0) + } + } + private func addAppToGraph(_ letters: [String], entry: AppIndex.Entry) -> String? { if let problem = chainProblem(letters) { return problem } let shown = letters.map { $0.uppercased() }.joined(separator: " ") diff --git a/Tests/LodestarCoreTests/AnalysisTests.swift b/Tests/LodestarCoreTests/AnalysisTests.swift index a7b3314..b2d67ae 100644 --- a/Tests/LodestarCoreTests/AnalysisTests.swift +++ b/Tests/LodestarCoreTests/AnalysisTests.swift @@ -284,7 +284,7 @@ final class AnalysisTests: XCTestCase { } return Advisor.Context( observations: o, events: events, - leaves: [(chain: ["g"], label: "Ghostty")], + leaves: [.init(chain: ["g"], label: "Ghostty", value: "Ghostty")], webRoutes: [:], now: start.addingTimeInterval(4 * 604_800)) } @@ -297,10 +297,55 @@ final class AnalysisTests: XCTestCase { XCTAssertTrue(bind?.detail.contains("lode F") ?? false, "the mnemonic slot") XCTAssertGreaterThan(bind?.secondsPerWeek ?? 0, 0) XCTAssertGreaterThanOrEqual(bind?.probability ?? 0, 0.9, "gated, not guessed") - XCTAssertEqual(bind?.edit, .bindApp(chain: ["f"], app: "facetime"), + XCTAssertEqual(bind?.edit, .bindTarget(chain: ["f"], target: "facetime"), "the one config line the chip would commit") } + /// A browser-profile leaf must commit the value a config line writes, + /// not the label a person reads. `GraphTarget.label` renders + /// "Brave (Xonar)", which nobody has installed: committing that sends + /// the coach into `AppIndex.entry(named:)`, past the exact match, into + /// `Fuzzy.rank`, and binds plain Brave with the profile dropped in + /// silence. Unreachable until `mnemonicLetters` learned to read + /// parenthesised words, which is exactly what makes it worth pinning. + func testShorteningAProfileCommitsTheProfileNotItsLabel() { + var o = Observations() + var events: [ObservationEvent] = [] + var noise = Noise(seed: 11) + // A fast single key, so the model knows one letter is cheap. + for i in 0..<50 { + var event = ObservationEvent(t: start.addingTimeInterval(Double(i) * 600), + kind: .chain) + event.chain = ["g"] + event.gaps = [exp(log(0.15) + noise.normal(sd: 0.1))] + events.append(event) + o.apply(event) + } + // A deep chain to a browser profile, typed often and slowly. + for i in 0..<40 { + var event = ObservationEvent(t: start.addingTimeInterval(Double(i) * 900), + kind: .chain) + event.chain = ["b", "x"] + event.gaps = [exp(log(0.30) + noise.normal(sd: 0.1)), + exp(log(0.60) + noise.normal(sd: 0.1))] + events.append(event) + o.apply(event) + } + let context = Advisor.Context( + observations: o, events: events, + leaves: [.init(chain: ["g"], label: "Ghostty", value: "Ghostty"), + .init(chain: ["b", "x"], label: "Brave (Xonar)", + value: "brave:xonar")], + webRoutes: [:], + now: start.addingTimeInterval(3 * 86_400)) + let shorten = Advisor.recommend(context).first { $0.kind == .shorten } + XCTAssertNotNil(shorten, "a deep chain typed 40 times has earned a letter") + XCTAssertEqual(shorten?.edit, .bindTarget(chain: ["x"], target: "brave:xonar"), + "the edit writes the profile reference, never the rendered label") + XCTAssertEqual(shorten?.display, "Brave (Xonar)", + "the chip still shows the name a person would recognise") + } + func testAdvisorStaysSilentOnThinData() { var o = Observations() var reach = ObservationEvent(t: start, kind: .reach) @@ -364,7 +409,8 @@ final class AnalysisTests: XCTestCase { completion.gaps = [0.3, 0.2] o.apply(completion) let context = Advisor.Context(observations: o, events: [], - leaves: [(chain: ["v", "z"], label: "Zoom")], + leaves: [.init(chain: ["v", "z"], label: "Zoom", + value: "Zoom")], webRoutes: [:], now: start) let rebind = Advisor.recommend(context).first { $0.kind == .rebind } XCTAssertNotNil(rebind, "prefix evidence must reach the advisor") @@ -378,11 +424,10 @@ final class AnalysisTests: XCTestCase { let context = Advisor.Context(observations: Observations(), events: [], leaves: [], webRoutes: [:], now: start) let free = Advisor.freeLetters(context) - for reserved in ["x", "z"] { - XCTAssertFalse(free.contains(reserved), - "\(reserved) is a verb — a graph slot there would be shadowed") + for returned in ["o", "x", "z"] { + XCTAssertTrue(free.contains(returned), + "\(returned) rejoined the graph when its verb moved off it") } - XCTAssertTrue(free.contains("o"), "o rejoined the graph when the flip moved to \\") XCTAssertTrue(free.contains("q"), "ordinary letters stay on offer") } diff --git a/Tests/LodestarCoreTests/CoachTests.swift b/Tests/LodestarCoreTests/CoachTests.swift index 7210618..a4545c8 100644 --- a/Tests/LodestarCoreTests/CoachTests.swift +++ b/Tests/LodestarCoreTests/CoachTests.swift @@ -16,7 +16,7 @@ final class CoachTests: XCTestCase { Recommendation(kind: .bind, target: app, detail: "\(app) is searched often", secondsPerWeek: seconds, probability: probability, evidence: [], - edit: .bindApp(chain: [slot], app: app)) + edit: .bindTarget(chain: [slot], target: app)) } private func routeRec(seconds: Double = 12) -> Recommendation { @@ -33,7 +33,7 @@ final class CoachTests: XCTestCase { event.rec = rec.kind.rawValue event.app = rec.target event.seconds = rec.secondsPerWeek - if case .bindApp(let chain, _)? = rec.edit { + if case .bindTarget(let chain, _)? = rec.edit { event.address = Observations.key(chain) } return event diff --git a/Tests/LodestarCoreTests/ConfigBuildTests.swift b/Tests/LodestarCoreTests/ConfigBuildTests.swift index 86c52ce..d69c0c8 100644 --- a/Tests/LodestarCoreTests/ConfigBuildTests.swift +++ b/Tests/LodestarCoreTests/ConfigBuildTests.swift @@ -137,11 +137,20 @@ final class ConfigBuildTests: XCTestCase { // MARK: - Graph - func testGraphBuildsAndReservedLettersAreRefused() throws { - let (config, problems) = try build(#"{"graph": {"s": "Slack", "z": "Zed"}}"#) + /// Nothing is reserved as of 0.17 — every verb sits on a key the graph + /// cannot want, so the whole alphabet builds. The refusal path in + /// `Config.parse` is kept as a guard in case a verb ever moves back onto + /// a letter; with an empty reserved set it simply has nothing to refuse. + func testGraphAcceptsEveryLetterNowThatNoneAreReserved() throws { + let (config, problems) = try build(#"{"graph": {"s": "Slack", "z": "Zed", "x": "Xcode"}}"#) guard case .leaf = config.graph.resolve(["s"]) else { return XCTFail("s should resolve") } - XCTAssertNil(config.graph.children["z"], "z belongs to layout undo") - XCTAssertTrue(problems.contains { $0.contains("reserved") }, "\(problems)") + for letter in ["z", "x"] { + guard case .leaf = config.graph.resolve([letter]) else { + return XCTFail("\(letter) should resolve") + } + } + XCTAssertFalse(problems.contains { $0.contains("reserved") }, "\(problems)") + XCTAssertTrue(Config.reservedTopLevel.isEmpty) } // MARK: - Key overrides diff --git a/Tests/LodestarCoreTests/EngineTests.swift b/Tests/LodestarCoreTests/EngineTests.swift index 8334a75..9e959d4 100644 --- a/Tests/LodestarCoreTests/EngineTests.swift +++ b/Tests/LodestarCoreTests/EngineTests.swift @@ -230,10 +230,8 @@ final class EngineTests: XCTestCase { func testLayoutVerbs() { XCTAssertEqual(press("\\"), [.flipOrientation]) - XCTAssertEqual(press("z"), [.undoLayout]) - XCTAssertEqual(press("z", shift: true), [.redoLayout]) - XCTAssertEqual(press("x"), [.goBack]) - XCTAssertEqual(press("x", shift: true), [.goForward]) + XCTAssertEqual(press("left"), [.undoLayout]) + XCTAssertEqual(press("right"), [.redoLayout]) } func testDisplayMoves() { @@ -251,7 +249,17 @@ final class EngineTests: XCTestCase { XCTAssertEqual(core.state, .chain(kind: .breath, letters: [], deleting: false)) } - /// Marks retired in 0.9.14 — backtick is unbound and reaches the app. + /// Undo moved to the arrows in 0.17 and the attention timeline was + /// retired, so every letter is an ordinary chain starter again. + func testEveryLetterWalksTheGraph() { + for letter in ["x", "z", "o"] { + XCTAssertEqual(press(letter), + [.hideGuide, .flash("✕ \(letter.uppercased()) is not on the graph")]) + XCTAssertEqual(core.state, .idle) + } + } + + /// Backtick was freed again when the timeline was retired. func testBacktickIsFree() { XCTAssertEqual(press("`"), [.passThrough]) XCTAssertEqual(core.state, .idle) diff --git a/Tests/LodestarCoreTests/GesturesTests.swift b/Tests/LodestarCoreTests/GesturesTests.swift index a89434c..f03121b 100644 --- a/Tests/LodestarCoreTests/GesturesTests.swift +++ b/Tests/LodestarCoreTests/GesturesTests.swift @@ -7,29 +7,39 @@ final class GesturesTests: XCTestCase { XCTAssertEqual(names.count, Set(names).count) } - func testGraphLettersExcludeReservedVerbs() { - XCTAssertEqual(Gestures.graphLetters.count, 23) - for reserved in ["o", "x", "z"] { - XCTAssertFalse(Gestures.graphLetters.contains(reserved)) + func testTheWholeAlphabetBelongsToTheGraph() { + XCTAssertEqual(Gestures.graphLetters.count, 26) + XCTAssertTrue(Gestures.reservedLetters.isEmpty, + "every verb lives on a key the graph cannot want") + for returned in ["o", "x", "z"] { + XCTAssertTrue(Gestures.graphLetters.contains(returned), + "\(returned) rejoined the graph when its verb moved") } } /// Every idle-state key the engine dispatches is owned by exactly one /// toggle — no verb is orphaned, no key claimed twice. - func testKeysAreDisjointAndCoverTheIdleDispatch(){ - var seen = Set() + /// + /// This asks the engine rather than restating it. The hand-written list + /// this replaces drifted in lockstep with the roster: when 0.14.2 moved + /// the flip off O, both the roster and this test kept saying "o", so the + /// test went on asserting the stale mapping was correct and the broken + /// `flip-orientation` toggle survived two releases. + func testRosterOwnsExactlyTheKeysTheEngineClaims() { + var claimed = Set() + for key in Set(Keys.ansi.values) { + var core = EngineCore() + let effects = core.keyDown(key: key, held: true, shift: false, + world: WorldStub()) + if effects != [.passThrough] { claimed.insert(key) } + } + var owned = Set() for verb in Gestures.roster { for key in verb.keys { - XCTAssertTrue(seen.insert(key).inserted, "key '\(key)' claimed twice") + XCTAssertTrue(owned.insert(key).inserted, "key '\(key)' claimed twice") } } - // Unbound and deliberately held in reserve: "`" (marks, retired - // 0.9.14) and "=" / "-" (claim moved to 0 in 0.9.15). - let dispatched = Set(["space", "tab", "return", ".", ",", ";", - "'", "0", "o", "x", "z", "[", "]", "/"] - + (1...9).map(String.init) - + "abcdefghijklmnopqrstuvwxyz".map(String.init)) - XCTAssertEqual(seen, dispatched) + XCTAssertEqual(claimed, owned) } func testDisabledKeysMapsFalseTogglesOnly() { @@ -40,8 +50,8 @@ final class GesturesTests: XCTestCase { func testDisablingGraphFreesChainStarters() { let keys = Gestures.disabledKeys(from: ["graph": false]) - XCTAssertEqual(keys.count, 23) + XCTAssertEqual(keys.count, 26) XCTAssertTrue(keys.contains("a")) - XCTAssertFalse(keys.contains("o")) + XCTAssertTrue(keys.contains("z"), "Z is the graph's since undo moved") } } diff --git a/Tests/LodestarCoreTests/ModifierTapTests.swift b/Tests/LodestarCoreTests/ModifierTapTests.swift index 290754e..2a13cd0 100644 --- a/Tests/LodestarCoreTests/ModifierTapTests.swift +++ b/Tests/LodestarCoreTests/ModifierTapTests.swift @@ -160,8 +160,8 @@ final class DisabledGestureTests: XCTestCase { XCTAssertEqual(core.keyDown(key: "o", held: true, shift: false, world: world), [.passThrough]) XCTAssertEqual(core.keyDown(key: "[", held: true, shift: true, world: world), [.passThrough], "shift variants ride along") - XCTAssertEqual(core.keyDown(key: "z", held: true, shift: false, world: world), [.undoLayout], - "everything else untouched") + XCTAssertEqual(core.keyDown(key: "left", held: true, shift: false, world: world), + [.undoLayout], "everything else untouched") XCTAssertEqual(core.state, .idle) } diff --git a/Tests/LodestarCoreTests/PipelineTests.swift b/Tests/LodestarCoreTests/PipelineTests.swift index ef73d84..fb3f593 100644 --- a/Tests/LodestarCoreTests/PipelineTests.swift +++ b/Tests/LodestarCoreTests/PipelineTests.swift @@ -134,8 +134,9 @@ final class PipelineTests: XCTestCase { // And the advisor reads the world correctly: FaceTime deserves F. let context = Advisor.Context( observations: o, events: events, - leaves: [(chain: ["g"], label: "Ghostty"), - (chain: ["b", "d"], label: "Brave (default)")], + leaves: [.init(chain: ["g"], label: "Ghostty", value: "Ghostty"), + .init(chain: ["b", "d"], label: "Brave (default)", + value: "brave:default")], webRoutes: [:], now: start.addingTimeInterval(4 * 604_800)) let recommendations = Advisor.recommend(context) diff --git a/Tests/LodestarCoreTests/PlacementTests.swift b/Tests/LodestarCoreTests/PlacementTests.swift index cef558a..2439807 100644 --- a/Tests/LodestarCoreTests/PlacementTests.swift +++ b/Tests/LodestarCoreTests/PlacementTests.swift @@ -33,52 +33,4 @@ final class PlacementTests: XCTestCase { XCTAssertNil(Placement.reorder([10, 20, 30], move: 30, toDigit: 9), "already last") } - - func testHistoryRecordsAndBounces() { - let history = FocusHistory() - history.recordFocus(1) - history.recordFocus(2) - history.recordFocus(3) - XCTAssertEqual(history.stepBack(isAlive: { _ in true }), 2) - // The jump's own focus event must not truncate. - history.recordFocus(2) - XCTAssertEqual(history.stepBack(isAlive: { _ in true }), 1) - history.recordFocus(1) - XCTAssertEqual(history.stepForward(isAlive: { _ in true }), 2) - history.recordFocus(2) - XCTAssertEqual(history.stepForward(isAlive: { _ in true }), 3) - } - - func testFreshNavigationTruncatesForward() { - let history = FocusHistory() - history.recordFocus(1) - history.recordFocus(2) - history.recordFocus(3) - _ = history.stepBack(isAlive: { _ in true }) // at 2 - history.recordFocus(2) - history.recordFocus(9) // fresh branch - XCTAssertNil(history.stepForward(isAlive: { _ in true }), "3 was truncated") - XCTAssertEqual(history.stepBack(isAlive: { _ in true }), 2) - } - - func testDeadWindowsAreSkipped() { - let history = FocusHistory() - history.recordFocus(1) - history.recordFocus(2) - history.recordFocus(3) - XCTAssertEqual(history.stepBack(isAlive: { $0 != 2 }), 1, "2 is dead; land on 1") - } - - func testNothingFurtherBack() { - let history = FocusHistory() - history.recordFocus(1) - XCTAssertNil(history.stepBack(isAlive: { _ in true })) - } - - func testCapacityBounds() { - let history = FocusHistory(capacity: 5) - for id in 1...20 { history.recordFocus(CGWindowID(id)) } - XCTAssertEqual(history.entries.count, 5) - XCTAssertEqual(history.entries.last, 20) - } } diff --git a/Tests/LodestarCoreTests/StarterGraphTests.swift b/Tests/LodestarCoreTests/StarterGraphTests.swift index 62e20b0..bf545c2 100644 --- a/Tests/LodestarCoreTests/StarterGraphTests.swift +++ b/Tests/LodestarCoreTests/StarterGraphTests.swift @@ -31,16 +31,15 @@ final class StarterGraphTests: XCTestCase { } func testReservedVerbsAreOffLimits() { - // X and Z are fixed verbs; an address there would be shadowed - // forever. O rejoined the graph when orientation moved to \ — - // Zoom now routes to its second letter instead of its fourth. + // Nothing is off limits any more: every verb moved off its letter + // by 0.17, so each app simply keeps its own initial. let proposals = StarterGraph.propose(running: ["Xcode", "Zoom", "Obsidian"], existing: GraphNode(), reserved: Config.reservedTopLevel) XCTAssertEqual(proposals, [ - .init(letter: "c", app: "Xcode"), - .init(letter: "o", app: "Zoom"), - .init(letter: "b", app: "Obsidian"), + .init(letter: "x", app: "Xcode"), + .init(letter: "z", app: "Zoom"), + .init(letter: "o", app: "Obsidian"), ]) }