Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
41 changes: 40 additions & 1 deletion Sources/Runway/App/SettingsMigrator.swift
Original file line number Diff line number Diff line change
Expand Up @@ -30,7 +30,7 @@ struct SettingsMigration: Sendable {
enum SettingsSchema {
/// Current schema version. Keep equal to the highest migration `version` below (or the baseline when
/// there are none). This is NOT the app version — bump it only alongside a migration you add.
static let current = 4
static let current = 5

/// The provider IDs that existed when the v2 migration shipped, frozen forever. A migration is a
/// point-in-time transform: any future build with more providers also contains this migration, so a
Expand Down Expand Up @@ -73,8 +73,47 @@ enum SettingsSchema {
SettingsMigration(version: 4) { defaults, domainName in
guard !domainName.isEmpty else { return }
defaults.removePersistentDomain(forName: "\(domainName).telemetry")
},
// v5 re-slots two rows that were appended at the end of existing Customize orders when they
// first shipped: Cursor Grok Bot belongs above Extra Usage, and Grok Rate Limit Resets
// belongs above Usage Trend / spend tiles, matching Codex. Fresh installs already declare
// that order; this only rewrites a saved `metricOrderByProvider`.
SettingsMigration(version: 5) { defaults in
let key = "runway.layout.v1.metricOrderByProvider"
guard let data = defaults.data(forKey: key) else { return }
var order = try JSONDecoder().decode([String: [String]].self, from: data)
if let cursor = order["cursor"] {
order["cursor"] = LayoutOrdering.relocating(
["cursor.grokBot"],
in: cursor,
canonical: v5CursorMetricOrder
)
}
if let grok = order["grok"] {
order["grok"] = LayoutOrdering.relocating(
["grok.rateLimitResets"],
in: grok,
canonical: v5GrokMetricOrder
)
}
defaults.set(try JSONEncoder().encode(order), forKey: key)
}
Comment on lines +81 to 100
]

/// Frozen Cursor declaration order at the v5 migration. Do not edit: a later descriptor shuffle
/// must not rewrite this transform.
static let v5CursorMetricOrder = [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.grokBot", "cursor.onDemand",
"cursor.requests", "cursor.credits", "cursor.trend",
"cursor.today", "cursor.yesterday", "cursor.last30"
]

/// Frozen Grok declaration order at the v5 migration. Do not edit: a later descriptor shuffle
/// must not rewrite this transform.
static let v5GrokMetricOrder = [
"grok.weekly", "grok.payAsYouGo", "grok.rateLimitResets",
"grok.trend", "grok.today", "grok.yesterday", "grok.last30"
]
}

/// Versioned, cascading settings migration — the replacement for the beta-era domain wipe. Runs once at
Expand Down
5 changes: 4 additions & 1 deletion Sources/Runway/Stores/DefaultLayout.swift
Original file line number Diff line number Diff line change
Expand Up @@ -107,13 +107,16 @@ enum DefaultLayout {
"claude.trend", "claude.today", "claude.yesterday", "claude.last30",
// Codex: Weekly stays above the fold; reset details and local history sit below the caret.
"codex.rateLimitResets", "codex.trend", "codex.today", "codex.yesterday", "codex.last30",
// Cursor: Total / Cursor Models / Other Models stay above the fold; Grok Bot sits above
// Extra Usage in the On Demand section, then spend tiles.
"cursor.grokBot", "cursor.onDemand", "cursor.requests", "cursor.credits",
"cursor.today", "cursor.yesterday", "cursor.last30",
// Copilot adapts its visible rows to the account: personal Credits/Extra Usage or org-wide
// credits/spend stay above the fold; free-tier Chat + Completions sit below the caret.
"copilot.chat", "copilot.completions",
"devin.extra",
// Grok: Weekly stays above the fold; reset details and local history sit below the caret.
// Grok: Weekly stays above the fold; Rate Limit Resets sit above Usage Trend and the
// spend tiles below the caret, matching Codex.
"grok.rateLimitResets", "grok.trend", "grok.today", "grok.yesterday", "grok.last30",
// Kimi: Five-Hour Usage stays above the fold; Weekly Usage and the two Extra Usage
// account/billing rows sit below the caret.
Expand Down
44 changes: 37 additions & 7 deletions Sources/Runway/Stores/LayoutBootstrap.swift
Original file line number Diff line number Diff line change
Expand Up @@ -232,16 +232,16 @@ enum LayoutOrdering {
) -> [String: [String]] {
// Start with every saved provider so a temporarily absent account card keeps its ordering
// entry. For providers present now, deduplicate the saved sequence (including unknown metric
// tombstones) and append newly introduced live metrics; `LayoutStore` filters this persisted
// tombstones) and insert newly introduced live metrics at their declaration-order slot, so a
// new row does not fall to the bottom of Customize. `LayoutStore` filters this persisted
// superset through the live registry before rendering.
var fallback = saved
for provider in registry.providers {
let valid = registry.descriptors(for: provider.id).map(\.id)
if let savedIDs = saved[provider.id] {
var seen = Set<String>()
var retained = savedIDs.filter { seen.insert($0).inserted }
retained.append(contentsOf: valid.filter { seen.insert($0).inserted })
fallback[provider.id] = retained
let retained = savedIDs.filter { seen.insert($0).inserted }
fallback[provider.id] = inserting(valid.filter { !seen.contains($0) }, into: retained, canonical: valid)
} else {
fallback[provider.id] = valid
}
Expand All @@ -252,12 +252,42 @@ enum LayoutOrdering {
static func normalizedMetricIDs(_ saved: [String], validIDs: [String]) -> [String] {
let validSet = Set(validIDs)
var seen = Set<String>()
var ordered = saved.filter { id in
let ordered = saved.filter { id in
guard validSet.contains(id), !seen.contains(id) else { return false }
seen.insert(id)
return true
}
ordered.append(contentsOf: validIDs.filter { !seen.contains($0) })
return ordered
return inserting(validIDs.filter { !seen.contains($0) }, into: ordered, canonical: validIDs)
}

/// Drop `ids` from `saved` (if present) and put them back at their `canonical` slots. Used to
/// correct a row that was appended at the end when it first shipped.
static func relocating(_ ids: [String], in saved: [String], canonical: [String]) -> [String] {
let present = ids.filter { saved.contains($0) }
guard !present.isEmpty else { return saved }
let stripped = saved.filter { !present.contains($0) }
return inserting(present, into: stripped, canonical: canonical)
}

/// Insert `newIDs` into `saved` at each id's declaration slot in `canonical`, keeping the relative
/// order of ids already in `saved`. Saved ids that `canonical` does not know (tombstones) stay put.
static func inserting(_ newIDs: [String], into saved: [String], canonical: [String]) -> [String] {
let indexByID = Dictionary(uniqueKeysWithValues: canonical.enumerated().map { ($1, $0) })
var seen = Set(saved)
var result = saved
for id in newIDs where seen.insert(id).inserted {
let insertAt = indexByID[id].flatMap { mine in
result.firstIndex { other in
guard let idx = indexByID[other] else { return false }
return idx > mine
}
}
if let insertAt {
result.insert(id, at: insertAt)
} else {
result.append(id)
}
}
return result
}
}
62 changes: 62 additions & 0 deletions Tests/RunwayTests/LayoutOrderingTests.swift
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
import XCTest
@testable import Runway

final class LayoutOrderingTests: XCTestCase {
func testInsertingPlacesNewIDsAtDeclarationSlots() {
let canonical = [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.grokBot", "cursor.onDemand",
"cursor.trend", "cursor.today"
]
let saved = [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.onDemand",
"cursor.trend", "cursor.today"
]

XCTAssertEqual(
LayoutOrdering.inserting(["cursor.grokBot"], into: saved, canonical: canonical),
[
"cursor.usage", "cursor.auto", "cursor.api", "cursor.grokBot", "cursor.onDemand",
"cursor.trend", "cursor.today"
]
)
}

func testRelocatingMovesAnAppendedRowToItsDeclarationSlot() {
let canonical = [
"grok.weekly", "grok.payAsYouGo", "grok.rateLimitResets",
"grok.trend", "grok.today", "grok.last30"
]
let saved = [
"grok.weekly", "grok.payAsYouGo", "grok.trend",
"grok.today", "grok.last30", "grok.rateLimitResets"
]

XCTAssertEqual(
LayoutOrdering.relocating(["grok.rateLimitResets"], in: saved, canonical: canonical),
[
"grok.weekly", "grok.payAsYouGo", "grok.rateLimitResets",
"grok.trend", "grok.today", "grok.last30"
]
)
}

func testRelocatingLeavesAnAlreadySlottedRowInPlace() {
let canonical = [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.grokBot", "cursor.onDemand"
]
let saved = canonical

XCTAssertEqual(
LayoutOrdering.relocating(["cursor.grokBot"], in: saved, canonical: canonical),
saved
)
}

func testNormalizedMetricIDsInsertRatherThanAppend() {
let valid = ["a", "new", "b", "c"]
XCTAssertEqual(
LayoutOrdering.normalizedMetricIDs(["a", "b", "c"], validIDs: valid),
["a", "new", "b", "c"]
)
}
}
44 changes: 42 additions & 2 deletions Tests/RunwayTests/SettingsMigratorTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -263,16 +263,56 @@ final class SettingsMigratorTests: XCTestCase {

let result = SettingsMigrator.migrate(defaults: defaults, domainName: domain)

XCTAssertEqual(result, 4)
XCTAssertEqual(result, SettingsSchema.current)
XCTAssertNil(defaults.persistentDomain(forName: telemetryDomain))
XCTAssertEqual(defaults.string(forKey: "openusage.layout.v1"), "custom")

// The cleanup is safe to retry after an interrupted launch.
defaults.set(3, forKey: SettingsMigrator.schemaVersionKey)
XCTAssertEqual(SettingsMigrator.migrate(defaults: defaults, domainName: domain), 4)
XCTAssertEqual(SettingsMigrator.migrate(defaults: defaults, domainName: domain), SettingsSchema.current)
XCTAssertNil(defaults.persistentDomain(forName: telemetryDomain))
}

func testV5MovesGrokBotAboveExtraUsageAndGrokResetsAboveUsageStats() throws {
let (defaults, domain) = makeDefaults("V5MetricOrder")
defer { defaults.removePersistentDomain(forName: domain) }
defaults.set(4, forKey: SettingsMigrator.schemaVersionKey)
let key = "runway.layout.v1.metricOrderByProvider"
let saved: [String: [String]] = [
"cursor": [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.onDemand",
"cursor.trend", "cursor.today", "cursor.yesterday", "cursor.last30",
"cursor.grokBot"
],
"grok": [
"grok.weekly", "grok.payAsYouGo", "grok.trend",
"grok.today", "grok.yesterday", "grok.last30",
"grok.rateLimitResets"
],
"codex": [
"codex.weekly", "codex.rateLimitResets", "codex.trend",
"codex.today", "codex.yesterday", "codex.last30"
]
]
defaults.set(try JSONEncoder().encode(saved), forKey: key)

XCTAssertEqual(SettingsMigrator.migrate(defaults: defaults, domainName: domain), SettingsSchema.current)

let migrated = try JSONDecoder().decode(
[String: [String]].self,
from: try XCTUnwrap(defaults.data(forKey: key))
)
XCTAssertEqual(migrated["cursor"], [
"cursor.usage", "cursor.auto", "cursor.api", "cursor.grokBot", "cursor.onDemand",
"cursor.trend", "cursor.today", "cursor.yesterday", "cursor.last30"
])
XCTAssertEqual(migrated["grok"], [
"grok.weekly", "grok.payAsYouGo", "grok.rateLimitResets", "grok.trend",
"grok.today", "grok.yesterday", "grok.last30"
])
XCTAssertEqual(migrated["codex"], saved["codex"], "unrelated providers stay untouched")
}

/// A legacy install with no disabled providers (the all-on default) converts to all-on.
func testV2ConvertsAllOnLegacyInstall() {
let (defaults, domain) = makeDefaults("V2AllOn")
Expand Down
4 changes: 2 additions & 2 deletions docs/dashboard.md
Original file line number Diff line number Diff line change
Expand Up @@ -83,11 +83,11 @@ A provider's **detail** has a back button and provider-specific Reset control in

Drag-reorder also works directly on the dashboard — drag a row within its provider, drag it across the caret boundary while the card is open, or drag a provider header to reorder sections. On a Force Touch trackpad you'll feel a light tap each time the dragged item snaps into a new slot.

For Claude, the default reset layout keeps Session, Weekly, and Fable always visible. Codex and Grok keep only Weekly always visible, while Sakana Fugu keeps Five-Hour Usage and Weekly Usage always visible. For those four providers, Usage Trend and the Today, Yesterday, and Last 30 Days history rows start on demand; Codex also puts Rate Limit Resets there. Their other metrics start off. Other providers keep their own core meters above the caret and secondary details on demand.
For Claude, the default reset layout keeps Session, Weekly, and Fable always visible. Codex and Grok keep only Weekly always visible, while Sakana Fugu keeps Five-Hour Usage and Weekly Usage always visible. For those four providers, Usage Trend and the Today, Yesterday, and Last 30 Days history rows start on demand; Codex and Grok also put Rate Limit Resets there, above the usage history. Their other metrics start off. Other providers keep their own core meters above the caret and secondary details on demand.

Made a change you didn't mean to? Press **⌘Z** to undo — it works anywhere in the popover (the dashboard and Customize alike) and steps back through your recent customization changes one at a time: hiding or showing a metric, reordering metrics or whole providers, starring or unstarring, and moving a metric across the divider all undo. Each step restores the exact previous arrangement. Undo is per-session (it starts fresh after a relaunch), and resetting clears it.

When Runway ships a new default metric, existing layouts get it once. If you turn it off, it stays off. A provider's **Reset** button (top right of its detail) restores that provider's default metrics, order, menu-bar stars, and which metrics start on demand, but leaves other providers and the provider order untouched. The **Reset All Customization** button (top right of the provider list) does the same for every provider at once, restores the default provider order, and re-detects your installed tools. It turns providers back on for exactly the tools set up on your Mac, just like first launch (see [Which Providers Are On](provider-enablement.md)). It asks for confirmation first, since it wipes the whole layout and re-detects providers, and can't be undone.
When Runway ships a new default metric, existing layouts get it once, in that provider's default position. If you turn it off, it stays off. A provider's **Reset** button (top right of its detail) restores that provider's default metrics, order, menu-bar stars, and which metrics start on demand, but leaves other providers and the provider order untouched. The **Reset All Customization** button (top right of the provider list) does the same for every provider at once, restores the default provider order, and re-detects your installed tools. It turns providers back on for exactly the tools set up on your Mac, just like first launch (see [Which Providers Are On](provider-enablement.md)). It asks for confirmation first, since it wipes the whole layout and re-detects providers, and can't be undone.

## Keyboard

Expand Down