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/-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/.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 } } } }