Files
omarchy/shell/plugins/agents/Panel.qml
T
+14 75250d37ac Fix Codex limits, Claude counting, and agent usage reliability from community PRs (#14049)
* Read Codex app-server replies from the raw fd (#13703)

* Resolve Codex through mise which instead of running the lazy launcher (#13109)

* Skip the Codex app-server probe when there are no credentials (#13106)

Adapted: credentials are checked in the home being probed rather than in
the CODEX_HOME environment variable, since each registered account is
probed in its own home, so a signed-out secondary account isn't hidden
behind the primary's login. A home without credentials reports "Waiting
for auth" like any other signed-out home. The credentials store setting is
read with tomllib, so a single-quoted value counts too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the Codex CLI's own error when its app-server dies (#8977)

Detect an app-server that exits or stops answering, and report the end of
its stderr instead of a bare RPC method name. Rebased onto the raw-fd
reply reader; the switch from "-a on-request" to "-a never" is left out,
keeping the current approval flags.

* Count pi sessions when HOME is a git checkout (#13209)

* Count only OpenAI-backed native sessions as Codex usage (#12032)

* Deduplicate Pi usage across forked sessions (#8602)

* Skip unchanged native Codex token snapshots (#10531)

* Count omp and pi profile sessions in the agent usage collectors (#9546)

`omp --profile=<name>` (and pi's equivalent) relocates the whole agent
tree under <base>/profiles/<name>/. The Claude and Codex collectors only
ever scanned <base>/agent/sessions, so a subscription driven entirely
through a profile was invisible to the agents panel: no tokens by day, no
tokens by model, no prompt or session counts.

Discover the profile roots alongside the default one. Sessions are keyed
by file path, so a profile adds sessions instead of double-counting the
default root, and a missing or unreadable profiles directory leaves the
existing behavior untouched.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_014zFbJcDEEpV5BAmsH6kAB3

* Skip unrelated Codex session lines before JSON parsing (#12803)

Adapted: session_meta lines also pass the pre-filter, since the provider
filter from #12032 reads them to skip rollouts served by a non-OpenAI
provider.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read only the Codex session files that changed since the last scan (#12595)

Native Codex rollouts keep per-file totals between runs, replayed while a
file's mtime and size are unchanged. Rebased onto the session_meta
provider filter, snapshot dedup, and line pre-filter, which now live in
the per-file reader. pi and omp sessions are left out of the per-file
cache: a forked pi session repeats its parent's messages, so they are
deduplicated across the whole tree on every scan.

* Count streamed Claude messages by their highest-output usage line (#10606)

Claude Code writes a streamed assistant response as several transcript
lines that share one message id, one per content block. Each line
carries a usage object. The first line's output_tokens is a placeholder,
often 1, and the last line has the real count. Input and cache fields
usually match across the lines.

The scanner dedupes by message id and keeps the first line it sees, so
it under-counts output tokens. On a machine with 2,577 transcripts it
reported 39.0M output tokens against 60.1M used, a 35% shortfall. Input
and both cache fields differed by under 0.01%.

Keep the line with the highest output count, with the last one scanned
winning a tie. The whole line is kept because a response can fall back
to another model mid-stream. Those lines are separate snapshots with
different cache figures and a different model, and taking a maximum per
field across them over-counts cache tokens and credits the wrong model.

The zero-usage check now runs before dedup, so a zero-usage first line
no longer claims a message id and hides a later line with real usage.

Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
Co-authored-by: GPT-6 Astra <noreply@openai.com>

* Index Claude transcripts so the agents refresh reads only what was appended (#8313)

omarchy-agent-usage-claude re-parsed every line of every transcript under
~/.claude/projects on each refresh: no mtime cutoff, no memory of the last
pass. The agents widget is on by default and ticks every 15 minutes, so the
cost grew for the life of the machine. After one month here that was 803
files, 640 MB, 127k lines and 57k JSON parses per tick, about 1 core-second,
pushed through the page cache every quarter hour forever.

Keep a per-file index next to the scan cache: the unique usage records
already parsed out of each transcript and the byte offset they end at. A
file whose size and mtime match is not opened; a file that grew is read
from the stored offset; a file that shrank or was rewritten is read from
the start. --force drops the index and rescans from scratch.

The summary is built from the indexed records in the same directory order
the walk always used. That matters: when a resumed session carries earlier
messages, the same message id appears in two files with different usage,
and the first file visited wins. 91 ids differed on this machine; sorting
the walk moved one model's output total by 25k tokens. Output is now
byte-identical to the previous scan on a frozen copy of the corpus, cold,
warm, and after an append.

Warm refresh: 1.0 s -> 0.10 s of CPU, of which the scan itself is 70 ms;
the index for this corpus is 2.9 MB.

Adapted:
- Rebased onto #10606: the highest-output rule for streamed messages now
  lives where the index parses records, and decides between files too.
- The index records the timezone it was written in, and a change rereads
  every transcript, since its records hold local days.
- A file only counts as appended to when its inode and the hash of what
  was already read still match, so a transcript replaced by a larger one,
  or rewritten in place, is read from the start.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Label a Claude Team seat by its subscription, not its rate-limit tier (#11109)

The collector built the plan label from the OAuth rateLimitTier first, so a
Team premium seat, which runs on default_claude_max_5x, showed in the agents
panel as "Max 5x". Lead with subscriptionType and keep the multiplier as its
qualifier: Max still reads "Max 5x", a Team seat reads "Team 5x".

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* Label the Claude plan from the profile the CLI refreshes (#7225)

Adapted: the profile is found the same way current_account_id() finds it,
now shared as profile_path(): ~/.claude.json for the default home, the
home's own .claude.json otherwise. The original fell back to ~/.claude.json
for any home without CLAUDE_CONFIG_DIR set, so a secondary account read the
primary's tier. The profile's tier also keeps the subscription in the label,
so a Team seat stays "Team" (#11109).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Call a lapsed Claude access token paused, not signed out (#8093)

* Refresh Claude usage after the clock moves backwards (#9956)

* Bound unreadable Claude transcript warnings (#12414)

* Count Claude usage from opencode v2 sessions (#13894)

* Reload agent usage records when an inotify watch fails to rearm (#10067)

* Reload agent usage records after each update run instead of on a timer

Rather than #10067's two-minute timer per record, reload every record when
the omarchy-agent-usage-update process exits, the moment its files can have
been replaced. A reload that finds a file unchanged keeps its record, so the
panel isn't stirred up by identical data. The grep test now runs the QML
functions.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Show the agent status when the trouble line has no help text (#8497)

* Clear stale agent login guidance after a successful probe (#8892)

* Clear the Grok login hint after a successful probe

#8892 cleared the default login hint after a successful probe in the
Claude and Codex collectors; Grok's collector had the same stale hint.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Read Fireworks credentials from pi's auth.json (#7455)

The Fireworks collector skipped pi, Omarchy's default agent, when
walking its credential ladder, so a machine signed in to Fireworks only
through pi (/login fireworks) never showed the tab. Insert the key pi
stores in $PI_CODING_AGENT_DIR/auth.json (default ~/.pi/agent) between
the firectl auth.ini and the opencode fallback.

pi keys can be literals, $ENV_VAR/${ENV_VAR} references, or !command
shell lookups. The collector resolves the first two; command lookups
stay pi-only and are skipped rather than sent to the API verbatim.

* Call a lapsed Grok access token paused, not signed out

Grok's access token lives six hours and Grok mints a new one from its
refresh token whenever it starts, so a lapsed one is routine. Reporting
it as an expired sign-in made the panel offer Sign-in required several
times a day, sending people through grok login for nothing. With a
refresh token present it now reads as paused, like Claude's.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Keep showing Grok's last limits while it sits idle

While Grok hasn't run, nothing on the machine has spent its allowance,
so with a refresh token on hand the last numbers still stand: they show
as current rather than dimmed under a status line. A weekly window that
reset in the meantime starts over at 0%, a whole number of weeks on.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Ask for a Grok sign-in once its refresh token is past 30 days

A refresh token older than Grok's 30-day sign-in can't renew anything,
so the panel offers Sign-in required again instead of showing the last
limits as current. With nothing cached yet it says to start Grok, rather
than showing an empty section without a word.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Check both ends of what the Claude index read before resuming a transcript

A transcript rewritten in place could grow and change only after its
first kilobytes, and the index took it for an append. It now compares
the last kilobytes before the resume point too.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Simplify the agent usage collectors

- Codex: pass the forced-scan choice down instead of a module global, make
  the per-file reader's cache arguments required, shrink the cache record
  check, and drop guards for shapes that can't occur: an empty launcher
  path, realpath raising, mise itself being a lazy launcher, multi-line
  `mise which` output, and probing without a temp file for stderr.
- Claude: decide an append by the digest of both ends of what was read
  alone; the inode and mtime checks it made redundant are gone.
- Snapshot: the device id falls back to the hostname, which always exists.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Share fixture setup in the agent usage scanner tests

Every fixture home lives under one scratch directory with a single cleanup
trap, instead of a trap rewritten with a longer list for each new home, and
the Codex test builds its signed-in homes with one helper.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Treat a replaced Claude transcript as new even when its ends match

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

* Probe Codex without its error text when there's no temporary space

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

---------

Co-authored-by: tossbaws <17258053+tossbaws@users.noreply.github.com>
Co-authored-by: surim0n <suritech@gmail.com>
Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
Co-authored-by: anonwurcod <anonwurcod@proton.me>
Co-authored-by: Kevin Rajan <7121943+kvnloo@users.noreply.github.com>
Co-authored-by: Nate Ashby <nate.ashby11@gmail.com>
Co-authored-by: Aris Gysel <aris.gysel@me.com>
Co-authored-by: Brams <76213579+Brams-s@users.noreply.github.com>
Co-authored-by: This_Is_NPC <gabrielfollone27@gmail.com>
Co-authored-by: sanjyay <102979855+sanjyay@users.noreply.github.com>
Co-authored-by: PapistProtocol <12738904+PapistProtocol@users.noreply.github.com>
Co-authored-by: steez <stevedimakos97@gmail.com>
Co-authored-by: GPT-6 Astra <noreply@openai.com>
Co-authored-by: Ryan Yogan <ryanyogan@gmail.com>
Co-authored-by: Oli Denton <41393837+omdenton@users.noreply.github.com>
Co-authored-by: Igor Kramar <i@ikramar.ru>
Co-authored-by: Martin Eidensten <martin@meibe.se>
Co-authored-by: Romain Perron <rdj.perron@gmail.com>
Co-authored-by: Omarchy Contributor <contributor@users.noreply.github.com>
Co-authored-by: manuaudio <manu@arimaka.com>
Co-authored-by: Tyler South <tsouth2@gmail.com>
Co-authored-by: whathek <Hek846@users.noreply.github.com>
Co-authored-by: Ty Richards <me@tyrichards.com>
2026-10-02 22:03:07 -04:00

1964 lines
68 KiB
QML

import QtQuick
import QtQuick.Controls
import Quickshell
import Quickshell.Io
import qs.Commons
import qs.Ui
Panel {
id: root
moduleName: "omarchy.agents"
ipcTarget: "omarchy.agents"
manageIpc: false
readonly property color foreground: bar ? bar.foreground : Color.foreground
readonly property color urgent: bar ? bar.urgent : Color.urgent
readonly property color dim: Qt.darker(foreground, 1.55)
readonly property color surface: Color.popups.background
readonly property color track: Style.selectedFillFor(foreground, Color.accent)
readonly property string fontFamily: bar ? bar.fontFamily : Style.font.family
// Every subscription on one page, limits first: the question this panel
// answers is how much room is left, and where.
readonly property var providers: usage.enabledProviders
property bool cursorActive: false
// Countdowns and "as of" ages read this instead of Date.now() so the
// panel keeps telling the truth while it sits open.
property double nowMs: Date.now()
// Every account of every provider that has more than one, in page order.
// The keyboard picks one by its position; picking only looks, and Enter on
// a picked account is what moves new sessions to it.
readonly property var accountEntries: {
var out = []
for (var i = 0; i < providers.length; i++) {
var list = providerAccounts(providers[i])
if (list.length < 2) continue
for (var j = 0; j < list.length; j++) out.push({ provider: providers[i], account: list[j] })
}
return out
}
readonly property var pickedEntry: keyTarget && ["account", "autoswitch", "signin"].indexOf(keyTarget.kind) >= 0
? accountEntries[keyTarget.index] || null
: null
// The keyboard walks everything on the page that does something, in
// reading order, one row at a time: the hero's buttons, then each agent's
// header, its Sign-in required link or switchable accounts, and the starter
// tiles, or the agents to add while picking one. Up and down change rows, left and right move along one.
// Hovering moves the same cursor, so only one thing is lit.
readonly property var keyRows: {
var rows = []
var hero = []
if (addButtonShown) hero.push({ kind: "add", index: 0 })
if (addStage === "") hero.push({ kind: "launch", index: 0 })
if (hero.length > 0) rows.push(hero)
if (picking) {
var choices = []
for (var k = 0; k < addProviders.length; k++) choices.push({ kind: "choice", index: k })
rows.push(choices)
} else if (addStage === "") {
// Accounts count in the same order as accountEntries. Only what can be
// done is a stop: one that isn't active offers Autoswitch beside Use, a
// lapsed sign-in comes first, and the active account with nothing to
// fix is skipped.
var entry = 0
for (var p = 0; p < providers.length; p++) {
// Every agent's header is a stop, so Ctrl+Up/Down can move it.
rows.push([{ kind: "provider", index: p }])
var accounts = providerAccounts(providers[p])
if (accounts.length < 2) {
if (needsSignIn(providers[p])) rows.push([{ kind: "providerSignin", index: p }])
continue
}
for (var a = 0; a < accounts.length; a++, entry++) {
var row = []
if (needsSignIn(accounts[a])) row.push({ kind: "signin", index: entry })
if (!accounts[a].active) {
row.push({ kind: "autoswitch", index: entry })
row.push({ kind: "account", index: entry })
}
if (row.length > 0) rows.push(row)
}
}
if (!blankSlate) {
var tiles = []
for (var j = 0; j < starterPrompts.length; j++) tiles.push({ kind: "starter", index: j })
rows.push(tiles)
}
}
return rows
}
property int keyRow: 0
property int keyColumn: 0
readonly property var keyTarget: {
if (!cursorActive || keyRow < 0 || keyRow >= keyRows.length) return null
var row = keyRows[keyRow]
return row[Math.min(keyColumn, row.length - 1)]
}
function hasKey(kind, index) {
return !!keyTarget && keyTarget.kind === kind && keyTarget.index === (index || 0)
}
function pointAt(kind, index) {
for (var r = 0; r < keyRows.length; r++) {
for (var c = 0; c < keyRows[r].length; c++) {
if (keyRows[r][c].kind === kind && keyRows[r][c].index === (index || 0)) {
keyRow = r
keyColumn = c
cursorActive = true
return
}
}
}
}
// The first arrow only shows where the cursor is, as in the other panels.
function moveKey(dx, dy) {
if (keyRows.length === 0) return
if (!cursorActive) {
keyRow = clamp(keyRow, 0, keyRows.length - 1)
keyColumn = 0
cursorActive = true
return
}
if (dy !== 0) {
keyRow = clamp(keyRow + dy, 0, keyRows.length - 1)
// Arriving on an account lands on Use; left reaches Autoswitch.
var row = keyRows[keyRow]
var use = -1
for (var c = 0; c < row.length; c++) if (row[c].kind === "account") use = c
keyColumn = use >= 0 ? use : Math.min(keyColumn, row.length - 1)
} else if (dx !== 0) {
keyColumn = clamp(Math.min(keyColumn, keyRows[keyRow].length - 1) + dx, 0, keyRows[keyRow].length - 1)
}
}
// The agent the cursor is in, by its position on the page.
function providerIndexOfKey() {
var target = keyTarget
if (!target) return -1
if (target.kind === "provider" || target.kind === "providerSignin") return target.index
if (["account", "autoswitch", "signin"].indexOf(target.kind) < 0) return -1
var entry = accountEntries[target.index]
return entry ? providers.indexOf(entry.provider) : -1
}
// Ctrl+Up/Down carries the agent the cursor is in up or down the page, and
// the cursor along with it.
function reorderProvider(dy) {
var from = providerIndexOfKey()
if (from < 0) return
var to = clamp(from + dy, 0, providers.length - 1)
if (to === from) return
usage.moveProvider(providers[from].providerId, to)
Qt.callLater(function() { pointAt("provider", to) })
}
// Dragging an agent by its mark lights the header it would land on, and
// moves it there on release: the sections are rebuilt when the order
// changes, which would drop a drag still in progress.
property string dragProviderId: ""
property int dragTarget: -1
// An account name being edited keeps Ctrl+Up/Down: moving its agent would
// rebuild the section and drop the unfinished name.
property bool renaming: false
function dragProviderOver(y) {
for (var i = 0; i < providerSections.count; i++) {
var item = providerSections.itemAt(i)
if (item && y >= item.y && y < item.y + item.height) {
dragTarget = i
return
}
}
}
function dropProvider() {
var id = dragProviderId
var to = dragTarget
dragProviderId = ""
dragTarget = -1
if (id === "" || to < 0) return
usage.moveProvider(id, to)
Qt.callLater(function() { pointAt("provider", to) })
}
// Keeps whatever the cursor lands on inside the scrolled view.
function revealItem(item) {
if (!item || !panelFlick || !panelFlick.interactive) return
var top = item.mapToItem(column, 0, 0).y
var margin = Style.space(12)
if (top - margin < panelFlick.contentY)
panelFlick.contentY = Math.max(0, top - margin)
else if (top + item.height + margin > panelFlick.contentY + panelFlick.height)
panelFlick.contentY = Math.min(panelFlick.contentHeight - panelFlick.height, top + item.height + margin - panelFlick.height)
}
// The bar icon lights up when any account new sessions use is nearly out,
// or a prepaid balance is down to its last 10%.
readonly property bool alarming: {
for (var i = 0; i < providers.length; i++) {
var window = bindingWindow(providers[i])
if (window && window.percent >= 0.9) return true
if (balanceAlarming(providers[i].balance)) return true
}
return false
}
function clamp(v, lo, hi) { return Math.max(lo, Math.min(hi, v)) }
function alpha(c, a) { return Qt.rgba(c.r, c.g, c.b, a) }
// A provider's accounts, when it has any. The list arrives as a sequence
// rather than a JS array once it has passed through a model, so it's
// judged by its length.
function providerAccounts(p) {
return p && p.accounts && p.accounts.length > 0 ? p.accounts : []
}
function refreshNow() {
usage.refreshAll(true)
}
// ------------------------------------------------------------ adding
//
// Adding a subscription happens right here: pick a provider, name it if
// it's a second account, then sign in through the browser while the panel
// follows omarchy-agent-account-add --events. The browser taking focus may
// close the panel; the login carries on, and its result arrives as a
// notification too.
readonly property var addProviders: [
{ providerId: "claude", providerName: "Claude Code" },
{ providerId: "codex", providerName: "Codex" },
{ providerId: "grok", providerName: "Grok" }
]
property string addStage: ""
property string addProvider: ""
property var addChecks: ({})
property string addStatus: ""
property string addCode: ""
property string addUrl: ""
property bool addNeedsPaste: false
property string addResult: ""
// Whether the sign-in in progress opened a private window, so reopening its
// page does too.
property bool addPrivate: false
function addProviderName(id) {
for (var i = 0; i < addProviders.length; i++)
if (addProviders[i].providerId === id) return addProviders[i].providerName
return id
}
function addAccount() {
if (addStage === "running") return
addStage = "pick"
addChecks = ({})
if (!checkProcess.running) checkProcess.running = true
}
function chooseAddProvider(id) {
var state = addChecks[id] || ""
if (state === "") return
addProvider = id
if (state === "additional") addStage = "name"
else startAdd("")
}
function startAdd(label) {
addPrivate = (addChecks[addProvider] || "") === "additional"
addStatus = "Starting…"
addCode = ""
addUrl = ""
// Claude's login offers pasting a code back whenever its page shows one,
// and its prompt for it has no newline to read it by, so the field is
// there from the start.
addNeedsPaste = addProvider === "claude"
addResult = ""
addStage = "running"
addProcess.command = ["omarchy-agent-account-add", "--events", addProvider].concat(label !== "" ? [label] : [])
addProcess.running = true
}
function cancelAdd() {
if (addProcess.running) addProcess.signal(15)
addStage = ""
root.focusKeys()
}
// A first sign-in keeps saying so until its limits arrive, rather than
// flashing the choices again while the record is written.
onBlankSlateChanged: if (!blankSlate && addStage === "done") addStage = ""
function submitPaste(code) {
if (!addProcess.running || code.trim() === "") return
addProcess.write(code.trim() + "\n")
addStatus = "Checking the code…"
}
// The add command's own lines are tagged; everything else is the CLI
// talking, and only two things in it matter here: a code to confirm in the
// browser (Grok), and an invitation to paste one back (Claude).
function handleAddLine(line) {
var text = String(line).replace(/\u001b\[[0-9;]*m/g, "")
var tagged = text.match(/^@@omarchy (status|done|error) (.*)$/)
if (tagged) {
if (tagged[1] === "status") {
addStatus = tagged[2]
} else {
addResult = tagged[2]
addStage = tagged[1]
if (tagged[1] === "done") {
usage.refreshLimits()
addDoneTimer.restart()
// Nothing to show yet means the sign-in is what counts as set up.
if (providers.length === 0 && !checkProcess.running) {
addChecks = ({})
checkProcess.running = true
}
}
}
return
}
var code = text.match(/^\s*([A-Z0-9]{4}-[A-Z0-9]{4})\s*$/)
if (code) addCode = code[1]
if (/paste code/i.test(text)) addNeedsPaste = true
var url = text.match(/(https:\/\/\S+)/)
if (url && addUrl === "") addUrl = url[1]
}
function reopenSignIn() {
if (addUrl === "") return
Util.execArgv(addPrivate ? ["omarchy-launch-browser", "--private", addUrl] : ["omarchy-launch-browser", addUrl])
}
// A few ways into making Omarchy your own, handed to the default agent.
readonly property var starterPrompts: [
{ glyph: "󰏘", label: "Theme", prompt: "Make me a new Omarchy theme. Ask me what look or inspiration I have in mind, then build it following the Omarchy skill's theming guide and switch to it." },
{ glyph: "󰐱", label: "Plugin", prompt: "Make me a new Omarchy shell plugin. Ask me what I'd like it to do, then build it following the Omarchy skill's plugin guide and enable it." },
{ glyph: "󰣆", label: "App", prompt: "Make me a new app for my Omarchy desktop. Ask me what it should do, then build it following the omarchy-app skill and install it so it shows up in the app launcher." }
]
function startPrompt(prompt) {
root.close()
Util.execArgv(["omarchy-agent-prompt", prompt])
}
function renameAccount(p, account, label) {
if (!p || !account) return
Util.execArgv(["omarchy-agent-account-rename", p.providerId, String(account.id), label])
}
function useAccount(p, account) {
if (!p || !account || account.active) return
Util.execArgv(["omarchy-agent-account-use", p.providerId, String(account.id)])
}
function autoSwitchFor(p) {
return !!p && !!p.accountSwitch && p.accountSwitch.mode === "auto"
}
function switchThreshold(p) {
return p && p.accountSwitch ? Number(p.accountSwitch.threshold || 95) : 95
}
function setSwitchMode(p, mode) {
if (!p || providerAccounts(p).length < 2 || mode === (autoSwitchFor(p) ? "auto" : "manual")) return
Util.execArgv(["bash", "-c", 'omarchy-agent-account-mode "$1" "$2" >/dev/null && omarchy-agent-usage-update --limits-only "$1"',
"omarchy-agent-account-mode", p.providerId, mode])
}
// `m` flips autoswitch for the picked account's provider, or the first
// provider with several accounts when nothing is picked.
function toggleSwitchMode() {
var p = pickedEntry ? pickedEntry.provider : (accountEntries.length > 0 ? accountEntries[0].provider : null)
if (p) setSwitchMode(p, autoSwitchFor(p) ? "manual" : "auto")
}
function activateSelection() {
var target = keyTarget
if (!target) refreshNow()
else if (target.kind === "add") addStage !== "" ? cancelAdd() : addAccount()
else if (target.kind === "choice") chooseAddProvider(addProviders[target.index].providerId)
else if (target.kind === "launch") launchAgent()
else if (target.kind === "starter") startPrompt(starterPrompts[target.index].prompt)
else if (target.kind === "providerSignin") signInAgain(providers[target.index], null)
else if (target.kind === "signin") signInAgain(pickedEntry.provider, pickedEntry.account)
else if (target.kind === "autoswitch") setSwitchMode(pickedEntry.provider, autoSwitchFor(pickedEntry.provider) ? "manual" : "auto")
else if (pickedEntry && !pickedEntry.account.active) useAccount(pickedEntry.provider, pickedEntry.account)
}
function isPicked(p, account) {
return !!pickedEntry && !!p && !!account
&& pickedEntry.provider.providerId === p.providerId && pickedEntry.account.id === account.id
}
// Hands the keyboard back to the panel after an inline edit.
function focusKeys() {
keyCatcher.forceActiveFocus()
}
function accountDetail(account) {
var parts = []
if (String(account.plan || "") !== "") parts.push(account.plan)
if (account.resetCredits && Number(account.resetCredits.available) > 0)
parts.push(account.resetCredits.available + " free reset" + (Number(account.resetCredits.available) === 1 ? "" : "s"))
return parts.join(" · ")
}
// A lapsed or missing sign-in is something you can fix from here; any other
// trouble is only reported.
function needsSignIn(item) {
var status = String(item && item.usageStatusText || "")
return status === "Sign-in expired" || status === "Waiting for auth"
}
function otherTrouble(item) {
var status = String(item && item.usageStatusText || "")
return status !== "" && !needsSignIn(item) ? status : ""
}
// Sign an account that's already here in again, following along in the
// add view just like adding one.
function signInAgain(p, account) {
if (!p || addStage === "running") return
addProvider = p.providerId
addPrivate = !!account && account.primary !== true
addStatus = "Starting…"
addCode = ""
addUrl = ""
// Claude's login offers pasting a code back whenever its page shows one,
// and its prompt for it has no newline to read it by, so the field is
// there from the start.
addNeedsPaste = addProvider === "claude"
addResult = ""
addStage = "running"
addProcess.command = ["omarchy-agent-account-add", "--events", "--reauth",
account && account.primary !== true ? String(account.id) : ":primary", p.providerId]
addProcess.running = true
}
function resetCreditsText(credits) {
if (!credits || !(Number(credits.available) > 0)) return ""
var count = Number(credits.available)
var text = count + " free reset" + (count === 1 ? "" : "s")
var expires = new Date(String(credits.nextExpiresAt || "")).getTime()
if (isFinite(expires) && expires > nowMs) text += " · next expires in " + formatDuration(expires - nowMs)
return text
}
function planLabel(p) {
var tier = String(p && p.tierLabel || "")
return tier === "" ? "" : tier.charAt(0).toUpperCase() + tier.slice(1)
}
function launchAgent() {
if (root.bar) root.bar.run("omarchy-agent --pick")
root.close()
}
// ---------------------------------------------------------------- limits
//
// Both providers report the same two shapes: a short rolling session window
// and a long weekly one. Everything below normalizes them into one record so
// the meters speak a single language.
// Claude spells its windows out ("Session (5-hour)"), Codex abbreviates
// them ("5h window", "30m window"). Both have to land on the same record.
function windowIsLong(text) {
return text.indexOf("week") >= 0 || text.indexOf("7-day") >= 0 || text.indexOf("seven") >= 0
|| text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0
}
function windowSpanMs(label) {
var text = String(label || "").toLowerCase()
if (text.indexOf("month") >= 0 || text.indexOf("30-day") >= 0) return 30 * 24 * 3600 * 1000
if (windowIsLong(text)) return 7 * 24 * 3600 * 1000
var hours = text.match(/(\d+)\s*-?\s*h(?:our)?\b/)
if (hours) return Number(hours[1]) * 3600 * 1000
var minutes = text.match(/(\d+)\s*-?\s*m(?:in(?:ute)?s?)?\b/)
if (minutes) return Number(minutes[1]) * 60 * 1000
return 0
}
function windowTitle(label) {
var text = String(label || "").toLowerCase()
if (text.indexOf("month") >= 0) return "Monthly"
if (windowIsLong(text)) return "Weekly"
if (text.indexOf("session") >= 0 || windowSpanMs(label) > 0) return "Session"
var plain = String(label || "").replace(/\s*\(.*\)\s*/, "").trim()
return plain === "" ? "Limit" : plain
}
// A collector that already knows which window a limit belongs to says so,
// and that beats reading it back out of the label: a model-scoped limit is
// titled after its model, and a name like "Opus 5 (1M context)" would parse
// as a one-minute window.
function limitWindow(label, percent, resetAt, title) {
return {
title: String(title || "") !== "" ? String(title) : windowTitle(label),
percent: Number(percent),
resetAt: String(resetAt || "")
}
}
function limitWindows(p) {
if (!p) return []
var out = []
var list = p.limits || []
for (var i = 0; i < list.length; i++) {
var entry = list[i] || {}
var percent = Number(entry.percent)
if (percent >= 0) out.push(limitWindow(entry.label, percent, entry.resetsAt, entry.title))
}
return out
}
// A model-scoped window ("Fable Weekly") is its own allowance, but it runs
// on the same clock as the window it's named for, so it's shown attached to
// that row rather than as a row of its own. One with nothing to attach to
// still gets its own row.
function scopedPart(title) {
var match = String(title || "").match(/^(.+) (Session|Weekly|Monthly)$/)
return match ? { model: match[1], window: match[2] } : null
}
function displayWindows(p) {
var windows = limitWindows(p)
var out = []
var byTitle = {}
for (var i = 0; i < windows.length; i++) {
if (scopedPart(windows[i].title)) continue
windows[i].scoped = []
out.push(windows[i])
byTitle[windows[i].title] = windows[i]
}
for (var j = 0; j < windows.length; j++) {
var part = scopedPart(windows[j].title)
if (!part) continue
var base = byTitle[part.window]
if (base) {
base.scoped.push({ title: part.model, percent: windows[j].percent, resetAt: windows[j].resetAt })
} else {
windows[j].scoped = []
out.push(windows[j])
}
}
return out
}
// The window that decides how much room is left — the fullest one, since
// that is what stops the next prompt.
function bindingWindow(p) {
var windows = limitWindows(p)
var best = null
for (var i = 0; i < windows.length; i++) {
if (!best || windows[i].percent > best.percent) best = windows[i]
}
return best
}
function resetMsFor(w) {
if (!w || w.resetAt === "") return -1
var ms = new Date(w.resetAt).getTime()
return isFinite(ms) ? ms - root.nowMs : -1
}
function formatDuration(ms) {
if (!(ms > 0)) return "now"
var minutes = Math.floor(ms / 60000)
var hours = Math.floor(minutes / 60)
var days = Math.floor(hours / 24)
if (days > 0) return days + "d " + (hours % 24) + "h"
if (hours > 0) return hours + "h " + (minutes % 60) + "m"
return Math.max(1, minutes) + "m"
}
// ---------------------------------------------------------------- balance
//
// Prepaid agents report a credit ledger instead of rate-limit windows: the
// record's balance object carries remaining, funded, and spent amounts.
// A prepaid account runs low the way a subscription window fills up: the
// last 10% of the funded credits lights the same alarm.
function balanceAlarming(b) {
return !!b && b.funded > 0 && b.remaining / b.funded <= 0.1
}
function currencyPrefix(currency) {
var code = String(currency || "USD").toUpperCase()
if (code === "USD") return "$"
if (code === "EUR") return "€"
if (code === "GBP") return "£"
return code + " "
}
function formatMoney(value, currency) {
var amount = Number(value)
if (!isFinite(amount)) amount = 0
return currencyPrefix(currency) + amount.toFixed(2)
}
function balanceDetailText(b) {
if (!b || !(b.funded > 0)) return ""
var text = formatMoney(b.spent, b.currency) + " spent of " + formatMoney(b.funded, b.currency) + " funded"
if (b.estimated) text += " · estimated"
return text
}
// ---------------------------------------------------------------- summary
//
// The hero's line rotates through what the token counts add up to across
// every agent, now that they no longer get charts of their own.
readonly property var summaryPhrases: {
var rev = usage.dataRevision
var week = 0
var today = 0
var prompts = 0
var sessions = 0
var byDay = {}
var byModel = {}
for (var i = 0; i < providers.length; i++) {
var p = providers[i]
var days = p.recentDays || []
for (var d = 0; d < days.length; d++) {
var tokens = Number(days[d].messageCount || 0)
week += tokens
byDay[days[d].date] = (byDay[days[d].date] || 0) + tokens
}
today += Number(p.todayTotalTokens || 0)
if (p.hasPromptStats !== false) {
prompts += Number(p.todayPrompts || 0)
sessions += Number(p.todaySessions || 0)
}
var models = p.modelUsage || {}
for (var id in models) {
var bucket = models[id] || {}
var total = Number(bucket.inputTokens || 0) + Number(bucket.outputTokens || 0)
+ Number(bucket.cacheReadInputTokens || 0) + Number(bucket.cacheCreationInputTokens || 0)
var name = usage.friendlyModelName(id)
byModel[name] = (byModel[name] || 0) + total
}
}
var phrases = []
if (week > 0) phrases.push(usage.formatTokenCount(week) + " tokens this week")
if (today > 0) phrases.push(usage.formatTokenCount(today) + " tokens today")
var topModel = ""
for (var model in byModel) if (topModel === "" || byModel[model] > byModel[topModel]) topModel = model
if (topModel !== "" && byModel[topModel] > 0) phrases.push("Mostly " + topModel)
var busiest = ""
for (var date in byDay) if (busiest === "" || byDay[date] > byDay[busiest]) busiest = date
if (busiest !== "" && byDay[busiest] > 0) phrases.push("Busiest day: " + dayName(busiest))
if (prompts > 0) phrases.push(prompts + " prompt" + (prompts === 1 ? "" : "s") + " today")
if (sessions > 0) phrases.push(sessions + " session" + (sessions === 1 ? "" : "s") + " today")
return phrases
}
property int phraseIndex: 0
// Every record that lands rebuilds the phrases, and opening the panel
// refreshes each agent in turn. Indexing the live list would swap the line
// on each of those, so the hero holds what it shows until the next fade.
property string shownPhrase: ""
readonly property string heroPhrase: addStage !== "" || blankSlate
? addHeading
: (shownPhrase !== "" ? shownPhrase : "Subscriptions")
function showPhrase() {
var n = summaryPhrases.length
shownPhrase = n > 0 ? summaryPhrases[phraseIndex % n] : ""
}
onSummaryPhrasesChanged: if (shownPhrase === "" || summaryPhrases.length <= 1) showPhrase()
function dayName(date) {
var parsed = new Date(String(date || "") + "T00:00:00")
if (isNaN(parsed.getTime())) return String(date || "")
return ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"][parsed.getDay()]
}
// Only speaks up when the numbers cover more than this machine.
function footerText() {
if (usage.syncStatusText !== "") return usage.syncStatusText
for (var i = 0; i < providers.length; i++) {
var count = Number(providers[i].syncDeviceCount || 0)
if (providers[i].syncEnabled && count > 0)
return "Merged from " + count + " device" + (count === 1 ? "" : "s")
}
return ""
}
// Agents that ship a white mark carry an `assets/<id>-light.svg` twin for
// light surfaces; marks that work on both (Claude's brand-orange) ship one
// file. The luminance check decides which candidate to try first.
function colorChannelLuminance(value) {
var channel = Number(value)
if (!isFinite(channel)) return 0
return channel <= 0.03928 ? channel / 12.92 : Math.pow((channel + 0.055) / 1.055, 2.4)
}
function colorLuminance(color) {
return 0.2126 * colorChannelLuminance(color.r)
+ 0.7152 * colorChannelLuminance(color.g)
+ 0.0722 * colorChannelLuminance(color.b)
}
// Marks resolve by convention, so a new agent's data file needs nothing
// from this panel: assets/<id>.svg if it ships one, the module's bar glyph
// if it doesn't.
function iconCandidatesForProvider(p, surfaceColor) {
if (!p) return []
var candidates = []
if (colorLuminance(surfaceColor || Color.background) >= 0.5)
candidates.push(Qt.resolvedUrl("assets/" + p.providerId + "-light.svg"))
candidates.push(Qt.resolvedUrl("assets/" + p.providerId + ".svg"))
return candidates
}
// Always in the bar: on a machine with no agent yet, the panel is where you
// set one up.
readonly property bool anySignedIn: {
for (var id in addChecks)
if (addChecks[id] !== "first") return true
return false
}
readonly property bool blankSlate: providers.length === 0 && !anySignedIn
// Choosing a provider: asked for with the +, or simply what the panel is
// while nothing is set up. It's derived rather than switched into, so
// records that load a moment after the panel opens take its place.
readonly property bool picking: addStage === "pick" || (blankSlate && addStage === "")
// The + in the hero turns into the X that leaves adding. A first setup has
// nothing to go back to until an agent is chosen.
readonly property bool addButtonShown: !blankSlate || !picking
// Adding takes over the hero's line, and the cursor starts over.
readonly property string addHeading: picking || addProvider === ""
? "Add an account"
: "Add " + (/^[AEIOU]/.test(addProviderName(addProvider)) ? "an " : "a ") + addProviderName(addProvider) + " account"
onAddStageChanged: {
phraseSwap.stop()
hero.metaOpacity = 1.0
resetKeys()
}
// A first setup ends when the records land, which swaps the rows under the
// cursor without any stage change.
onPickingChanged: resetKeys()
// Picking an agent to add starts on the first one, ready for Enter. The
// rows follow the same stage change, so the cursor waits a tick for them.
function resetKeys() {
cursorActive = false
keyRow = 0
keyColumn = 0
Qt.callLater(function() { if (picking) pointAt("choice", 0) })
}
implicitWidth: button.implicitWidth
implicitHeight: button.implicitHeight
onOpenedChanged: if (opened) {
if (addStage !== "running") addStage = ""
resetKeys()
if (providers.length === 0 && !checkProcess.running) checkProcess.running = true
nowMs = Date.now()
if (panelFlick) panelFlick.contentY = 0
usage.refreshLimits()
Qt.callLater(function() { keyCatcher.forceActiveFocus() })
}
Main {
id: usage
settings: root.settings
}
Process {
id: checkProcess
running: false
command: ["omarchy-agent-account-add", "--check"]
stdout: SplitParser {
onRead: function(line) {
var parts = String(line).trim().split(" ")
if (parts.length !== 2) return
var checks = Object.assign({}, root.addChecks)
checks[parts[0]] = parts[1]
root.addChecks = checks
}
}
}
Process {
id: addProcess
running: false
stdinEnabled: true
stdout: SplitParser { onRead: function(line) { root.handleAddLine(line) } }
onExited: {
if (root.addStage === "running") {
root.addResult = "The sign-in didn't finish."
root.addStage = "error"
}
}
}
Timer {
id: addDoneTimer
interval: 2500
onTriggered: if (root.addStage === "done" && !root.blankSlate) root.addStage = ""
}
// Cheap enough to keep running: it only re-evaluates text bindings, and a
// stale "resets in 2h" on a panel that is open is worse than a timer.
Timer {
interval: 30000
running: root.opened
repeat: true
onTriggered: root.nowMs = Date.now()
}
Timer {
interval: 2800
running: root.opened && root.summaryPhrases.length > 1 && root.addStage === "" && !root.blankSlate
repeat: true
onTriggered: phraseSwap.restart()
}
SequentialAnimation {
id: phraseSwap
PropertyAnimation {
target: hero; property: "metaOpacity"
to: 0.0; duration: Style.duration(180); easing.type: Easing.OutQuad
}
ScriptAction {
script: {
root.phraseIndex = (root.phraseIndex + 1) % Math.max(1, root.summaryPhrases.length)
root.showPhrase()
}
}
PropertyAnimation {
target: hero; property: "metaOpacity"
to: 1.0; duration: Style.duration(260); easing.type: Easing.InQuad
}
}
ShellIpc {
target: root.ipcTarget
function open(): void { root.open() }
function close(): void { root.close() }
function show(): void { root.open() }
function hide(): void { root.close() }
function toggle(): void { root.toggle() }
function refresh(): string { root.refreshNow(); return "ok" }
}
BarIconButton {
id: button
anchors.fill: parent
bar: root.bar
text: "󱚣"
active: root.alarming
onPressed: function(buttonCode) {
if (buttonCode === Qt.RightButton) root.launchAgent()
else if (buttonCode === Qt.MiddleButton) root.refreshNow()
else root.toggle()
}
}
KeyboardPanel {
id: panel
anchorItem: button
owner: root
bar: root.bar
open: root.opened
focusTarget: keyCatcher
contentWidth: panel.fittedContentWidth(Style.space(380))
contentHeight: panel.fittedContentHeight(column.implicitHeight, Style.space(860))
PanelKeyCatcher {
id: keyCatcher
anchors.fill: parent
reorderable: root.addStage === "" && !root.renaming
onReorderRequested: function(dy) { root.reorderProvider(dy) }
onMoveRequested: function(dx, dy) {
// Naming and signing in have their own fields; there the arrows scroll.
if (root.addStage === "" || root.picking) root.moveKey(dx, dy)
else if (dy !== 0)
panelFlick.contentY = root.clamp(panelFlick.contentY + dy * Style.space(56), 0,
Math.max(0, panelFlick.contentHeight - panelFlick.height))
}
onActivateRequested: root.activateSelection()
onCloseRequested: {
if (root.addStage !== "") root.cancelAdd()
else root.close()
}
onTabRequested: function(direction) { root.switchPanel(direction) }
onTextKey: function(t) {
if (t === "r" || t === "R") root.refreshNow()
else if (t === "m" || t === "M") root.toggleSwitchMode()
else if (root.addStage === "" && t >= "1" && t <= "9" && Number(t) <= root.accountEntries.length) root.pointAt("account", Number(t) - 1)
}
Flickable {
id: panelFlick
// Reaches a little into the panel's padding on the left, with the
// content shifted back, so the box around a lit agent mark isn't
// clipped where it overhangs the content's edge.
readonly property real overhang: Style.space(8)
anchors.fill: parent
anchors.leftMargin: -overhang
contentWidth: width
contentHeight: column.implicitHeight
clip: true
boundsBehavior: Flickable.StopAtBounds
flickableDirection: Flickable.VerticalFlick
interactive: contentHeight > height
ScrollBar.vertical: ScrollBar {
id: panelScroll
policy: ScrollBar.AsNeeded
}
Column {
id: column
x: panelFlick.overhang
// When the panel scrolls, the bar gets its own strip rather than
// sitting on top of the right-aligned numbers.
width: panelFlick.width - panelFlick.overhang - (panelFlick.interactive ? panelScroll.width + Style.space(6) : 0)
spacing: Style.space(16)
// ---------- Hero: agents · rotating summary · add ----------
PanelHero {
id: hero
width: parent.width
title: "Agents"
meta: root.heroPhrase
foreground: root.foreground
fontFamily: root.fontFamily
iconComponent: Component {
Text {
textFormat: Text.PlainText
text: button.text
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.display
}
}
trailingControl: Component {
Row {
spacing: Style.space(6)
visible: root.addStage === "" || root.addButtonShown
HeroButton {
visible: root.addButtonShown
hasCursor: root.hasKey("add")
onHovered: root.pointAt("add")
readonly property bool adding: root.addStage !== ""
glyph: adding ? "󰅖" : "󰐕"
tooltip: adding ? "Back to the limits" : "Add a subscription"
onClicked: adding ? root.cancelAdd() : root.addAccount()
}
HeroButton {
visible: root.addStage === ""
hasCursor: root.hasKey("launch")
onHovered: root.pointAt("launch")
glyph: "󰞷"
tooltip: "Start the default agent"
onClicked: root.launchAgent()
}
}
}
}
AddView {
visible: root.addStage !== "" || root.blankSlate
width: column.width
}
Repeater {
id: providerSections
model: root.addStage === "" ? root.providers : []
ProviderSection {
required property var modelData
required property int index
width: column.width
provider: modelData
providerIndex: index
}
}
// ---------- Make something ----------
PanelSeparator {
visible: root.addStage === "" && !root.blankSlate
foreground: root.foreground
}
Column {
visible: root.addStage === "" && !root.blankSlate
width: parent.width
spacing: Style.space(12)
PanelSectionHeader {
text: "MAKE SOMETHING COOL"
foreground: root.foreground
fontFamily: root.fontFamily
}
Row {
id: tileRow
width: parent.width
spacing: Style.space(10)
Repeater {
model: root.starterPrompts
StarterTile {
required property var modelData
required property int index
hasCursor: root.hasKey("starter", index)
onHovered: root.pointAt("starter", index)
width: (tileRow.width - tileRow.spacing * 2) / 3
glyph: modelData.glyph
title: modelData.label
onClicked: root.startPrompt(modelData.prompt)
}
}
}
}
Text {
textFormat: Text.PlainText
visible: text !== ""
width: parent.width
topPadding: Style.space(2)
text: root.footerText()
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
horizontalAlignment: Text.AlignHCenter
elide: Text.ElideRight
}
}
}
}
}
// Adding a subscription, in place of the list: pick, name, sign in.
component AddView: Column {
id: add
spacing: Style.space(14)
PanelSeparator { foreground: root.foreground }
Text {
visible: root.blankSlate && root.picking
width: parent.width
text: "Sign in to an AI coding subscription, and this panel keeps track of how much of it you have left."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
// Pick: each agent by a large mark over its name, three across.
Row {
id: choiceRow
visible: root.picking
width: parent.width
spacing: Style.space(10)
Repeater {
model: root.picking ? root.addProviders : []
Item {
id: choice
required property var modelData
required property int index
readonly property string state: root.addChecks[modelData.providerId] || ""
readonly property bool available: state === "first" || state === "additional"
readonly property bool hasCursor: root.hasKey("choice", index)
width: (choiceRow.width - choiceRow.spacing * (root.addProviders.length - 1)) / root.addProviders.length
implicitHeight: choiceBody.implicitHeight + Style.space(16)
onHasCursorChanged: if (hasCursor) root.revealItem(choice)
Column {
id: choiceBody
anchors.centerIn: parent
spacing: Style.space(10)
ProviderIcon {
anchors.horizontalCenter: parent.horizontalCenter
provider: choice.modelData
size: Style.font.display * 1.6
scale: choice.hasCursor ? 1.08 : 1.0
Behavior on scale { NumberAnimation { duration: Style.duration(120); easing.type: Easing.OutQuad } }
}
Text {
anchors.horizontalCenter: parent.horizontalCenter
textFormat: Text.PlainText
text: choice.modelData.providerName
color: choice.hasCursor ? Color.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
}
MouseArea {
anchors.fill: parent
hoverEnabled: true
cursorShape: choice.available ? Qt.PointingHandCursor : Qt.ArrowCursor
onEntered: root.pointAt("choice", choice.index)
onClicked: root.chooseAddProvider(choice.modelData.providerId)
}
}
}
}
// Name: a second account needs telling apart from the first.
Column {
visible: root.addStage === "name"
width: parent.width
spacing: Style.space(10)
Text {
width: parent.width
text: "Name this account. It signs in through a private window, so your browser's current account isn't picked up."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
TextField {
id: addNameField
width: parent.width
placeholderText: "Work"
foreground: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
onVisibleChanged: if (visible) { text = ""; forceActiveFocus() }
onAccepted: root.startAdd(text.trim())
Keys.onEscapePressed: function(event) { root.cancelAdd(); event.accepted = true }
}
}
// Running: what's happening, and whatever the sign-in needs from you.
Column {
visible: root.addStage === "running"
width: parent.width
spacing: Style.space(12)
Text {
width: parent.width
textFormat: Text.PlainText
text: root.addStatus
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
Column {
visible: root.addCode !== ""
width: parent.width
spacing: Style.space(4)
Text {
text: "Confirm this code in your browser"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Text {
textFormat: Text.PlainText
text: root.addCode
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.display
font.bold: true
font.letterSpacing: 2
}
}
Column {
visible: root.addNeedsPaste
width: parent.width
spacing: Style.space(6)
Text {
width: parent.width
text: "If the page shows a code instead of finishing, paste it here."
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
TextField {
id: pasteField
width: parent.width
placeholderText: "Code"
foreground: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
onAccepted: { root.submitPaste(text); text = "" }
Keys.onEscapePressed: function(event) { root.cancelAdd(); event.accepted = true }
}
}
TextLink {
visible: root.addUrl !== ""
text: "Open the sign-in page again"
onClicked: root.reopenSignIn()
}
}
// Done or failed.
Text {
visible: root.addStage === "done" || root.addStage === "error"
width: parent.width
textFormat: Text.PlainText
text: root.addResult
color: root.addStage === "error" ? root.urgent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
wrapMode: Text.WordWrap
}
}
// One provider: its mark, name, and plan, then its limits — or, with
// several accounts, each account's name, state, and limits in turn. A
// prepaid provider shows its balance instead.
component ProviderSection: Column {
id: section
property var provider: null
property int providerIndex: -1
readonly property var accounts: root.providerAccounts(provider)
readonly property bool multi: accounts.length > 1
readonly property var windows: root.displayWindows(provider)
readonly property var balance: provider ? (provider.balance || null) : null
spacing: Style.space(16)
opacity: root.dragProviderId !== "" && provider && root.dragProviderId === provider.providerId ? 0.5 : 1.0
PanelSeparator { foreground: root.foreground }
Item {
id: sectionHead
width: parent.width
implicitHeight: Math.max(sectionMark.height, sectionName.implicitHeight)
// Lit for the keyboard, and as the drop spot while an agent is dragged.
readonly property bool lit: root.dragProviderId !== "" ? root.dragTarget === section.providerIndex : root.hasKey("provider", section.providerIndex)
onLitChanged: if (lit && root.dragProviderId === "") root.revealItem(sectionHead)
// Only the mark is lit: it's the handle the agent moves by.
CursorSurface {
anchors.fill: sectionMark
anchors.margins: -Style.space(5)
z: -1
hasCursor: sectionHead.lit
foreground: root.foreground
}
ProviderIcon {
id: sectionMark
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
provider: section.provider
// The mark is the handle for dragging the agent up or down the page.
MouseArea {
anchors.fill: parent
anchors.margins: -Style.space(4)
hoverEnabled: true
preventStealing: true
cursorShape: pressed ? Qt.ClosedHandCursor : Qt.OpenHandCursor
onPressed: {
root.dragProviderId = section.provider ? section.provider.providerId : ""
root.dragTarget = section.providerIndex
}
onPositionChanged: function(mouse) {
if (pressed) root.dragProviderOver(mapToItem(column, mouse.x, mouse.y).y)
}
onReleased: root.dropProvider()
onCanceled: { root.dragProviderId = ""; root.dragTarget = -1 }
}
}
Text {
id: sectionName
anchors.left: sectionMark.right
anchors.leftMargin: Style.space(10)
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
text: section.provider ? section.provider.providerName : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
Text {
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
visible: !section.multi
textFormat: Text.PlainText
text: root.planLabel(section.provider)
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// Sign-in and endpoint trouble for a single-account provider; with
// several, each account says so on its own line.
TextLink {
visible: !section.multi && root.needsSignIn(section.provider)
picked: root.hasKey("providerSignin", section.providerIndex)
text: "Sign-in required"
idleColor: root.urgent
tooltip: "Sign in to " + (section.provider ? section.provider.providerName : "") + " again"
onClicked: root.signInAgain(section.provider, null)
}
Text {
visible: !section.multi && root.otherTrouble(section.provider) !== ""
width: parent.width
textFormat: Text.PlainText
// Shown for the status, so a record with no help to offer says the status rather than nothing.
text: section.provider ? String(section.provider.authHelpText || section.provider.usageStatusText || "") : ""
color: root.urgent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
wrapMode: Text.WordWrap
}
Column {
visible: !section.multi && section.windows.length > 0
width: parent.width
spacing: Style.space(12)
Repeater {
model: section.multi ? [] : section.windows
CompactLimit {
required property var modelData
width: section.width
window: modelData
stale: !!section.provider && section.provider.limitsStale === true
fetchedAt: section.provider ? Number(section.provider.limitsFetchedAt || 0) : 0
}
}
Text {
textFormat: Text.PlainText
visible: text !== ""
width: parent.width
text: root.resetCreditsText(section.provider ? section.provider.resetCredits : null)
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
// The meter shows what is left, not what is used: a prepaid account
// drains toward empty rather than filling toward a cap.
Column {
visible: !!section.balance
width: parent.width
spacing: Style.space(6)
Item {
width: parent.width
implicitHeight: balanceTitle.implicitHeight
Text {
id: balanceTitle
textFormat: Text.PlainText
width: parent.width * 0.3
anchors.verticalCenter: parent.verticalCenter
text: "Balance"
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
Meter {
anchors.left: balanceTitle.right
anchors.right: balanceValue.left
anchors.rightMargin: Style.spacing.md
anchors.verticalCenter: parent.verticalCenter
value: section.balance && section.balance.funded > 0 ? section.balance.remaining / section.balance.funded : -1
alarming: root.balanceAlarming(section.balance)
}
Text {
id: balanceValue
textFormat: Text.PlainText
width: Style.space(96)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
horizontalAlignment: Text.AlignRight
text: section.balance ? root.formatMoney(section.balance.remaining, section.balance.currency) : ""
color: root.balanceAlarming(section.balance) ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Text {
textFormat: Text.PlainText
visible: text !== ""
width: parent.width
text: root.balanceDetailText(section.balance)
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
}
Repeater {
model: section.multi ? section.accounts : []
Column {
id: accountBlock
required property var modelData
required property int index
width: section.width
topPadding: index > 0 ? Style.space(6) : 0
spacing: Style.space(14)
AccountHeader {
width: parent.width
account: accountBlock.modelData
owner: section.provider
picked: root.isPicked(section.provider, accountBlock.modelData)
}
Column {
width: parent.width
spacing: Style.space(12)
Repeater {
model: root.displayWindows({ limits: accountBlock.modelData.limits || [] })
CompactLimit {
required property var modelData
width: accountBlock.width
window: modelData
stale: accountBlock.modelData.stale === true
fetchedAt: Number(accountBlock.modelData.fetchedAt || 0)
}
}
}
}
}
}
// A provider's mark, falling back to the bar glyph when it ships none.
component ProviderIcon: Item {
id: mark
property var provider: null
property var candidates: root.iconCandidatesForProvider(provider, root.surface)
property string candidatesKey: candidates.join("\n")
property int candidateIndex: 0
onCandidatesKeyChanged: candidateIndex = 0
property real size: Style.font.heading
width: size
height: size
Image {
id: markImage
anchors.fill: parent
source: mark.candidateIndex < mark.candidates.length ? mark.candidates[mark.candidateIndex] : ""
sourceSize.width: mark.size * 2
sourceSize.height: mark.size * 2
fillMode: Image.PreserveAspectFit
// Advancing source from inside its own status change trips the
// binding-loop detector; defer the step one tick.
onStatusChanged: if (status === Image.Error && mark.candidateIndex < mark.candidates.length)
Qt.callLater(function() { mark.candidateIndex++ })
}
Text {
anchors.centerIn: parent
visible: markImage.status !== Image.Ready
textFormat: Text.PlainText
text: button.text
color: root.foreground
font.family: root.fontFamily
font.pixelSize: mark.size
}
}
// A starter: its glyph and what it makes, on a soft tile that warms to the
// accent on hover.
component StarterTile: Rectangle {
id: tile
signal clicked()
signal hovered()
property string glyph: ""
property string title: ""
property bool hasCursor: false
implicitHeight: tileBody.implicitHeight + Style.space(20)
radius: Style.cornerRadius
color: hasCursor ? root.alpha(Color.accent, 0.14) : root.alpha(root.foreground, 0.05)
onHasCursorChanged: if (hasCursor) root.revealItem(tile)
Row {
id: tileBody
anchors.centerIn: parent
spacing: Style.space(8)
Text {
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
text: tile.glyph
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.heading
}
Text {
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
text: tile.title
color: tile.hasCursor ? Color.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: true
}
}
MouseArea {
id: tileMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: tile.hovered()
onClicked: tile.clicked()
}
PanelToolTip {
visible: tileMouse.containsMouse
text: "Start your default agent on a new " + tile.title.toLowerCase()
}
}
// The tinted square in the hero's corner: start an agent, add a subscription.
component HeroButton: Rectangle {
id: heroButton
signal clicked()
signal hovered()
property string glyph: ""
property string tooltip: ""
property bool hasCursor: false
implicitWidth: Style.space(34)
implicitHeight: implicitWidth
radius: Style.cornerRadius
// The cursor needs more than a shade deeper to read on a tinted square.
color: root.alpha(Color.accent, hasCursor ? 0.3 : 0.12)
border.width: hasCursor ? Math.max(1, Style.hoverBorderWidth) : 0
border.color: Color.accent
onHasCursorChanged: if (hasCursor) root.revealItem(heroButton)
Text {
anchors.centerIn: parent
textFormat: Text.PlainText
text: heroButton.glyph
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.heading
}
MouseArea {
id: heroMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onEntered: heroButton.hovered()
onClicked: heroButton.clicked()
}
PanelToolTip {
visible: heroMouse.containsMouse
text: heroButton.tooltip
}
}
// A plain-text control: dim until hovered or picked, accent when it's the
// current choice. Stands in for bordered buttons, which pile up here.
component TextLink: Text {
id: link
signal clicked()
property bool current: false
property bool picked: false
property string tooltip: ""
property color idleColor: root.dim
readonly property bool hot: linkMouse.containsMouse || picked
onPickedChanged: if (picked) root.revealItem(link)
textFormat: Text.PlainText
color: current ? Color.accent : (hot ? root.foreground : idleColor)
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: current
font.underline: hot && !current
MouseArea {
id: linkMouse
anchors.fill: parent
anchors.margins: -Style.space(4)
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: link.clicked()
}
PanelToolTip {
visible: link.tooltip !== "" && linkMouse.containsMouse
text: link.tooltip
}
}
// An account's name, email and plan, with ACTIVE or a Use link on the
// right. Clicking the name edits it in place: Enter renames, Esc or
// clicking away leaves it as it was.
component AccountHeader: Item {
id: head
property var account: ({})
property var owner: null
property bool picked: false
property bool editing: false
// The new name shows at once; the record catches up a moment later.
property string renamedTo: ""
readonly property bool isActive: account.active === true
readonly property bool autoOn: root.autoSwitchFor(owner)
// Which of the row's links the keyboard is on, when it's on this row.
readonly property string pickedKind: picked && root.keyTarget ? root.keyTarget.kind : ""
readonly property string label: renamedTo !== "" ? renamedTo : String(account.label || account.id || "")
onAccountChanged: renamedTo = ""
onEditingChanged: root.renaming = editing
onPickedChanged: if (picked) root.revealItem(head)
// Anywhere on the line counts, so Use can show up when it's hidden.
HoverHandler { id: useHover }
implicitHeight: Math.max(headText.implicitHeight, headAction.implicitHeight)
function startRename() {
editing = true
nameField.text = label
nameField.forceActiveFocus()
nameField.selectAll()
}
function finishRename(save) {
if (!editing) return
var value = nameField.text.trim()
editing = false
root.focusKeys()
if (save && value !== "" && value !== label) {
renamedTo = value
root.renameAccount(owner, account, value)
}
}
// One line: the name, then its plan and any caveats, quieter. The
// email is the name's tooltip.
Item {
id: headText
anchors.left: parent.left
anchors.right: headAction.left
anchors.rightMargin: Style.spacing.sm
implicitHeight: head.editing ? nameField.implicitHeight : nameText.implicitHeight
Text {
id: nameText
textFormat: Text.PlainText
visible: !head.editing
anchors.left: parent.left
anchors.verticalCenter: parent.verticalCenter
width: Math.min(implicitWidth, parent.width * 0.6)
text: head.label
color: head.picked ? Color.accent : root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
font.bold: head.isActive
font.underline: nameMouse.containsMouse
elide: Text.ElideRight
MouseArea {
id: nameMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.IBeamCursor
onClicked: head.startRename()
}
PanelToolTip {
visible: nameMouse.containsMouse && String(head.account.email || "") !== ""
text: String(head.account.email || "")
}
}
// Bounded by the actions on the right: the plan and any trouble shorten
// first, and the Sign-in required link keeps its whole width.
Row {
id: detailRow
visible: !head.editing
anchors.left: nameText.right
anchors.leftMargin: Style.space(8)
anchors.verticalCenter: nameText.verticalCenter
width: Math.max(0, parent.width - nameText.width - Style.space(8))
spacing: Style.space(8)
Text {
textFormat: Text.PlainText
visible: text !== ""
width: Math.max(0, Math.min(implicitWidth, detailRow.width
- (signInDot.visible ? signInDot.implicitWidth + detailRow.spacing : 0)
- (signInLink.visible ? signInLink.implicitWidth + detailRow.spacing : 0)))
elide: Text.ElideRight
text: {
var parts = []
var detail = root.accountDetail(head.account)
if (detail !== "") parts.push(detail)
var trouble = root.otherTrouble(head.account)
if (trouble !== "") parts.push(trouble)
return parts.length > 0 ? "· " + parts.join(" · ") : ""
}
color: root.otherTrouble(head.account) !== "" ? root.urgent : root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
// The dot is punctuation, not part of the link.
Text {
id: signInDot
visible: root.needsSignIn(head.account)
text: "·"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.caption
}
TextLink {
id: signInLink
visible: root.needsSignIn(head.account)
picked: head.pickedKind === "signin"
text: "Sign-in required"
idleColor: root.urgent
tooltip: "Sign in to this account again"
onClicked: root.signInAgain(head.owner, head.account)
}
}
TextField {
id: nameField
visible: head.editing
anchors.left: parent.left
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
foreground: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.body
horizontalPadding: Style.space(4)
verticalPadding: Style.space(1)
onAccepted: head.finishRename(true)
onActiveFocusChanged: if (!activeFocus) head.finishRename(false)
Keys.onEscapePressed: function(event) {
head.finishRename(false)
event.accepted = true
}
}
}
Item {
id: headAction
anchors.right: parent.right
anchors.top: parent.top
implicitWidth: head.isActive ? headActive.implicitWidth : useRow.implicitWidth
implicitHeight: head.isActive ? headActive.implicitHeight : useRow.implicitHeight
Text {
id: headActive
visible: head.isActive
anchors.right: parent.right
text: "ACTIVE"
color: Color.accent
font.family: root.fontFamily
font.pixelSize: Style.font.caption
font.bold: true
}
// Switching now is Use. Hovering it also offers Autoswitch: move new
// sessions over by themselves once the active account reaches the
// threshold. While that's on it stays in view in place of Use, and
// clicking it again goes back to only being notified.
Row {
id: useRow
visible: !head.isActive
anchors.right: parent.right
spacing: Style.space(12)
TextLink {
visible: head.autoOn || useHover.hovered || head.picked
text: head.pickedKind === "autoswitch" ? "Autoswitch ⏎" : "Autoswitch"
picked: head.pickedKind === "autoswitch"
current: head.autoOn
tooltip: head.autoOn
? "Stop switching automatically"
: "Switch here automatically at " + root.switchThreshold(head.owner) + "%"
onClicked: root.setSwitchMode(head.owner, head.autoOn ? "manual" : "auto")
}
// With Autoswitch on, Use waits until the line is hovered or picked.
TextLink {
visible: !head.autoOn || useHover.hovered || head.picked
text: head.pickedKind === "account" ? "Use ⏎" : "Use"
picked: head.pickedKind === "account"
onClicked: root.useAccount(head.owner, head.account)
}
}
}
}
// One line per limit window: title, meter, percentage, and reset. A
// model-scoped allowance on the same clock ("Fable" on Weekly) is a marker
// on this row's meter, named in the row's tooltip.
component CompactLimit: Item {
id: compact
property var window: null
// Numbers kept past a failed check dim, and say how old they are on hover.
property bool stale: false
property real fetchedAt: 0
opacity: stale ? 0.5 : 1.0
readonly property var scoped: window && window.scoped ? window.scoped : []
readonly property bool alarming: window && window.percent >= 0.9
readonly property real resetMs: root.resetMsFor(window)
implicitHeight: Math.max(compactTitle.implicitHeight, compactValue.implicitHeight)
HoverHandler { id: compactHover }
PanelToolTip {
visible: compactHover.hovered && !!compact.window
text: {
var lines = []
if (compact.window) {
lines.push(compact.window.title + ": " + Math.round(compact.window.percent * 100) + "% used"
+ (compact.resetMs > 0 ? " · resets in " + root.formatDuration(compact.resetMs) : ""))
}
if (compact.stale)
lines.push(compact.fetchedAt > 0 && root.nowMs - compact.fetchedAt > 60000
? "As of " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago"
: "Last known")
for (var i = 0; i < compact.scoped.length; i++)
lines.push(compact.scoped[i].title + ": " + Math.round(compact.scoped[i].percent * 100) + "% of its "
+ String(compact.window ? compact.window.title : "").toLowerCase() + " allowance")
return lines.join("\n")
}
}
Text {
id: compactTitle
textFormat: Text.PlainText
width: parent.width * 0.3
anchors.verticalCenter: parent.verticalCenter
text: compact.window ? compact.window.title : ""
color: root.foreground
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
elide: Text.ElideRight
}
Meter {
anchors.left: compactTitle.right
anchors.right: compactValue.left
anchors.rightMargin: Style.space(20)
anchors.verticalCenter: parent.verticalCenter
value: compact.window ? compact.window.percent : -1
alarming: compact.alarming
markers: compact.scoped
}
// Sized to the widest time left ("19h 40m"), so every meter ends in the
// same place.
TextMetrics {
id: compactValueMetrics
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
text: "00h 00m"
}
// How long until the window resets. The meter says how full it is; the
// exact percentage is in the row's tooltip.
Text {
id: compactValue
width: Math.ceil(compactValueMetrics.advanceWidth)
anchors.right: parent.right
anchors.verticalCenter: parent.verticalCenter
textFormat: Text.PlainText
horizontalAlignment: Text.AlignRight
text: compact.resetMs > 0 ? root.formatDuration(compact.resetMs) : ""
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
}
// Rounded track showing the percentage of the allowance used.
component Meter: Item {
id: meter
property real value: -1
property bool alarming: false
property real thickness: Math.max(Style.space(4), Math.round(Style.spacing.controlHeight * 0.14))
// Other allowances on the same clock, drawn as ticks across the track.
property var markers: []
implicitHeight: thickness
Rectangle {
id: meterTrack
anchors.fill: parent
radius: height / 2
color: root.track
}
Rectangle {
anchors.left: meterTrack.left
anchors.verticalCenter: meterTrack.verticalCenter
height: meterTrack.height
radius: meterTrack.radius
width: meterTrack.width * root.clamp(meter.value, 0, 1)
color: meter.alarming ? root.urgent : root.foreground
Behavior on width {
NumberAnimation { duration: Style.duration(160); easing.type: Easing.OutCubic }
}
}
Repeater {
model: meter.markers
Rectangle {
required property var modelData
width: Math.max(2, Math.round(meter.thickness * 0.5))
height: meter.thickness * 2.5
radius: width / 2
anchors.verticalCenter: meterTrack.verticalCenter
x: root.clamp(meterTrack.width * root.clamp(Number(modelData.percent), 0, 1) - width / 2, 0, meterTrack.width - width)
color: meter.alarming ? root.urgent : root.foreground
}
}
}
}