@phenx-inc/ctlsurf 0.1.18 → 0.1.21

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 (24) hide show
  1. package/out/headless/index.mjs +21 -62
  2. package/out/headless/index.mjs.map +2 -2
  3. package/out/renderer/assets/{cssMode-Cxe23-tB.js → cssMode-C6bY9C4O.js} +3 -3
  4. package/out/renderer/assets/{freemarker2-Be0nj7Oa.js → freemarker2-CkAJiX1K.js} +1 -1
  5. package/out/renderer/assets/{handlebars-C0It7_Nu.js → handlebars-DnLXVUXp.js} +1 -1
  6. package/out/renderer/assets/{html-BW6LB-7J.js → html-Ds5-qvDh.js} +1 -1
  7. package/out/renderer/assets/{htmlMode-D_V-1VlE.js → htmlMode-DYFYy4MK.js} +3 -3
  8. package/out/renderer/assets/{index-D568SpEt.js → index-DwSsD_Xm.js} +41 -24
  9. package/out/renderer/assets/{javascript-D_LoeNc7.js → javascript-CiHhG2a9.js} +2 -2
  10. package/out/renderer/assets/{jsonMode-K3WSinSE.js → jsonMode-DdDRlbXP.js} +3 -3
  11. package/out/renderer/assets/{liquid-BqfOd6m8.js → liquid-BP5mb-uD.js} +1 -1
  12. package/out/renderer/assets/{lspLanguageFeatures-Bjf28WU6.js → lspLanguageFeatures-Dljhj5Gh.js} +1 -1
  13. package/out/renderer/assets/{mdx-BoESjI38.js → mdx-D4u3N7dt.js} +1 -1
  14. package/out/renderer/assets/{python-DlafOOgB.js → python-BQDHXVwp.js} +1 -1
  15. package/out/renderer/assets/{razor-CB6E9DBD.js → razor-BfXW9cDc.js} +1 -1
  16. package/out/renderer/assets/{tsMode-DYu1z_nn.js → tsMode-BGTjG8Ow.js} +1 -1
  17. package/out/renderer/assets/{typescript-CDjkh0d5.js → typescript-422MU_YO.js} +1 -1
  18. package/out/renderer/assets/{xml-C-XlilKZ.js → xml-B6EKhHiy.js} +1 -1
  19. package/out/renderer/assets/{yaml-BTrtxLEo.js → yaml-LkO_eGYb.js} +1 -1
  20. package/out/renderer/index.html +1 -1
  21. package/package.json +1 -1
  22. package/src/main/headless.ts +12 -0
  23. package/src/main/tui.ts +31 -107
  24. package/src/renderer/components/TerminalPanel.tsx +27 -4
package/src/main/tui.ts CHANGED
@@ -1,8 +1,10 @@
1
1
  /**
2
2
  * Terminal UI (TUI) renderer
3
3
  *
4
- * Draws a title bar and status bar around the PTY output area.
5
- * Uses ANSI escape codes and scroll regions no external dependencies.
4
+ * Minimal status bar at the bottom, full PTY passthrough above.
5
+ * No alternate screen the PTY (Claude Code) owns the screen and
6
+ * handles its own scrolling, mouse events, and display management.
7
+ * Status info also goes to the terminal tab title via OSC.
6
8
  */
7
9
 
8
10
  const ESC = '\x1b'
@@ -10,13 +12,13 @@ const CSI = `${ESC}[`
10
12
 
11
13
  // Colors (Tokyo Night palette)
12
14
  const BG_BAR = `${CSI}48;2;22;22;30m` // #16161e
13
- const FG_TITLE = `${CSI}38;2;192;202;245m` // #c0caf5
14
15
  const FG_DIM = `${CSI}38;2;86;95;137m` // #565f89
15
16
  const FG_ACCENT = `${CSI}38;2;122;162;247m` // #7aa2f7
16
17
  const FG_GREEN = `${CSI}38;2;158;206;106m` // #9ece6a
17
18
  const FG_RED = `${CSI}38;2;247;118;142m` // #f7768e
18
19
  const FG_YELLOW = `${CSI}38;2;224;175;104m` // #e0af68
19
20
  const FG_WHITE = `${CSI}38;2;169;177;214m` // #a9b1d6
21
+ const FG_TITLE = `${CSI}38;2;192;202;245m` // #c0caf5
20
22
  const BG_MODAL = `${CSI}48;2;31;35;53m` // #1f2335
21
23
  const BG_SELECTED = `${CSI}48;2;42;43;61m` // #2a2b3d
22
24
  const RESET = `${CSI}0m`
@@ -46,28 +48,18 @@ export class Tui {
46
48
  }
47
49
 
48
50
  /**
49
- * Initialize the TUI: alternate screen, hide cursor reporting, set scroll region
51
+ * Initialize the TUI: scroll region leaving last row for status bar.
52
+ * No alternate screen — PTY fully owns the display.
50
53
  */
51
54
  init(): void {
52
- // Alternate screen buffer
53
- this.write(`${CSI}?1049h`)
54
- // Set scroll region: lines 2 to (rows - 1), leaving line 1 for title, line rows for status
55
- this.setScrollRegion()
56
- // Move cursor to the PTY area
57
- this.moveToPtyArea()
58
- // Draw chrome
59
- this.drawTitleBar()
60
- this.drawStatusBar()
55
+ // Just set tab title — the PTY agent owns the full screen
56
+ this.updateTerminalTitle()
61
57
  }
62
58
 
63
59
  /**
64
60
  * Restore terminal to normal state
65
61
  */
66
62
  destroy(): void {
67
- // Reset scroll region
68
- this.write(`${CSI}r`)
69
- // Leave alternate screen
70
- this.write(`${CSI}?1049l`)
71
63
  // Show cursor
72
64
  this.write(`${CSI}?25h`)
73
65
  }
@@ -78,46 +70,35 @@ export class Tui {
78
70
  resize(cols: number, rows: number): void {
79
71
  this.cols = cols
80
72
  this.rows = rows
81
- this.setScrollRegion()
82
- this.drawTitleBar()
83
- this.drawStatusBar()
84
- this.moveToPtyArea()
85
73
  }
86
74
 
87
75
  /**
88
- * Get the PTY dimensions (main area minus title + status bars)
76
+ * Get the PTY dimensions (all rows minus status bar)
89
77
  */
90
78
  getPtySize(): { cols: number; rows: number } {
91
79
  return {
92
80
  cols: this.cols,
93
- rows: Math.max(1, this.rows - 2),
81
+ rows: this.rows,
94
82
  }
95
83
  }
96
84
 
97
85
  /**
98
- * Update state and redraw bars
86
+ * Update state and redraw status bar
99
87
  */
100
88
  update(partial: Partial<TuiState>): void {
101
89
  Object.assign(this.state, partial)
102
- // Save cursor, draw bars, restore cursor
103
- this.write(`${CSI}s`) // save cursor
104
- this.drawTitleBar()
105
- this.drawStatusBar()
106
- this.write(`${CSI}u`) // restore cursor
90
+ this.updateTerminalTitle()
107
91
  }
108
92
 
109
93
  /**
110
- * Write PTY output to the scroll region.
111
- * Cursor is assumed to be in the PTY area already.
94
+ * Write PTY output full passthrough.
112
95
  */
113
96
  writePtyData(data: string): void {
114
- // PTY data goes straight to stdout — it's already in the scroll region
115
97
  this.write(data)
116
98
  }
117
99
 
118
100
  /**
119
101
  * Update the terminal window/tab title via OSC escape sequence.
120
- * Works in passthrough mode (no chrome) — doesn't conflict with the agent's TUI.
121
102
  */
122
103
  setTerminalTitle(title: string): void {
123
104
  this.write(`${ESC}]0;${title}\x07`)
@@ -149,49 +130,41 @@ export class Tui {
149
130
 
150
131
  /**
151
132
  * Show an interactive agent picker modal.
152
- * Returns a promise that resolves with the selected agent index.
133
+ * Uses alternate screen just for the picker, then exits back to normal.
153
134
  */
154
135
  showAgentPicker(agents: { name: string; description: string }[]): Promise<number> {
155
136
  return new Promise((resolve) => {
156
137
  let selected = 0
157
138
  const modalWidth = 44
158
- const modalHeight = agents.length + 4 // border + title + items + border
139
+ const modalHeight = agents.length + 4
159
140
  const startCol = Math.max(1, Math.floor((this.cols - modalWidth) / 2))
160
141
  const startRow = Math.max(1, Math.floor((this.rows - modalHeight) / 2))
161
142
 
162
- // Enter alternate screen if not already
143
+ // Enter alternate screen just for the picker
163
144
  this.write(`${CSI}?1049h`)
164
- // Hide cursor
165
145
  this.write(`${CSI}?25l`)
166
146
 
167
147
  const drawModal = () => {
168
148
  const topBorder = '\u250c' + '\u2500'.repeat(modalWidth - 2) + '\u2510'
169
149
  const botBorder = '\u2514' + '\u2500'.repeat(modalWidth - 2) + '\u2518'
170
- const emptyLine = '\u2502' + ' '.repeat(modalWidth - 2) + '\u2502'
171
150
 
172
- // Draw background fill
173
151
  for (let r = 0; r < this.rows; r++) {
174
152
  this.write(`${CSI}${r + 1};1H${BG_BAR}${CSI}2K${RESET}`)
175
153
  }
176
154
 
177
- // Draw logo/branding centered
178
155
  const brand = 'ctlsurf'
179
156
  const brandCol = Math.max(1, Math.floor((this.cols - brand.length) / 2))
180
157
  this.write(`${CSI}${startRow - 2};${brandCol}H${FG_ACCENT}${brand}${RESET}`)
181
158
 
182
- // Top border
183
159
  this.write(`${CSI}${startRow};${startCol}H${BG_MODAL}${FG_DIM}${topBorder}${RESET}`)
184
160
 
185
- // Title
186
161
  const title = ' Select Agent'
187
162
  const titlePad = ' '.repeat(Math.max(0, modalWidth - 2 - title.length))
188
163
  this.write(`${CSI}${startRow + 1};${startCol}H${BG_MODAL}${FG_DIM}\u2502${RESET}${BG_MODAL}${FG_TITLE}${title}${titlePad}${FG_DIM}\u2502${RESET}`)
189
164
 
190
- // Separator
191
165
  const sep = '\u251c' + '\u2500'.repeat(modalWidth - 2) + '\u2524'
192
166
  this.write(`${CSI}${startRow + 2};${startCol}H${BG_MODAL}${FG_DIM}${sep}${RESET}`)
193
167
 
194
- // Agent items
195
168
  for (let i = 0; i < agents.length; i++) {
196
169
  const agent = agents[i]
197
170
  const row = startRow + 3 + i
@@ -199,28 +172,24 @@ export class Tui {
199
172
  const bg = isSelected ? BG_SELECTED : BG_MODAL
200
173
  const pointer = isSelected ? `${FG_ACCENT}\u25B8 ` : ' '
201
174
  const nameFg = isSelected ? FG_ACCENT : FG_WHITE
202
- const descFg = FG_DIM
203
175
  const nameStr = agent.name
204
- const descStr = agent.description ? ` ${FG_DIM} ${agent.description.slice(0, 20)}` : ''
176
+ const descStr = agent.description ? ` ${FG_DIM}\u2014 ${agent.description.slice(0, 20)}` : ''
205
177
  const content = `${pointer}${nameFg}${nameStr}${descStr}`
206
- const contentLen = (isSelected ? 2 : 2) + nameStr.length + (agent.description ? 3 + Math.min(20, agent.description.length) : 0)
178
+ const contentLen = 2 + nameStr.length + (agent.description ? 3 + Math.min(20, agent.description.length) : 0)
207
179
  const pad = ' '.repeat(Math.max(0, modalWidth - 2 - contentLen))
208
180
  this.write(`${CSI}${row};${startCol}H${bg}${FG_DIM}\u2502${RESET}${bg}${content}${pad}${RESET}${BG_MODAL}${FG_DIM}\u2502${RESET}`)
209
181
  }
210
182
 
211
- // Bottom border
212
183
  const botRow = startRow + 3 + agents.length
213
184
  this.write(`${CSI}${botRow};${startCol}H${BG_MODAL}${FG_DIM}${botBorder}${RESET}`)
214
185
 
215
- // Hint
216
- const hint = '\u2191\u2193 navigate · Enter select · q quit'
186
+ const hint = '\u2191\u2193 navigate \u00B7 Enter select \u00B7 q quit'
217
187
  const hintCol = Math.max(1, Math.floor((this.cols - hint.length) / 2))
218
188
  this.write(`${CSI}${botRow + 2};${hintCol}H${FG_DIM}${hint}${RESET}`)
219
189
  }
220
190
 
221
191
  drawModal()
222
192
 
223
- // Set raw mode to capture keypresses
224
193
  if (process.stdin.isTTY) {
225
194
  process.stdin.setRawMode(true)
226
195
  }
@@ -230,31 +199,27 @@ export class Tui {
230
199
  const key = data.toString()
231
200
 
232
201
  if (key === '\x1b[A' || key === 'k') {
233
- // Up
234
202
  selected = (selected - 1 + agents.length) % agents.length
235
203
  drawModal()
236
204
  } else if (key === '\x1b[B' || key === 'j') {
237
- // Down
238
205
  selected = (selected + 1) % agents.length
239
206
  drawModal()
240
207
  } else if (key === '\r' || key === '\n') {
241
- // Enter — select
242
208
  cleanup()
243
209
  resolve(selected)
244
210
  } else if (key === 'q' || key === '\x1b' || key === '\x03') {
245
- // q, Escape, Ctrl+C — quit
246
211
  cleanup()
247
- this.write(`${CSI}?25h`) // show cursor
248
- this.write(`${CSI}?1049l`) // leave alt screen
212
+ this.write(`${CSI}?25h`)
213
+ this.write(`${CSI}?1049l`)
249
214
  process.exit(0)
250
215
  }
251
216
  }
252
217
 
253
218
  const cleanup = () => {
254
219
  process.stdin.removeListener('data', onKey)
255
- // Show cursor again
256
220
  this.write(`${CSI}?25h`)
257
- // Clear the modal (will be redrawn by init())
221
+ // Leave alternate screen back to normal mode for the agent
222
+ this.write(`${CSI}?1049l`)
258
223
  }
259
224
 
260
225
  process.stdin.on('data', onKey)
@@ -270,58 +235,19 @@ export class Tui {
270
235
  }
271
236
 
272
237
  private setScrollRegion(): void {
273
- // Scroll region from line 2 to line (rows - 1)
274
- this.write(`${CSI}2;${this.rows - 1}r`)
238
+ // PTY gets rows 1 through (rows - 1), status bar on last row
239
+ this.write(`${CSI}1;${this.rows - 1}r`)
275
240
  }
276
241
 
277
- private moveToPtyArea(): void {
278
- // Move cursor to top of PTY area (line 2)
279
- this.write(`${CSI}2;1H`)
280
- }
242
+ private drawStatusBar(): void {
243
+ const { agentName, wsStatus, cwd } = this.state
281
244
 
282
- private drawTitleBar(): void {
283
- const { agentName, cwd, wsStatus } = this.state
284
- // Move to line 1
285
- this.write(`${CSI}1;1H`)
286
- // Clear line and draw
245
+ // Move to last line (outside scroll region)
246
+ this.write(`${CSI}${this.rows};1H`)
287
247
  this.write(`${BG_BAR}${CSI}2K`)
288
248
 
289
249
  const displayCwd = this.shortenPath(cwd)
290
250
 
291
- // WS status indicator
292
- const statusColor = {
293
- connected: FG_GREEN,
294
- connecting: FG_YELLOW,
295
- disconnected: FG_RED,
296
- pending_approval: FG_YELLOW,
297
- no_project: FG_DIM,
298
- }[wsStatus] || FG_DIM
299
- const statusLabel = {
300
- connected: 'Connected',
301
- connecting: 'Connecting...',
302
- disconnected: 'Disconnected',
303
- pending_approval: 'Pending',
304
- no_project: 'No Project',
305
- }[wsStatus] || wsStatus
306
- const wsIndicator = `${statusColor}\u25CF ${statusLabel}${RESET}${BG_BAR}`
307
-
308
- const left = ` ${FG_ACCENT}ctlsurf${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_TITLE}${agentName || 'starting...'}${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_DIM}${displayCwd}${RESET}${BG_BAR}`
309
- const right = `${wsIndicator} ${RESET}${BG_BAR}`
310
-
311
- this.write(left)
312
- const pad = Math.max(0, this.cols - this.visibleLen(left) - this.visibleLen(right))
313
- this.write(' '.repeat(pad))
314
- this.write(right)
315
- this.write(RESET)
316
- }
317
-
318
- private drawStatusBar(): void {
319
- const { wsStatus, workerId, cwd } = this.state
320
- // Move to last line
321
- this.write(`${CSI}${this.rows};1H`)
322
- // Clear line and draw
323
- this.write(`${BG_BAR}${CSI}2K`)
324
-
325
251
  const statusColor = {
326
252
  connected: FG_GREEN,
327
253
  connecting: FG_YELLOW,
@@ -339,8 +265,7 @@ export class Tui {
339
265
  no_project: 'No Project',
340
266
  }[wsStatus] || wsStatus
341
267
 
342
- const displayCwd = this.shortenPath(cwd)
343
- const left = ` ${statusDot} ${FG_DIM}${statusLabel}${RESET}${BG_BAR}`
268
+ const left = ` ${FG_ACCENT}ctlsurf${RESET}${BG_BAR} ${statusDot} ${FG_DIM}${statusLabel}${RESET}${BG_BAR} ${FG_DIM}\u2502${RESET}${BG_BAR} ${FG_DIM}${agentName || '...'}${RESET}${BG_BAR}`
344
269
  const right = `${FG_DIM}Ctrl+\\ exit${RESET}${BG_BAR} ${FG_DIM}${displayCwd} ${RESET}${BG_BAR}`
345
270
 
346
271
  this.write(left)
@@ -360,7 +285,6 @@ export class Tui {
360
285
  }
361
286
 
362
287
  private visibleLen(s: string): number {
363
- // Strip ANSI codes to get visible length
364
288
  return s.replace(/\x1b\[[^m]*m/g, '').length
365
289
  }
366
290
  }
@@ -27,6 +27,20 @@ let _unsubData: (() => void) | null = null
27
27
  let _unsubExit: (() => void) | null = null
28
28
  let _currentAgentId: string | null = null
29
29
 
30
+ // Scroll-pinning: true means user is at/near bottom, auto-scroll is allowed
31
+ let _pinnedToBottom = true
32
+
33
+ function isAtBottom(terminal: Terminal): boolean {
34
+ const buf = terminal.buffer.active
35
+ return buf.viewportY >= buf.baseY
36
+ }
37
+
38
+ function scrollIfPinned(terminal: Terminal) {
39
+ if (_pinnedToBottom) {
40
+ terminal.scrollToBottom()
41
+ }
42
+ }
43
+
30
44
  function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon: FitAddon } {
31
45
  if (_terminal && _fitAddon) {
32
46
  return { terminal: _terminal, fitAddon: _fitAddon }
@@ -67,14 +81,22 @@ function getOrCreateTerminal(onExit: () => void): { terminal: Terminal; fitAddon
67
81
  terminal.loadAddon(fitAddon)
68
82
  terminal.loadAddon(new WebLinksAddon())
69
83
 
70
- // Send keystrokes to pty
84
+ // Track scroll position — detect when user scrolls away from bottom
85
+ terminal.onScroll(() => {
86
+ _pinnedToBottom = isAtBottom(terminal)
87
+ })
88
+
89
+ // Send keystrokes to pty — re-pin to bottom on user input
71
90
  terminal.onData((data) => {
91
+ _pinnedToBottom = true
92
+ terminal.scrollToBottom()
72
93
  window.worker.writePty(data)
73
94
  })
74
95
 
75
- // Receive pty output — subscribed once, never unsubscribed
96
+ // Receive pty output — auto-scroll only if pinned
76
97
  _unsubData = window.worker.onPtyData((data) => {
77
98
  terminal.write(data)
99
+ scrollIfPinned(terminal)
78
100
  })
79
101
 
80
102
  // Handle pty exit
@@ -108,7 +130,7 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
108
130
  }
109
131
 
110
132
  fitAddon.fit()
111
- terminal.scrollToBottom()
133
+ scrollIfPinned(terminal)
112
134
 
113
135
  // Resize handling
114
136
  let resizeTimeout: ReturnType<typeof setTimeout>
@@ -117,7 +139,7 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
117
139
  resizeTimeout = setTimeout(() => {
118
140
  if (_fitAddon && _terminal) {
119
141
  _fitAddon.fit()
120
- _terminal.scrollToBottom()
142
+ scrollIfPinned(_terminal)
121
143
  const { cols, rows } = _terminal
122
144
  window.worker.resizePty(cols, rows)
123
145
  }
@@ -147,6 +169,7 @@ export function TerminalPanel({ agent, onSpawn, onExit }: TerminalPanelProps) {
147
169
  onSpawn(agent).then(() => {
148
170
  if (_fitAddon && _terminal) {
149
171
  _fitAddon.fit()
172
+ _pinnedToBottom = true
150
173
  _terminal.scrollToBottom()
151
174
  const { cols, rows } = _terminal
152
175
  window.worker.resizePty(cols, rows)