Files
omarchy/shell/plugins/agents/Panel.qml
T

2003 lines
69 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 "last updated" 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 !== "" && status !== "Limits paused" && !needsSignIn(item) ? status : ""
}
function pausedWithoutLimits(item) {
return !!item && item.usageStatusText === "Limits paused" && limitWindows(item).length === 0
}
// 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) {
var reset = new Date(String(resetAt || "")).getTime()
var expired = isFinite(reset) && reset <= root.nowMs
return {
title: String(title || "") !== "" ? String(title) : windowTitle(label),
percent: expired ? 0 : Number(percent),
resetAt: expired ? "" : 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
}
UnavailableUsage {
visible: !section.multi && root.pausedWithoutLimits(section.provider)
width: parent.width
record: section.provider
}
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)
UnavailableUsage {
width: parent.width
record: accountBlock.modelData
}
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)
}
}
}
}
// With no measured windows, leave a place to discover how to get usage.
component UnavailableUsage: Item {
id: unavailable
property var record: null
visible: root.pausedWithoutLimits(record)
implicitHeight: unavailableLabel.implicitHeight
Text {
id: unavailableLabel
text: "Usage unavailable"
color: root.dim
font.family: root.fontFamily
font.pixelSize: Style.font.bodySmall
}
HoverHandler { id: unavailableHover }
PanelToolTip {
visible: unavailableHover.hovered && unavailable.visible
text: unavailable.record ? String(unavailable.record.authHelpText || "Usage has not been updated yet.") : ""
}
}
// 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
// The age of numbers kept past a failed check is shown only on hover.
property bool stale: false
property real fetchedAt: 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
? "Last updated " + root.formatDuration(root.nowMs - compact.fetchedAt) + " ago"
: compact.fetchedAt > 0 ? "Last updated less than a minute ago" : "Last updated time unavailable")
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
}
}
}
}