@phenx-inc/ctlsurf 0.16.1 → 0.17.1

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/bin/ctlsurf-worker.js +39 -6
  2. package/out/headless/index.mjs +549 -116
  3. package/out/headless/index.mjs.map +4 -4
  4. package/out/main/index.js +416 -38
  5. package/out/preload/index.js +3 -0
  6. package/out/renderer/assets/{cssMode-DwHD6JSn.js → cssMode-BtQxI2Eb.js} +3 -3
  7. package/out/renderer/assets/{freemarker2-Na9-bXYw.js → freemarker2-DRW5dmY5.js} +1 -1
  8. package/out/renderer/assets/{handlebars-D7gzKLS8.js → handlebars-CFLF3cP7.js} +1 -1
  9. package/out/renderer/assets/{html-DDK7xN27.js → html-DtnBOkwp.js} +1 -1
  10. package/out/renderer/assets/{htmlMode-BfbLv-NB.js → htmlMode-DMVysDCg.js} +3 -3
  11. package/out/renderer/assets/{index-DID1LlMU.css → index-CEjwKjtd.css} +45 -0
  12. package/out/renderer/assets/{index-HoDaBul3.js → index-Dm70hk5s.js} +252 -26
  13. package/out/renderer/assets/{javascript-DJjWOxID.js → javascript-ChlyA1sf.js} +2 -2
  14. package/out/renderer/assets/{jsonMode-BileWbig.js → jsonMode-D1_oHzeN.js} +3 -3
  15. package/out/renderer/assets/{liquid-DvNU_dI9.js → liquid-D4ftdy1G.js} +1 -1
  16. package/out/renderer/assets/{lspLanguageFeatures-Bb27EY8c.js → lspLanguageFeatures-Ckqu5ubG.js} +1 -1
  17. package/out/renderer/assets/{mdx-CLkNPVgD.js → mdx-B9wsVYSC.js} +1 -1
  18. package/out/renderer/assets/{python-DOJdL4kC.js → python-e23tD6IS.js} +1 -1
  19. package/out/renderer/assets/{razor-CSr8UlJc.js → razor-CrDyXj3d.js} +1 -1
  20. package/out/renderer/assets/{tsMode-sbsAvk21.js → tsMode-BwGC5o4O.js} +1 -1
  21. package/out/renderer/assets/{typescript-r5QB4yRW.js → typescript-Do-98d_O.js} +1 -1
  22. package/out/renderer/assets/{xml-CYhKh3Pi.js → xml-Dpdu5R3m.js} +1 -1
  23. package/out/renderer/assets/{yaml-B8DsoaYW.js → yaml-B0Zt7LCJ.js} +1 -1
  24. package/out/renderer/index.html +2 -2
  25. package/package.json +1 -1
  26. package/scripts/extract-electron.js +105 -0
  27. package/src/main/headless.ts +12 -0
  28. package/src/main/index.ts +8 -0
  29. package/src/main/monitor.ts +540 -0
  30. package/src/main/orchestrator.ts +10 -0
  31. package/src/main/pty.ts +5 -0
  32. package/src/main/tui.ts +37 -3
  33. package/src/preload/index.ts +4 -0
  34. package/src/renderer/App.tsx +9 -1
  35. package/src/renderer/components/Resources.tsx +217 -0
  36. package/src/renderer/components/StatusBar.tsx +6 -1
  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
  <>
@@ -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;