@phenx-inc/ctlsurf 0.1.21 → 0.3.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 +409 -99
- package/out/headless/index.mjs.map +4 -4
- package/out/main/index.js +419 -77
- package/out/preload/index.js +12 -8
- package/out/renderer/assets/{cssMode-C6bY9C4O.js → cssMode-DiOmyihM.js} +3 -3
- package/out/renderer/assets/{freemarker2-CkAJiX1K.js → freemarker2-BAfv60yb.js} +1 -1
- package/out/renderer/assets/{handlebars-DnLXVUXp.js → handlebars-Ult17NzQ.js} +1 -1
- package/out/renderer/assets/{html-Ds5-qvDh.js → html-DCxh4J-1.js} +1 -1
- package/out/renderer/assets/{htmlMode-DYFYy4MK.js → htmlMode-CQ5Xenrg.js} +3 -3
- package/out/renderer/assets/{index-DwSsD_Xm.js → index-BnCJ1IaZ.js} +308 -101
- package/out/renderer/assets/{index-DK9wLFFm.css → index-CrTu3Z4M.css} +132 -0
- package/out/renderer/assets/{javascript-CiHhG2a9.js → javascript-U5dsRcHx.js} +2 -2
- package/out/renderer/assets/{jsonMode-DdDRlbXP.js → jsonMode-DshPNyVy.js} +3 -3
- package/out/renderer/assets/{liquid-BP5mb-uD.js → liquid-jHHLYTlB.js} +1 -1
- package/out/renderer/assets/{lspLanguageFeatures-Dljhj5Gh.js → lspLanguageFeatures-CUafmPGy.js} +1 -1
- package/out/renderer/assets/{mdx-D4u3N7dt.js → mdx-Ct-tiY6g.js} +1 -1
- package/out/renderer/assets/{python-BQDHXVwp.js → python-wD3UwKPV.js} +1 -1
- package/out/renderer/assets/{razor-BfXW9cDc.js → razor-11ECS4oH.js} +1 -1
- package/out/renderer/assets/{tsMode-BGTjG8Ow.js → tsMode-D-7JexQ_.js} +1 -1
- package/out/renderer/assets/{typescript-422MU_YO.js → typescript-Cvna1mak.js} +1 -1
- package/out/renderer/assets/{xml-B6EKhHiy.js → xml-JsEaImjA.js} +1 -1
- package/out/renderer/assets/{yaml-LkO_eGYb.js → yaml-B8pCNDb_.js} +1 -1
- package/out/renderer/index.html +2 -2
- package/package.json +1 -1
- package/src/main/ctlsurfApi.ts +26 -0
- package/src/main/headless.ts +40 -34
- package/src/main/index.ts +95 -13
- package/src/main/orchestrator.ts +160 -55
- package/src/main/timeTracker.ts +223 -0
- package/src/main/tui.ts +25 -5
- package/src/preload/index.ts +23 -15
- package/src/renderer/App.tsx +197 -43
- package/src/renderer/components/SettingsDialog.tsx +38 -1
- package/src/renderer/components/TerminalPanel.tsx +109 -59
- package/src/renderer/styles.css +132 -0
|
@@ -13,37 +13,42 @@ interface AgentConfig {
|
|
|
13
13
|
}
|
|
14
14
|
|
|
15
15
|
interface TerminalPanelProps {
|
|
16
|
+
tabId: string
|
|
16
17
|
agent: AgentConfig | null
|
|
17
|
-
onSpawn: (agent: AgentConfig) => Promise<void>
|
|
18
|
-
onExit: () => void
|
|
18
|
+
onSpawn: (tabId: string, agent: AgentConfig) => Promise<void>
|
|
19
|
+
onExit: (tabId: string) => void
|
|
20
|
+
isActive: boolean
|
|
19
21
|
}
|
|
20
22
|
|
|
21
|
-
// ───
|
|
22
|
-
//
|
|
23
|
+
// ─── Per-tab terminal instances ──────────────────
|
|
24
|
+
// Keyed by tabId so each tab has its own xterm + pty listeners.
|
|
25
|
+
// Survives React re-renders but cleaned up when tab is closed.
|
|
23
26
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
27
|
+
const _terminals = new Map<string, {
|
|
28
|
+
terminal: Terminal
|
|
29
|
+
fitAddon: FitAddon
|
|
30
|
+
unsubData: (() => void) | null
|
|
31
|
+
unsubExit: (() => void) | null
|
|
32
|
+
spawnedAgentId: string | null
|
|
33
|
+
pinnedToBottom: boolean
|
|
34
|
+
}>()
|
|
32
35
|
|
|
33
36
|
function isAtBottom(terminal: Terminal): boolean {
|
|
34
37
|
const buf = terminal.buffer.active
|
|
35
38
|
return buf.viewportY >= buf.baseY
|
|
36
39
|
}
|
|
37
40
|
|
|
38
|
-
function scrollIfPinned(terminal: Terminal) {
|
|
39
|
-
|
|
41
|
+
function scrollIfPinned(tabId: string, terminal: Terminal) {
|
|
42
|
+
const state = _terminals.get(tabId)
|
|
43
|
+
if (state?.pinnedToBottom) {
|
|
40
44
|
terminal.scrollToBottom()
|
|
41
45
|
}
|
|
42
46
|
}
|
|
43
47
|
|
|
44
|
-
function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon: FitAddon } {
|
|
45
|
-
|
|
46
|
-
|
|
48
|
+
function getOrCreateTerminal(tabId: string, onExit: (tabId: string) => void): { terminal: Terminal; fitAddon: FitAddon } {
|
|
49
|
+
const existing = _terminals.get(tabId)
|
|
50
|
+
if (existing) {
|
|
51
|
+
return { terminal: existing.terminal, fitAddon: existing.fitAddon }
|
|
47
52
|
}
|
|
48
53
|
|
|
49
54
|
const terminal = new Terminal({
|
|
@@ -81,45 +86,66 @@ function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon
|
|
|
81
86
|
terminal.loadAddon(fitAddon)
|
|
82
87
|
terminal.loadAddon(new WebLinksAddon())
|
|
83
88
|
|
|
84
|
-
|
|
89
|
+
const state = {
|
|
90
|
+
terminal,
|
|
91
|
+
fitAddon,
|
|
92
|
+
unsubData: null as (() => void) | null,
|
|
93
|
+
unsubExit: null as (() => void) | null,
|
|
94
|
+
spawnedAgentId: null as string | null,
|
|
95
|
+
pinnedToBottom: true,
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Track scroll position
|
|
85
99
|
terminal.onScroll(() => {
|
|
86
|
-
|
|
100
|
+
state.pinnedToBottom = isAtBottom(terminal)
|
|
87
101
|
})
|
|
88
102
|
|
|
89
|
-
// Send keystrokes to pty
|
|
103
|
+
// Send keystrokes to pty
|
|
90
104
|
terminal.onData((data) => {
|
|
91
|
-
|
|
105
|
+
state.pinnedToBottom = true
|
|
92
106
|
terminal.scrollToBottom()
|
|
93
|
-
window.worker.writePty(data)
|
|
107
|
+
window.worker.writePty(tabId, data)
|
|
94
108
|
})
|
|
95
109
|
|
|
96
|
-
// Receive pty output
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
110
|
+
// Receive pty output for this tab
|
|
111
|
+
state.unsubData = window.worker.onPtyData((eventTabId, data) => {
|
|
112
|
+
if (eventTabId === tabId) {
|
|
113
|
+
terminal.write(data)
|
|
114
|
+
scrollIfPinned(tabId, terminal)
|
|
115
|
+
}
|
|
100
116
|
})
|
|
101
117
|
|
|
102
|
-
// Handle pty exit
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
118
|
+
// Handle pty exit for this tab
|
|
119
|
+
state.unsubExit = window.worker.onPtyExit((eventTabId, code) => {
|
|
120
|
+
if (eventTabId === tabId) {
|
|
121
|
+
terminal.writeln(`\r\n\x1b[33m[Process exited with code ${code}]\x1b[0m`)
|
|
122
|
+
onExit(tabId)
|
|
123
|
+
}
|
|
106
124
|
})
|
|
107
125
|
|
|
108
|
-
|
|
109
|
-
_fitAddon = fitAddon
|
|
126
|
+
_terminals.set(tabId, state)
|
|
110
127
|
return { terminal, fitAddon }
|
|
111
128
|
}
|
|
112
129
|
|
|
130
|
+
export function destroyTerminal(tabId: string): void {
|
|
131
|
+
const state = _terminals.get(tabId)
|
|
132
|
+
if (!state) return
|
|
133
|
+
state.unsubData?.()
|
|
134
|
+
state.unsubExit?.()
|
|
135
|
+
state.terminal.dispose()
|
|
136
|
+
_terminals.delete(tabId)
|
|
137
|
+
}
|
|
138
|
+
|
|
113
139
|
// ─── Component ───────────────────────────────────────
|
|
114
140
|
|
|
115
|
-
export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
|
|
141
|
+
export function TerminalPanel({ tabId, agent, onSpawn, onExit, isActive }: TerminalPanelProps) {
|
|
116
142
|
const containerRef = useRef<HTMLDivElement>(null)
|
|
117
143
|
|
|
118
|
-
// Attach terminal to DOM container
|
|
144
|
+
// Attach terminal to DOM container
|
|
119
145
|
useEffect(() => {
|
|
120
146
|
if (!containerRef.current) return
|
|
121
147
|
|
|
122
|
-
const { terminal, fitAddon } = getOrCreateTerminal(onExit)
|
|
148
|
+
const { terminal, fitAddon } = getOrCreateTerminal(tabId, onExit)
|
|
123
149
|
|
|
124
150
|
// If terminal is already open in another container, move it
|
|
125
151
|
const existingParent = terminal.element?.parentElement
|
|
@@ -130,18 +156,19 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
|
|
|
130
156
|
}
|
|
131
157
|
|
|
132
158
|
fitAddon.fit()
|
|
133
|
-
scrollIfPinned(terminal)
|
|
159
|
+
scrollIfPinned(tabId, terminal)
|
|
134
160
|
|
|
135
161
|
// Resize handling
|
|
136
162
|
let resizeTimeout: ReturnType<typeof setTimeout>
|
|
137
163
|
const handleResize = () => {
|
|
138
164
|
clearTimeout(resizeTimeout)
|
|
139
165
|
resizeTimeout = setTimeout(() => {
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
166
|
+
const state = _terminals.get(tabId)
|
|
167
|
+
if (state) {
|
|
168
|
+
state.fitAddon.fit()
|
|
169
|
+
scrollIfPinned(tabId, state.terminal)
|
|
170
|
+
const { cols, rows } = state.terminal
|
|
171
|
+
window.worker.resizePty(tabId, cols, rows)
|
|
145
172
|
}
|
|
146
173
|
}, 150)
|
|
147
174
|
}
|
|
@@ -154,29 +181,52 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
|
|
|
154
181
|
clearTimeout(resizeTimeout)
|
|
155
182
|
window.removeEventListener('resize', handleResize)
|
|
156
183
|
observer.disconnect()
|
|
157
|
-
// Do NOT dispose terminal or unsub PTY listeners
|
|
184
|
+
// Do NOT dispose terminal or unsub PTY listeners — they persist across remounts
|
|
158
185
|
}
|
|
159
|
-
}, [onExit])
|
|
186
|
+
}, [tabId, onExit])
|
|
160
187
|
|
|
161
|
-
// Spawn agent when it changes
|
|
188
|
+
// Spawn agent when it changes for this tab
|
|
162
189
|
useEffect(() => {
|
|
163
|
-
if (!agent
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
190
|
+
if (!agent) return
|
|
191
|
+
const state = _terminals.get(tabId)
|
|
192
|
+
if (!state) return
|
|
193
|
+
if (state.spawnedAgentId === agent.id) return
|
|
194
|
+
|
|
195
|
+
state.spawnedAgentId = agent.id
|
|
196
|
+
state.terminal.clear()
|
|
197
|
+
|
|
198
|
+
onSpawn(tabId, agent).then(() => {
|
|
199
|
+
const s = _terminals.get(tabId)
|
|
200
|
+
if (s) {
|
|
201
|
+
s.fitAddon.fit()
|
|
202
|
+
s.pinnedToBottom = true
|
|
203
|
+
s.terminal.scrollToBottom()
|
|
204
|
+
const { cols, rows } = s.terminal
|
|
205
|
+
window.worker.resizePty(tabId, cols, rows)
|
|
176
206
|
}
|
|
177
|
-
|
|
207
|
+
state.terminal.focus()
|
|
178
208
|
})
|
|
179
|
-
}, [agent, onSpawn])
|
|
209
|
+
}, [tabId, agent, onSpawn])
|
|
210
|
+
|
|
211
|
+
// Focus terminal when tab becomes active — delay fit to let the container get its layout dimensions
|
|
212
|
+
useEffect(() => {
|
|
213
|
+
if (isActive) {
|
|
214
|
+
window.worker.setActiveTab(tabId)
|
|
215
|
+
// Wait for the browser to layout the now-visible container before fitting
|
|
216
|
+
requestAnimationFrame(() => {
|
|
217
|
+
setTimeout(() => {
|
|
218
|
+
const state = _terminals.get(tabId)
|
|
219
|
+
if (state) {
|
|
220
|
+
state.fitAddon.fit()
|
|
221
|
+
state.terminal.focus()
|
|
222
|
+
scrollIfPinned(tabId, state.terminal)
|
|
223
|
+
const { cols, rows } = state.terminal
|
|
224
|
+
window.worker.resizePty(tabId, cols, rows)
|
|
225
|
+
}
|
|
226
|
+
}, 50)
|
|
227
|
+
})
|
|
228
|
+
}
|
|
229
|
+
}, [isActive, tabId])
|
|
180
230
|
|
|
181
231
|
return (
|
|
182
232
|
<div className="terminal-container" ref={containerRef} />
|
package/src/renderer/styles.css
CHANGED
|
@@ -118,6 +118,117 @@ html, body, #root {
|
|
|
118
118
|
overflow: hidden;
|
|
119
119
|
}
|
|
120
120
|
|
|
121
|
+
/* Terminal tabs wrapper */
|
|
122
|
+
.terminal-tabs-wrapper {
|
|
123
|
+
display: flex;
|
|
124
|
+
flex-direction: column;
|
|
125
|
+
width: 100%;
|
|
126
|
+
height: 100%;
|
|
127
|
+
overflow: hidden;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.terminal-tab-bar {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 1px;
|
|
134
|
+
padding: 2px 4px;
|
|
135
|
+
background: #16161e;
|
|
136
|
+
border-bottom: 1px solid #292e42;
|
|
137
|
+
flex-shrink: 0;
|
|
138
|
+
overflow-x: auto;
|
|
139
|
+
min-height: 28px;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
.terminal-tab {
|
|
143
|
+
display: flex;
|
|
144
|
+
align-items: center;
|
|
145
|
+
gap: 4px;
|
|
146
|
+
padding: 3px 8px;
|
|
147
|
+
font-size: 11px;
|
|
148
|
+
color: #565f89;
|
|
149
|
+
background: transparent;
|
|
150
|
+
border-radius: 4px;
|
|
151
|
+
cursor: pointer;
|
|
152
|
+
white-space: nowrap;
|
|
153
|
+
user-select: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.terminal-tab:hover {
|
|
157
|
+
background: #1f2335;
|
|
158
|
+
color: #a9b1d6;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
.terminal-tab.active {
|
|
162
|
+
background: #1a1b26;
|
|
163
|
+
color: #c0caf5;
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
.terminal-tab-label {
|
|
167
|
+
display: flex;
|
|
168
|
+
align-items: center;
|
|
169
|
+
gap: 4px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.terminal-tab-dot {
|
|
173
|
+
display: inline-block;
|
|
174
|
+
width: 6px;
|
|
175
|
+
height: 6px;
|
|
176
|
+
border-radius: 50%;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
.terminal-tab-dot.active {
|
|
180
|
+
background: #9ece6a;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
.terminal-tab-dot.exited {
|
|
184
|
+
background: #565f89;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
.terminal-tab-close {
|
|
188
|
+
background: none;
|
|
189
|
+
border: none;
|
|
190
|
+
color: inherit;
|
|
191
|
+
font-size: 14px;
|
|
192
|
+
line-height: 1;
|
|
193
|
+
cursor: pointer;
|
|
194
|
+
padding: 0 2px;
|
|
195
|
+
border-radius: 3px;
|
|
196
|
+
opacity: 0.5;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
.terminal-tab-close:hover {
|
|
200
|
+
opacity: 1;
|
|
201
|
+
background: rgba(247, 118, 142, 0.2);
|
|
202
|
+
color: #f7768e;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.terminal-tab-add {
|
|
206
|
+
background: none;
|
|
207
|
+
border: none;
|
|
208
|
+
color: #565f89;
|
|
209
|
+
font-size: 16px;
|
|
210
|
+
cursor: pointer;
|
|
211
|
+
padding: 2px 6px;
|
|
212
|
+
border-radius: 4px;
|
|
213
|
+
line-height: 1;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
.terminal-tab-add:hover {
|
|
217
|
+
background: #1f2335;
|
|
218
|
+
color: #a9b1d6;
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
.terminal-tabs-content {
|
|
222
|
+
flex: 1;
|
|
223
|
+
display: flex;
|
|
224
|
+
overflow: hidden;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
.terminal-tab-panel {
|
|
228
|
+
flex-direction: column;
|
|
229
|
+
overflow: hidden;
|
|
230
|
+
}
|
|
231
|
+
|
|
121
232
|
/* Terminal container */
|
|
122
233
|
.terminal-container {
|
|
123
234
|
width: 100%;
|
|
@@ -407,6 +518,27 @@ html, body, #root {
|
|
|
407
518
|
.status-dot.idle { background: #565f89; }
|
|
408
519
|
.status-dot.pending { background: #e0af68; }
|
|
409
520
|
|
|
521
|
+
.tracking-dot {
|
|
522
|
+
width: 6px;
|
|
523
|
+
height: 6px;
|
|
524
|
+
border-radius: 50%;
|
|
525
|
+
display: inline-block;
|
|
526
|
+
vertical-align: middle;
|
|
527
|
+
}
|
|
528
|
+
.tracking-dot.on { background: #9ece6a; box-shadow: 0 0 4px #9ece6a; }
|
|
529
|
+
.tracking-dot.off { background: #565f89; }
|
|
530
|
+
|
|
531
|
+
.titlebar-icon-btn {
|
|
532
|
+
display: inline-flex;
|
|
533
|
+
align-items: center;
|
|
534
|
+
gap: 5px;
|
|
535
|
+
padding: 0 8px;
|
|
536
|
+
}
|
|
537
|
+
.tracking-icon {
|
|
538
|
+
font-size: 14px;
|
|
539
|
+
line-height: 1;
|
|
540
|
+
}
|
|
541
|
+
|
|
410
542
|
/* Editor panel */
|
|
411
543
|
.editor-panel {
|
|
412
544
|
display: flex;
|