@phenx-inc/ctlsurf 0.16.0 → 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 (37) 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-Xx1-SozN.js → cssMode-BtQxI2Eb.js} +3 -3
  6. package/out/renderer/assets/{freemarker2-DM49yPw1.js → freemarker2-DRW5dmY5.js} +1 -1
  7. package/out/renderer/assets/{handlebars-DJJBblpV.js → handlebars-CFLF3cP7.js} +1 -1
  8. package/out/renderer/assets/{html-DnMnJOxw.js → html-DtnBOkwp.js} +1 -1
  9. package/out/renderer/assets/{htmlMode-BG5u2CSH.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-D65QIRev.js → index-Dm70hk5s.js} +292 -38
  12. package/out/renderer/assets/{javascript-Dci_phmN.js → javascript-ChlyA1sf.js} +2 -2
  13. package/out/renderer/assets/{jsonMode-Dmp3JJSm.js → jsonMode-D1_oHzeN.js} +3 -3
  14. package/out/renderer/assets/{liquid-C9KVysk1.js → liquid-D4ftdy1G.js} +1 -1
  15. package/out/renderer/assets/{lspLanguageFeatures-IDD0-KWa.js → lspLanguageFeatures-Ckqu5ubG.js} +1 -1
  16. package/out/renderer/assets/{mdx-ascmBFXX.js → mdx-B9wsVYSC.js} +1 -1
  17. package/out/renderer/assets/{python-DcbM4Hy5.js → python-e23tD6IS.js} +1 -1
  18. package/out/renderer/assets/{razor-XJROxpYK.js → razor-CrDyXj3d.js} +1 -1
  19. package/out/renderer/assets/{tsMode-EXbzul3n.js → tsMode-BwGC5o4O.js} +1 -1
  20. package/out/renderer/assets/{typescript-DhTtFBLt.js → typescript-Do-98d_O.js} +1 -1
  21. package/out/renderer/assets/{xml-BLfEkVl1.js → xml-Dpdu5R3m.js} +1 -1
  22. package/out/renderer/assets/{yaml-DpzsqvWa.js → yaml-B0Zt7LCJ.js} +1 -1
  23. package/out/renderer/index.html +2 -2
  24. package/package.json +1 -1
  25. package/scripts/test-terminal-scroll.cjs +64 -0
  26. package/src/main/headless.ts +12 -0
  27. package/src/main/index.ts +8 -0
  28. package/src/main/monitor.ts +540 -0
  29. package/src/main/orchestrator.ts +10 -0
  30. package/src/main/pty.ts +5 -0
  31. package/src/main/tui.ts +37 -3
  32. package/src/preload/index.ts +4 -0
  33. package/src/renderer/App.tsx +9 -1
  34. package/src/renderer/components/Resources.tsx +217 -0
  35. package/src/renderer/components/StatusBar.tsx +6 -1
  36. package/src/renderer/components/TerminalPanel.tsx +54 -20
  37. package/src/renderer/styles.css +45 -0
@@ -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
  <>
@@ -127,7 +127,7 @@ type TermState = {
127
127
  // (neither down to the bottom, nor up to the top — the old bug).
128
128
  anchorLine: number | null
129
129
  // One-shot flag set by a real user scroll gesture (wheel / scroll keys) and
130
- // consumed by the very next onScroll, so a redraw firing onScroll right after
130
+ // consumed by the next terminal/viewport scroll, so a redraw right after
131
131
  // a gesture isn't mistaken for one. Spinner redraws (~10x/s while "thinking")
132
132
  // would otherwise be misread as user intent and re-pin to the bottom.
133
133
  userScrollPending: boolean
@@ -190,6 +190,22 @@ function markGesture(tabId: string): void {
190
190
  if (s) s.userScrollPending = true
191
191
  }
192
192
 
193
+ function followLatest(state: TermState): void {
194
+ state.userScrollPending = false
195
+ setPinned(state, true)
196
+ reassert(state)
197
+ }
198
+
199
+ function rememberScrollPosition(state: TermState): void {
200
+ state.userScrollPending = false
201
+ if (isAtBottom(state.terminal)) {
202
+ setPinned(state, true)
203
+ } else {
204
+ state.anchorLine = state.terminal.buffer.active.viewportY
205
+ setPinned(state, false)
206
+ }
207
+ }
208
+
193
209
  function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
194
210
  const existing = _terminals.get(tabId)
195
211
  if (existing) {
@@ -216,7 +232,9 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
216
232
  fontFamily: "'JetBrains Mono', 'Fira Code', 'Cascadia Code', 'SF Mono', monospace",
217
233
  theme: currentTheme().terminal,
218
234
  scrollback: 10000,
219
- scrollOnUserInput: true,
235
+ // Follow explicit keyboard/paste input below. xterm also counts mouse
236
+ // reports as user input, which must not pull someone out of scrollback.
237
+ scrollOnUserInput: false,
220
238
  allowTransparency: false
221
239
  })
222
240
 
@@ -302,19 +320,14 @@ function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): {
302
320
  reassert(state)
303
321
  return
304
322
  }
305
- state.userScrollPending = false
306
- if (isAtBottom(terminal)) {
307
- setPinned(state, true)
308
- } else {
309
- state.anchorLine = terminal.buffer.active.viewportY
310
- setPinned(state, false)
311
- }
323
+ rememberScrollPosition(state)
312
324
  })
313
325
 
314
- // Send keystrokes to pty — typing always returns to (and follows) the bottom.
326
+ // onData includes automatic cursor/device/color replies and focus/mouse
327
+ // reports, not just keystrokes. Codex's terminal queries used to re-enable
328
+ // follow mode here, snapping the viewport down while reading old output.
329
+ terminal.onKey(() => followLatest(state))
315
330
  terminal.onData((data) => {
316
- setPinned(state, true)
317
- terminal.scrollToBottom()
318
331
  window.worker.writePty(tabId, data)
319
332
  })
320
333
 
@@ -394,8 +407,7 @@ export function TerminalPanel({ tabId, agent, sessionVersion, onSpawn, onExit, i
394
407
  const jumpToLatest = () => {
395
408
  const s = _terminals.get(tabId)
396
409
  if (!s) return
397
- setPinned(s, true)
398
- s.terminal.scrollToBottom()
410
+ followLatest(s)
399
411
  s.terminal.focus()
400
412
  }
401
413
 
@@ -496,15 +508,33 @@ export function TerminalPanel({ tabId, agent, sessionVersion, onSpawn, onExit, i
496
508
  const attached = _terminals.get(tabId)
497
509
  if (attached) reassert(attached)
498
510
 
499
- // Wheel over the terminal is an explicit user scroll — stamp it so onScroll
500
- // treats the resulting move as intentional (not an involuntary redraw jump).
501
- // Capture is important: xterm handles wheel events on a descendant and
502
- // emits onScroll synchronously. A bubbling listener runs too late, causing
503
- // streamed Codex output to mistake the wheel scroll for a redraw and snap
504
- // the viewport straight back to the bottom.
511
+ // Wheel handling starts inside xterm, so record intent in capture phase.
505
512
  const onWheel = () => markGesture(tabId)
506
513
  terminal.element?.addEventListener('wheel', onWheel, { capture: true, passive: true })
507
514
 
515
+ // xterm 5.5 suppresses its public onScroll event for native viewport
516
+ // scrolling. Observe the DOM event after xterm updates buffer.viewportY,
517
+ // otherwise a wheel gesture never pauses follow mode before the next write.
518
+ const viewport = terminal.element?.querySelector('.xterm-viewport')
519
+ const onViewportScroll = () => {
520
+ const state = _terminals.get(tabId)
521
+ if (state && !state.holding && state.userScrollPending) rememberScrollPosition(state)
522
+ }
523
+ viewport?.addEventListener('scroll', onViewportScroll)
524
+
525
+ // Paste and IME/emoji input can bypass onKey. Capture them before xterm
526
+ // handles the event (paste stops propagation inside xterm).
527
+ const onTextInput = () => {
528
+ const state = _terminals.get(tabId)
529
+ if (state) followLatest(state)
530
+ }
531
+ const onPaste = (event: ClipboardEvent) => {
532
+ if (event.clipboardData?.getData('text/plain')) onTextInput()
533
+ }
534
+ terminal.textarea?.addEventListener('paste', onPaste, true)
535
+ terminal.textarea?.addEventListener('input', onTextInput, true)
536
+ terminal.textarea?.addEventListener('compositionend', onTextInput, true)
537
+
508
538
  // Resize handling — skip when the container is hidden (display:none → 0x0),
509
539
  // otherwise fit() reports a tiny size and the PTY gets stuck at ~4 cols
510
540
  // until the user switches back.
@@ -533,6 +563,10 @@ export function TerminalPanel({ tabId, agent, sessionVersion, onSpawn, onExit, i
533
563
  window.removeEventListener('resize', handleResize)
534
564
  observer.disconnect()
535
565
  terminal.element?.removeEventListener('wheel', onWheel, true)
566
+ viewport?.removeEventListener('scroll', onViewportScroll)
567
+ terminal.textarea?.removeEventListener('paste', onPaste, true)
568
+ terminal.textarea?.removeEventListener('input', onTextInput, true)
569
+ terminal.textarea?.removeEventListener('compositionend', onTextInput, true)
536
570
  // Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
537
571
  }
538
572
  }, [tabId, onExit])
@@ -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;