@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.
- package/out/headless/index.mjs +549 -116
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +416 -38
- package/out/preload/index.js +3 -0
- package/out/renderer/assets/{cssMode-Xx1-SozN.js → cssMode-BtQxI2Eb.js} +3 -3
- package/out/renderer/assets/{freemarker2-DM49yPw1.js → freemarker2-DRW5dmY5.js} +1 -1
- package/out/renderer/assets/{handlebars-DJJBblpV.js → handlebars-CFLF3cP7.js} +1 -1
- package/out/renderer/assets/{html-DnMnJOxw.js → html-DtnBOkwp.js} +1 -1
- package/out/renderer/assets/{htmlMode-BG5u2CSH.js → htmlMode-DMVysDCg.js} +3 -3
- package/out/renderer/assets/{index-DID1LlMU.css → index-CEjwKjtd.css} +45 -0
- package/out/renderer/assets/{index-D65QIRev.js → index-Dm70hk5s.js} +292 -38
- package/out/renderer/assets/{javascript-Dci_phmN.js → javascript-ChlyA1sf.js} +2 -2
- package/out/renderer/assets/{jsonMode-Dmp3JJSm.js → jsonMode-D1_oHzeN.js} +3 -3
- package/out/renderer/assets/{liquid-C9KVysk1.js → liquid-D4ftdy1G.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-IDD0-KWa.js → lspLanguageFeatures-Ckqu5ubG.js} +1 -1
- package/out/renderer/assets/{mdx-ascmBFXX.js → mdx-B9wsVYSC.js} +1 -1
- package/out/renderer/assets/{python-DcbM4Hy5.js → python-e23tD6IS.js} +1 -1
- package/out/renderer/assets/{razor-XJROxpYK.js → razor-CrDyXj3d.js} +1 -1
- package/out/renderer/assets/{tsMode-EXbzul3n.js → tsMode-BwGC5o4O.js} +1 -1
- package/out/renderer/assets/{typescript-DhTtFBLt.js → typescript-Do-98d_O.js} +1 -1
- package/out/renderer/assets/{xml-BLfEkVl1.js → xml-Dpdu5R3m.js} +1 -1
- package/out/renderer/assets/{yaml-DpzsqvWa.js → yaml-B0Zt7LCJ.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/scripts/test-terminal-scroll.cjs +64 -0
- package/src/main/headless.ts +12 -0
- package/src/main/index.ts +8 -0
- package/src/main/monitor.ts +540 -0
- package/src/main/orchestrator.ts +10 -0
- package/src/main/pty.ts +5 -0
- package/src/main/tui.ts +37 -3
- package/src/preload/index.ts +4 -0
- package/src/renderer/App.tsx +9 -1
- package/src/renderer/components/Resources.tsx +217 -0
- package/src/renderer/components/StatusBar.tsx +6 -1
- package/src/renderer/components/TerminalPanel.tsx +54 -20
- package/src/renderer/styles.css +45 -0
package/src/renderer/App.tsx
CHANGED
|
@@ -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
|
|
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
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
|
|
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
|
|
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])
|
package/src/renderer/styles.css
CHANGED
|
@@ -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;
|