@phenx-inc/ctlsurf 0.16.1 → 0.17.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (35) hide show
  1. package/out/headless/index.mjs +549 -116
  2. package/out/headless/index.mjs.map +4 -4
  3. package/out/main/index.js +416 -38
  4. package/out/preload/index.js +3 -0
  5. package/out/renderer/assets/{cssMode-DwHD6JSn.js → cssMode-BtQxI2Eb.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-Na9-bXYw.js → freemarker2-DRW5dmY5.js} +1 -1
  7. package/out/renderer/assets/{handlebars-D7gzKLS8.js → handlebars-CFLF3cP7.js} +1 -1
  8. package/out/renderer/assets/{html-DDK7xN27.js → html-DtnBOkwp.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-BfbLv-NB.js → htmlMode-DMVysDCg.js} +3 -3
  10. package/out/renderer/assets/{index-DID1LlMU.css → index-CEjwKjtd.css} +45 -0
  11. package/out/renderer/assets/{index-HoDaBul3.js → index-Dm70hk5s.js} +252 -26
  12. package/out/renderer/assets/{javascript-DJjWOxID.js → javascript-ChlyA1sf.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-BileWbig.js → jsonMode-D1_oHzeN.js} +3 -3
  14. package/out/renderer/assets/{liquid-DvNU_dI9.js → liquid-D4ftdy1G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-Bb27EY8c.js → lspLanguageFeatures-Ckqu5ubG.js} +1 -1
  16. package/out/renderer/assets/{mdx-CLkNPVgD.js → mdx-B9wsVYSC.js} +1 -1
  17. package/out/renderer/assets/{python-DOJdL4kC.js → python-e23tD6IS.js} +1 -1
  18. package/out/renderer/assets/{razor-CSr8UlJc.js → razor-CrDyXj3d.js} +1 -1
  19. package/out/renderer/assets/{tsMode-sbsAvk21.js → tsMode-BwGC5o4O.js} +1 -1
  20. package/out/renderer/assets/{typescript-r5QB4yRW.js → typescript-Do-98d_O.js} +1 -1
  21. package/out/renderer/assets/{xml-CYhKh3Pi.js → xml-Dpdu5R3m.js} +1 -1
  22. package/out/renderer/assets/{yaml-B8DsoaYW.js → yaml-B0Zt7LCJ.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/src/main/headless.ts +12 -0
  26. package/src/main/index.ts +8 -0
  27. package/src/main/monitor.ts +540 -0
  28. package/src/main/orchestrator.ts +10 -0
  29. package/src/main/pty.ts +5 -0
  30. package/src/main/tui.ts +37 -3
  31. package/src/preload/index.ts +4 -0
  32. package/src/renderer/App.tsx +9 -1
  33. package/src/renderer/components/Resources.tsx +217 -0
  34. package/src/renderer/components/StatusBar.tsx +6 -1
  35. package/src/renderer/styles.css +45 -0
package/src/main/tui.ts CHANGED
@@ -23,12 +23,19 @@ const BG_MODAL = `${CSI}48;2;31;35;53m` // #1f2335
23
23
  const BG_SELECTED = `${CSI}48;2;42;43;61m` // #2a2b3d
24
24
  const RESET = `${CSI}0m`
25
25
 
26
+ // Title writes share stdout with the agent's output; wait this long after the
27
+ // last PTY chunk so a title never lands inside one of the agent's escape
28
+ // sequences (node-pty can split them across chunks).
29
+ const TITLE_QUIET_MS = 50
30
+
26
31
  export interface TuiState {
27
32
  agentName: string
28
33
  cwd: string
29
34
  wsStatus: string
30
35
  workerId: string | null
31
36
  mode: string
37
+ // Resource + plan-quota summary from monitor.ts, appended to the title.
38
+ resources: string
32
39
  }
33
40
 
34
41
  export class Tui {
@@ -40,7 +47,12 @@ export class Tui {
40
47
  wsStatus: 'disconnected',
41
48
  workerId: null,
42
49
  mode: 'terminal',
50
+ resources: '',
43
51
  }
52
+ private lastPtyWrite = 0
53
+ private pendingTitle: string | null = null
54
+ private lastTitle = ''
55
+ private titleTimer: ReturnType<typeof setTimeout> | null = null
44
56
 
45
57
  constructor() {
46
58
  this.rows = process.stdout.rows || 24
@@ -60,6 +72,8 @@ export class Tui {
60
72
  * Restore terminal to normal state
61
73
  */
62
74
  destroy(): void {
75
+ if (this.titleTimer) clearTimeout(this.titleTimer)
76
+ this.titleTimer = null
63
77
  // Show cursor
64
78
  this.write(`${CSI}?25h`)
65
79
  }
@@ -94,6 +108,7 @@ export class Tui {
94
108
  * Write PTY output — full passthrough.
95
109
  */
96
110
  writePtyData(data: string): void {
111
+ this.lastPtyWrite = Date.now()
97
112
  this.write(data)
98
113
  }
99
114
 
@@ -101,14 +116,30 @@ export class Tui {
101
116
  * Update the terminal window/tab title via OSC escape sequence.
102
117
  */
103
118
  setTerminalTitle(title: string): void {
104
- this.write(`${ESC}]0;${title}\x07`)
119
+ this.pendingTitle = title
120
+ this.flushTitle()
121
+ }
122
+
123
+ private flushTitle(): void {
124
+ if (this.pendingTitle === null || this.titleTimer) return
125
+ const wait = TITLE_QUIET_MS - (Date.now() - this.lastPtyWrite)
126
+ if (wait > 0) {
127
+ this.titleTimer = setTimeout(() => {
128
+ this.titleTimer = null
129
+ this.flushTitle()
130
+ }, wait)
131
+ return
132
+ }
133
+ if (this.pendingTitle !== this.lastTitle) this.write(`${ESC}]0;${this.pendingTitle}\x07`)
134
+ this.lastTitle = this.pendingTitle
135
+ this.pendingTitle = null
105
136
  }
106
137
 
107
138
  /**
108
139
  * Build a title string from current state for the terminal tab.
109
140
  */
110
141
  updateTerminalTitle(): void {
111
- const { agentName, wsStatus, cwd } = this.state
142
+ const { agentName, wsStatus, cwd, resources } = this.state
112
143
  const displayCwd = this.shortenPath(cwd)
113
144
  const statusIcon = {
114
145
  connected: '\u25CF',
@@ -125,7 +156,10 @@ export class Tui {
125
156
  no_project: 'No Project',
126
157
  }[wsStatus] || wsStatus
127
158
 
128
- this.setTerminalTitle(`ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}`)
159
+ this.setTerminalTitle(
160
+ `ctlsurf \u00B7 ${agentName} \u00B7 ${statusIcon} ${statusLabel} \u00B7 ${displayCwd}` +
161
+ (resources ? ` \u00B7 ${resources}` : '')
162
+ )
129
163
  }
130
164
 
131
165
  /**
@@ -108,6 +108,10 @@ const api = {
108
108
  ipcRenderer.on('app:updateInfo', listener)
109
109
  return () => ipcRenderer.removeListener('app:updateInfo', listener)
110
110
  },
111
+ // Resources + plan quota for the status bar (shape: MonitorSnapshot in
112
+ // src/main/monitor.ts). `fast` = the details panel is open.
113
+ getResourceSnapshot: (fast: boolean): Promise<unknown> =>
114
+ ipcRenderer.invoke('monitor:snapshot', fast),
111
115
 
112
116
  // Settings (legacy)
113
117
  getSetting: (key: string): Promise<string | null> =>
@@ -14,6 +14,7 @@ import {
14
14
  findPaneIds,
15
15
  } from './components/PaneLayout'
16
16
  import { StatusBar } from './components/StatusBar'
17
+ import type { ResourceSnapshot } from './components/Resources'
17
18
  import { SettingsDialog } from './components/SettingsDialog'
18
19
  import { TicketPane } from './components/TicketPanel'
19
20
  import { ObserverPane } from './components/ObserverPanel'
@@ -98,6 +99,7 @@ declare global {
98
99
  getVersion: () => Promise<string>
99
100
  getUpdateInfo: () => Promise<UpdateInfo>
100
101
  onUpdateInfo: (callback: (info: UpdateInfo) => void) => () => void
102
+ getResourceSnapshot: (fast: boolean) => Promise<ResourceSnapshot>
101
103
  copyEmailTable: (html: string, text: string) => Promise<{ ok: boolean }>
102
104
  copyText: (text: string) => Promise<{ ok: boolean }>
103
105
  }
@@ -778,7 +780,13 @@ export default function App() {
778
780
  )}
779
781
  </div>
780
782
 
781
- <StatusBar wsStatus={wsStatus} cwd={cwd} onChangeCwd={handleChangeCwd} updateInfo={updateInfo} />
783
+ <StatusBar
784
+ wsStatus={wsStatus}
785
+ cwd={cwd}
786
+ onChangeCwd={handleChangeCwd}
787
+ updateInfo={updateInfo}
788
+ resourceKey={`${activeTabId}:${tabs.find(t => t.id === activeTabId)?.agent?.id ?? ''}`}
789
+ />
782
790
  <SettingsDialog open={showSettings} onClose={() => setShowSettings(false)} />
783
791
 
784
792
  {showAgentPicker && agents.length > 0 && (
@@ -0,0 +1,217 @@
1
+ import { useEffect, useState } from 'react'
2
+
3
+ /** Mirrors MonitorSnapshot in src/main/monitor.ts */
4
+ type Service = 'claude' | 'codex'
5
+ interface UsageWindow { label: string; short: string | null; pct: number; resets: string | null }
6
+ interface PlanUsage { ok: boolean; windows: UsageWindow[]; plan?: string | null; source: string; ts: string; error?: string }
7
+ interface AgentProcess {
8
+ tabId: string; name: string; active: boolean; remote: boolean; service: Service | null
9
+ pid: number | null; cpu: number | null; mem: number | null; procs: number
10
+ }
11
+ export interface ResourceSnapshot {
12
+ machine: {
13
+ ok: boolean; error?: string; ts: string; label: string
14
+ cpu?: { pct: number | null; cores: number; load: number[] }
15
+ mem?: { total: number; used: number; pct: number }
16
+ disk?: { total: number; free: number } | null
17
+ gpu?: number | null
18
+ history: { t: number; cpu: number | null; mem: number | null; gpu: number | null }[]
19
+ }
20
+ agents: AgentProcess[]
21
+ activeService: Service | null
22
+ usage: Partial<Record<Service, PlanUsage>>
23
+ interval: number
24
+ }
25
+
26
+ const SERVICE_NAME: Record<Service, string> = { claude: 'Claude', codex: 'Codex' }
27
+
28
+ const gb = (bytes: number | null | undefined) => bytes == null ? '—' : `${(bytes / 2 ** 30).toFixed(bytes < 10 * 2 ** 30 ? 1 : 0)} GB`
29
+ const pct = (x: number | null | undefined) => x == null ? '—' : `${Math.round(x)}%`
30
+ /** fills (memory, quota): green → yellow → red; load: neutral */
31
+ const fillTone = (x: number | null | undefined) => x == null ? 'var(--text-dim)' : x < 70 ? 'var(--success)' : x < 90 ? 'var(--warning)' : 'var(--danger)'
32
+
33
+ function ago(iso: string): string {
34
+ const s = Math.max(0, Math.round((Date.now() - new Date(iso).getTime()) / 1000))
35
+ if (s < 60) return `${s}s ago`
36
+ if (s < 3600) return `${Math.round(s / 60)}m ago`
37
+ if (s < 86400) return `${Math.round(s / 3600)}h ago`
38
+ return `${Math.round(s / 86400)}d ago`
39
+ }
40
+
41
+ /** Poll the main-process monitor: every 3 s while the panel is open, 10 s
42
+ * otherwise, and right away when the active tab or its agent changes. */
43
+ function useResourceSnapshot(open: boolean, refreshKey: string): ResourceSnapshot | null {
44
+ const [data, setData] = useState<ResourceSnapshot | null>(null)
45
+ useEffect(() => {
46
+ let cancelled = false
47
+ let timer: ReturnType<typeof setTimeout> | undefined
48
+ const poll = async () => {
49
+ try {
50
+ const snap = await window.worker.getResourceSnapshot(open)
51
+ if (!cancelled) setData(snap)
52
+ } catch { /* keep the last snapshot */ }
53
+ if (!cancelled) timer = setTimeout(poll, open ? 3000 : 10000)
54
+ }
55
+ void poll()
56
+ return () => { cancelled = true; clearTimeout(timer) }
57
+ }, [open, refreshKey])
58
+ return data
59
+ }
60
+
61
+ function Gauge({ label, value, tone, title }: { label: string; value: number | null | undefined; tone: string; title: string }) {
62
+ const v = Math.max(0, Math.min(100, value ?? 0))
63
+ return (
64
+ <span className="res-gauge" title={title}>
65
+ <span className="res-gauge-bar"><span style={{ height: `${v}%`, background: value == null ? 'transparent' : tone }} /></span>
66
+ <span className="res-gauge-k">{label}</span><span className="res-gauge-v">{pct(value)}</span>
67
+ </span>
68
+ )
69
+ }
70
+
71
+ /** Status-bar gauges: this machine's CPU/RAM and the active agent's plan
72
+ * quota. Click to open the details panel. */
73
+ export function ResourceBar({ refreshKey }: { refreshKey: string }) {
74
+ const [open, setOpen] = useState(false)
75
+ const data = useResourceSnapshot(open, refreshKey)
76
+ const m = data?.machine
77
+ return (
78
+ <div className="res-bar" onClick={() => setOpen(!open)} title="Resources and plan quota: click for details">
79
+ {m?.ok && (
80
+ <span className="res-group">
81
+ <Gauge label="CPU" value={m.cpu?.pct} tone="var(--accent)" title={`CPU ${pct(m.cpu?.pct)} of ${m.cpu?.cores} cores`} />
82
+ <Gauge label="RAM" value={m.mem?.pct} tone={fillTone(m.mem?.pct)} title={`RAM ${gb(m.mem?.used)} of ${gb(m.mem?.total)}`} />
83
+ </span>
84
+ )}
85
+ {data?.activeService && <QuotaGauges service={data.activeService} u={data.usage[data.activeService]} />}
86
+ {open && <ResourcePanel data={data} close={() => setOpen(false)} />}
87
+ </div>
88
+ )
89
+ }
90
+
91
+ /** Quota for the service the active tab's agent runs on. */
92
+ function QuotaGauges({ service, u }: { service: Service; u?: PlanUsage }) {
93
+ const name = SERVICE_NAME[service]
94
+ if (!u) return null // first fetch still running
95
+ const wins = u.windows.filter((w) => w.short)
96
+ return (
97
+ <span className="res-group">
98
+ <span className="res-name">{name}</span>
99
+ {u.ok && wins.length
100
+ ? wins.map((w) => (
101
+ <Gauge key={w.label} label={w.short!} value={w.pct} tone={fillTone(w.pct)}
102
+ title={`${name} ${w.label}: ${pct(w.pct)} used${w.resets ? ` · resets ${w.resets}` : ''}`} />
103
+ ))
104
+ : <span className="res-off" title={u.error ?? 'no usage reported'}>quota unavailable</span>}
105
+ </span>
106
+ )
107
+ }
108
+
109
+ function Bar({ value, tone }: { value: number | null | undefined; tone: string }) {
110
+ return <span className="res-rbar"><span style={{ width: `${Math.max(0, Math.min(100, value ?? 0))}%`, background: tone }} /></span>
111
+ }
112
+
113
+ function Spark({ values, tone, width = 120, height = 22 }: { values: (number | null)[]; tone: string; width?: number; height?: number }) {
114
+ const pts = values.map((v, i) => [values.length < 2 ? width : (i / (values.length - 1)) * width, height - ((v ?? 0) / 100) * (height - 2) - 1])
115
+ if (pts.length < 2) return <svg width={width} height={height} className="res-spark" />
116
+ const line = pts.map(([x, y]) => `${x.toFixed(1)},${y.toFixed(1)}`).join(' ')
117
+ return (
118
+ <svg width={width} height={height} className="res-spark">
119
+ <polygon points={`0,${height} ${line} ${width},${height}`} fill={tone} opacity={0.15} />
120
+ <polyline points={line} fill="none" stroke={tone} strokeWidth={1.4} />
121
+ </svg>
122
+ )
123
+ }
124
+
125
+ function UsageView({ service, u, active }: { service: Service; u: PlanUsage; active: boolean }) {
126
+ return (
127
+ <div className="res-card">
128
+ <div className="res-head">
129
+ <b>{SERVICE_NAME[service]} plan</b>{active && <span className="res-tag">active tab</span>}
130
+ <span className="res-spacer" /><span className="res-muted">as of {ago(u.ts)}</span>
131
+ </div>
132
+ {u.ok ? <>
133
+ {u.windows.map((w) => (
134
+ <div key={w.label} className="res-row">
135
+ <span className="res-k wide">{w.label}</span><Bar value={w.pct} tone={fillTone(w.pct)} />
136
+ <span className="res-v narrow">{pct(w.pct)}</span>
137
+ <span className="res-muted res-resets">{w.resets ? `resets ${w.resets}` : ''}</span>
138
+ </div>
139
+ ))}
140
+ <div className="res-muted res-note">from {u.source}{u.plan ? ` · plan ${u.plan}` : ''}</div>
141
+ </> : <div className="res-muted">{u.error ?? 'no usage reported'}</div>}
142
+ </div>
143
+ )
144
+ }
145
+
146
+ function MachineView({ m }: { m: ResourceSnapshot['machine'] }) {
147
+ const h = m.history
148
+ return (
149
+ <div className="res-card">
150
+ <div className="res-head">
151
+ <span className={`res-dot ${m.ok ? 'ok' : 'bad'}`} /><b>{m.label}</b>
152
+ <span className="res-spacer" /><span className="res-muted">updated {ago(m.ts)}</span>
153
+ </div>
154
+ {!m.ok ? <div className="res-err">{m.error ?? 'unavailable'}</div> : <>
155
+ <div className="res-row"><span className="res-k">CPU</span><Bar value={m.cpu?.pct} tone="var(--accent)" />
156
+ <span className="res-v">{pct(m.cpu?.pct)}</span><Spark values={h.map((p) => p.cpu)} tone="var(--accent)" /></div>
157
+ <div className="res-row"><span className="res-k" />
158
+ <span className="res-muted">{m.cpu?.cores} cores · load {m.cpu?.load.map((x) => x.toFixed(2)).join(' ')}</span></div>
159
+ <div className="res-row"><span className="res-k">RAM</span><Bar value={m.mem?.pct} tone={fillTone(m.mem?.pct)} />
160
+ <span className="res-v">{gb(m.mem?.used)} / {gb(m.mem?.total)}</span><Spark values={h.map((p) => p.mem)} tone={fillTone(m.mem?.pct)} /></div>
161
+ {m.gpu != null && <div className="res-row"><span className="res-k">GPU</span><Bar value={m.gpu} tone="var(--sev-digest)" />
162
+ <span className="res-v">{pct(m.gpu)}</span><Spark values={h.map((p) => p.gpu)} tone="var(--sev-digest)" /></div>}
163
+ {m.disk && <div className="res-row"><span className="res-k">Disk</span>
164
+ <Bar value={100 * (1 - m.disk.free / m.disk.total)} tone={fillTone(100 * (1 - m.disk.free / m.disk.total))} />
165
+ <span className="res-v">{gb(m.disk.free)} free</span></div>}
166
+ </>}
167
+ </div>
168
+ )
169
+ }
170
+
171
+ function AgentsView({ agents }: { agents: AgentProcess[] }) {
172
+ return (
173
+ <div className="res-card">
174
+ <div className="res-head"><b>Agents in tabs</b></div>
175
+ {agents.length ? agents.map((a) => (
176
+ <div key={a.tabId} className="res-agent">
177
+ <span className={a.active ? 'res-agent-name active' : 'res-agent-name'}>{a.name}</span>
178
+ {a.active && <span className="res-tag">active</span>}
179
+ <div className="res-muted">
180
+ {a.pid == null ? 'exited' : <>
181
+ pid {a.pid} · {a.procs} process{a.procs === 1 ? '' : 'es'}
182
+ {a.cpu != null ? ` · CPU ${a.cpu.toFixed(0)}%` : ''}{a.mem != null ? ` · ${gb(a.mem)}` : ''}
183
+ </>}
184
+ {a.remote ? ' · local ssh client (agent runs remotely)' : ''}
185
+ </div>
186
+ </div>
187
+ )) : <div className="res-muted">no agents running</div>}
188
+ </div>
189
+ )
190
+ }
191
+
192
+ function ResourcePanel({ data, close }: { data: ResourceSnapshot | null; close: () => void }) {
193
+ useEffect(() => {
194
+ const onKey = (e: KeyboardEvent) => { if (e.key === 'Escape') close() }
195
+ window.addEventListener('keydown', onKey)
196
+ return () => window.removeEventListener('keydown', onKey)
197
+ }, [close])
198
+ // Active tab's service first, then any other service an open tab uses.
199
+ const services = (Object.keys(data?.usage ?? {}) as Service[])
200
+ .sort((a, b) => Number(b === data?.activeService) - Number(a === data?.activeService))
201
+ return (
202
+ <div className="res-panel" onClick={(e) => e.stopPropagation()}>
203
+ <div className="res-panel-head">
204
+ <h2>Resources</h2><span className="res-muted">plan quota and this machine · every 3 s</span>
205
+ <span className="res-spacer" /><button className="res-close" onClick={close} aria-label="Close">✕</button>
206
+ </div>
207
+ <div className="res-panel-body">
208
+ {!data ? <div className="res-muted">sampling…</div> : <>
209
+ {services.map((s) => <UsageView key={s} service={s} u={data.usage[s]!} active={s === data.activeService} />)}
210
+ {data.activeService && !data.usage[data.activeService] && <div className="res-muted">checking {SERVICE_NAME[data.activeService]} quota…</div>}
211
+ <MachineView m={data.machine} />
212
+ <AgentsView agents={data.agents} />
213
+ </>}
214
+ </div>
215
+ </div>
216
+ )
217
+ }
@@ -1,4 +1,5 @@
1
1
  import { useState } from 'react'
2
+ import { ResourceBar } from './Resources'
2
3
 
3
4
  interface UpdateInfo {
4
5
  current: string
@@ -12,9 +13,12 @@ interface StatusBarProps {
12
13
  cwd: string | null
13
14
  onChangeCwd: () => void
14
15
  updateInfo: UpdateInfo | null
16
+ // Changes when the active tab or its agent does, so the quota gauges
17
+ // switch to the new agent's service right away.
18
+ resourceKey: string
15
19
  }
16
20
 
17
- export function StatusBar({ wsStatus, cwd, onChangeCwd, updateInfo }: StatusBarProps) {
21
+ export function StatusBar({ wsStatus, cwd, onChangeCwd, updateInfo, resourceKey }: StatusBarProps) {
18
22
  const [creating, setCreating] = useState(false)
19
23
 
20
24
  const wsLabel = {
@@ -63,6 +67,7 @@ export function StatusBar({ wsStatus, cwd, onChangeCwd, updateInfo }: StatusBarP
63
67
  </span>
64
68
  </div>
65
69
  <div className="statusbar-section">
70
+ <ResourceBar refreshKey={resourceKey} />
66
71
  <span className={`status-dot ${wsDotClass}`} />
67
72
  {isNoProject ? (
68
73
  <>
@@ -960,6 +960,51 @@ html, body, #root {
960
960
  font-weight: 500;
961
961
  }
962
962
 
963
+ /* Resources + plan quota (status bar gauges and details panel) */
964
+ .res-bar { display: flex; align-items: center; gap: 12px; cursor: pointer; padding: 1px 6px; margin-right: 8px; border-radius: 4px; }
965
+ .res-bar:hover { background: var(--bg-surface); }
966
+ .res-group { display: inline-flex; align-items: center; gap: 7px; }
967
+ .res-group + .res-group { padding-left: 10px; border-left: 1px solid var(--border-subtle); }
968
+ .res-name { color: var(--text-soft); }
969
+ .res-off { color: var(--text-muted); font-style: italic; }
970
+ .res-gauge { display: inline-flex; align-items: center; gap: 3px; }
971
+ .res-gauge-bar { width: 5px; height: 13px; background: var(--bg-surface-hi); border-radius: 2px; display: flex; align-items: flex-end; overflow: hidden; }
972
+ .res-gauge-bar > span { width: 100%; border-radius: 2px; transition: height .6s ease; }
973
+ .res-gauge-k { color: var(--text-muted); }
974
+ .res-gauge-v { color: var(--text-bright); font-family: 'JetBrains Mono', 'Fira Code', monospace; min-width: 26px; }
975
+
976
+ .res-panel { position: fixed; right: 0; bottom: 25px; width: 440px; max-height: 75vh; z-index: 50; cursor: default;
977
+ background: var(--bg-raised); border: 1px solid var(--border); border-right: none; border-radius: 8px 0 0 0;
978
+ box-shadow: -12px -12px 40px var(--shadow-color); display: flex; flex-direction: column; animation: res-rise .18s ease-out; }
979
+ @keyframes res-rise { from { transform: translateY(20px); opacity: 0; } to { transform: none; opacity: 1; } }
980
+ .res-panel-head { display: flex; align-items: baseline; gap: 10px; padding: 12px 14px 6px; }
981
+ .res-panel-head h2 { margin: 0; font-size: 14px; color: var(--text-bright); }
982
+ .res-close { background: none; border: none; color: var(--text-muted); cursor: pointer; font-size: 12px; }
983
+ .res-close:hover { color: var(--text-bright); }
984
+ .res-panel-body { overflow: auto; padding: 4px 14px 14px; display: flex; flex-direction: column; gap: 10px; font-size: 12px; color: var(--text); }
985
+ .res-card { background: var(--bg-surface); border-radius: 6px; padding: 8px 10px; display: flex; flex-direction: column; gap: 5px; }
986
+ .res-head { display: flex; align-items: center; gap: 8px; color: var(--text-bright); }
987
+ .res-spacer { flex: 1; }
988
+ .res-muted { color: var(--text-muted); font-size: 11.5px; }
989
+ .res-note { margin-top: 2px; }
990
+ .res-err { color: var(--danger); }
991
+ .res-tag { font-size: 10.5px; color: var(--accent); border: 1px solid var(--border); border-radius: 3px; padding: 0 4px; }
992
+ .res-dot { width: 7px; height: 7px; border-radius: 50%; }
993
+ .res-dot.ok { background: var(--success); }
994
+ .res-dot.bad { background: var(--danger); }
995
+ .res-row { display: flex; align-items: center; gap: 8px; }
996
+ .res-k { width: 40px; color: var(--text-soft); flex: none; }
997
+ .res-k.wide { width: 112px; }
998
+ .res-v { font-family: 'JetBrains Mono', 'Fira Code', monospace; font-size: 11.5px; min-width: 104px; }
999
+ .res-v.narrow { min-width: 36px; }
1000
+ .res-resets { white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
1001
+ .res-rbar { flex: 1; height: 6px; background: var(--bg-surface-hi); border-radius: 3px; overflow: hidden; min-width: 50px; }
1002
+ .res-rbar > span { display: block; height: 100%; border-radius: 3px; transition: width .6s ease; }
1003
+ .res-spark { flex: none; }
1004
+ .res-agent { padding: 2px 0; }
1005
+ .res-agent-name { color: var(--text); }
1006
+ .res-agent-name.active { color: var(--text-bright); }
1007
+
963
1008
  .tracking-dot {
964
1009
  width: 6px;
965
1010
  height: 6px;