numux 1.4.0 → 1.5.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.
- package/README.md +27 -17
- package/dist/bin.js +2611 -0
- package/dist/config.d.ts +4 -0
- package/dist/config.js +7 -0
- package/dist/types.d.ts +53 -0
- package/package.json +13 -9
- package/src/cli.ts +0 -217
- package/src/completions.ts +0 -121
- package/src/config/expand-scripts.ts +0 -96
- package/src/config/interpolate.ts +0 -50
- package/src/config/loader.ts +0 -76
- package/src/config/resolver.ts +0 -67
- package/src/config/validator.ts +0 -150
- package/src/config.ts +0 -8
- package/src/index.ts +0 -258
- package/src/process/manager.ts +0 -379
- package/src/process/ready.ts +0 -45
- package/src/process/runner.ts +0 -243
- package/src/types.ts +0 -57
- package/src/ui/app.ts +0 -454
- package/src/ui/pane.ts +0 -125
- package/src/ui/prefix.ts +0 -207
- package/src/ui/status-bar.ts +0 -58
- package/src/ui/tabs.ts +0 -246
- package/src/utils/color.ts +0 -93
- package/src/utils/env-file.ts +0 -58
- package/src/utils/log-writer.ts +0 -48
- package/src/utils/logger.ts +0 -32
- package/src/utils/shutdown.ts +0 -39
- package/src/utils/watcher.ts +0 -53
package/src/types.ts
DELETED
|
@@ -1,57 +0,0 @@
|
|
|
1
|
-
export interface NumuxProcessConfig {
|
|
2
|
-
command: string
|
|
3
|
-
cwd?: string
|
|
4
|
-
env?: Record<string, string>
|
|
5
|
-
envFile?: string | string[] // .env file path(s) to load
|
|
6
|
-
dependsOn?: string[]
|
|
7
|
-
readyPattern?: string
|
|
8
|
-
persistent?: boolean // default true, false = one-shot
|
|
9
|
-
maxRestarts?: number // default Infinity, limit auto-restart attempts
|
|
10
|
-
readyTimeout?: number // ms to wait for readyPattern before failing (default: none)
|
|
11
|
-
delay?: number // ms to wait before starting the process (default: none)
|
|
12
|
-
condition?: string // env var name (prefix with ! to negate); process skipped if condition is falsy
|
|
13
|
-
stopSignal?: 'SIGTERM' | 'SIGINT' | 'SIGHUP' // signal for graceful stop (default: SIGTERM)
|
|
14
|
-
color?: string | string[]
|
|
15
|
-
watch?: string | string[] // Glob patterns — restart process when matching files change
|
|
16
|
-
interactive?: boolean // default false — when true, keyboard input is forwarded to the process
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
/** Config for npm: wildcard entries — command is derived from package.json scripts */
|
|
20
|
-
export type NumuxScriptPattern = Omit<NumuxProcessConfig, 'command'> & { command?: never }
|
|
21
|
-
|
|
22
|
-
/** Raw config as authored — processes can be string shorthand, full objects, or wildcard patterns */
|
|
23
|
-
export interface NumuxConfig {
|
|
24
|
-
cwd?: string // Global working directory, inherited by all processes
|
|
25
|
-
env?: Record<string, string> // Global env vars, merged into each process (process-level overrides)
|
|
26
|
-
envFile?: string | string[] // Global .env file(s), inherited by processes without their own envFile
|
|
27
|
-
processes: Record<string, NumuxProcessConfig | NumuxScriptPattern | string>
|
|
28
|
-
}
|
|
29
|
-
|
|
30
|
-
/** Validated config with all shorthand expanded to full objects */
|
|
31
|
-
export interface ResolvedNumuxConfig {
|
|
32
|
-
processes: Record<string, NumuxProcessConfig>
|
|
33
|
-
}
|
|
34
|
-
|
|
35
|
-
export type ProcessStatus =
|
|
36
|
-
| 'pending'
|
|
37
|
-
| 'starting'
|
|
38
|
-
| 'ready'
|
|
39
|
-
| 'running'
|
|
40
|
-
| 'stopping'
|
|
41
|
-
| 'stopped'
|
|
42
|
-
| 'finished'
|
|
43
|
-
| 'failed'
|
|
44
|
-
| 'skipped'
|
|
45
|
-
|
|
46
|
-
export interface ProcessState {
|
|
47
|
-
name: string
|
|
48
|
-
config: NumuxProcessConfig
|
|
49
|
-
status: ProcessStatus
|
|
50
|
-
exitCode: number | null
|
|
51
|
-
restartCount: number
|
|
52
|
-
}
|
|
53
|
-
|
|
54
|
-
export type ProcessEvent =
|
|
55
|
-
| { type: 'status'; name: string; status: ProcessStatus }
|
|
56
|
-
| { type: 'output'; name: string; data: Uint8Array }
|
|
57
|
-
| { type: 'exit'; name: string; code: number | null }
|
package/src/ui/app.ts
DELETED
|
@@ -1,454 +0,0 @@
|
|
|
1
|
-
import { BoxRenderable, type CliRenderer, createCliRenderer } from '@opentui/core'
|
|
2
|
-
import type { ProcessManager } from '../process/manager'
|
|
3
|
-
import type { ResolvedNumuxConfig } from '../types'
|
|
4
|
-
import { buildProcessHexColorMap } from '../utils/color'
|
|
5
|
-
import { Pane, type SearchMatch } from './pane'
|
|
6
|
-
import { StatusBar } from './status-bar'
|
|
7
|
-
import { TabBar } from './tabs'
|
|
8
|
-
|
|
9
|
-
export class App {
|
|
10
|
-
private renderer!: CliRenderer
|
|
11
|
-
private manager: ProcessManager
|
|
12
|
-
private panes = new Map<string, Pane>()
|
|
13
|
-
private tabBar!: TabBar
|
|
14
|
-
private statusBar!: StatusBar
|
|
15
|
-
private activePane: string | null = null
|
|
16
|
-
private destroyed = false
|
|
17
|
-
private names: string[]
|
|
18
|
-
private termCols = 80
|
|
19
|
-
private termRows = 24
|
|
20
|
-
private sidebarWidth = 20
|
|
21
|
-
|
|
22
|
-
private config: ResolvedNumuxConfig
|
|
23
|
-
|
|
24
|
-
private resizeTimer: ReturnType<typeof setTimeout> | null = null
|
|
25
|
-
|
|
26
|
-
// Search state
|
|
27
|
-
private searchMode = false
|
|
28
|
-
private searchQuery = ''
|
|
29
|
-
private searchMatches: SearchMatch[] = []
|
|
30
|
-
private searchIndex = -1
|
|
31
|
-
|
|
32
|
-
// Input-waiting detection for interactive processes
|
|
33
|
-
private inputWaitTimers = new Map<string, ReturnType<typeof setTimeout>>()
|
|
34
|
-
private awaitingInput = new Set<string>()
|
|
35
|
-
|
|
36
|
-
constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
|
|
37
|
-
this.manager = manager
|
|
38
|
-
this.config = config
|
|
39
|
-
this.names = manager.getProcessNames()
|
|
40
|
-
}
|
|
41
|
-
|
|
42
|
-
async start(): Promise<void> {
|
|
43
|
-
this.renderer = await createCliRenderer({
|
|
44
|
-
exitOnCtrlC: false,
|
|
45
|
-
useMouse: true
|
|
46
|
-
})
|
|
47
|
-
|
|
48
|
-
const { width, height } = this.renderer
|
|
49
|
-
const maxNameLen = Math.max(...this.names.map(n => n.length))
|
|
50
|
-
this.sidebarWidth = Math.min(30, Math.max(16, maxNameLen + 5))
|
|
51
|
-
this.termCols = Math.max(40, width - this.sidebarWidth - 2)
|
|
52
|
-
this.termRows = Math.max(5, height - 2)
|
|
53
|
-
const { termCols, termRows } = this
|
|
54
|
-
|
|
55
|
-
// Layout root
|
|
56
|
-
const layout = new BoxRenderable(this.renderer, {
|
|
57
|
-
id: 'root',
|
|
58
|
-
flexDirection: 'column',
|
|
59
|
-
width: '100%',
|
|
60
|
-
height: '100%',
|
|
61
|
-
border: false
|
|
62
|
-
})
|
|
63
|
-
|
|
64
|
-
// Tab bar (vertical sidebar)
|
|
65
|
-
const processHexColors = buildProcessHexColorMap(this.names, this.config)
|
|
66
|
-
this.tabBar = new TabBar(this.renderer, this.names, processHexColors)
|
|
67
|
-
|
|
68
|
-
// Content row: sidebar | pane
|
|
69
|
-
const contentRow = new BoxRenderable(this.renderer, {
|
|
70
|
-
id: 'content-row',
|
|
71
|
-
flexDirection: 'row',
|
|
72
|
-
flexGrow: 1,
|
|
73
|
-
width: '100%',
|
|
74
|
-
border: false
|
|
75
|
-
})
|
|
76
|
-
|
|
77
|
-
const sidebar = new BoxRenderable(this.renderer, {
|
|
78
|
-
id: 'sidebar',
|
|
79
|
-
width: this.sidebarWidth,
|
|
80
|
-
height: '100%',
|
|
81
|
-
border: ['right'],
|
|
82
|
-
borderColor: '#444'
|
|
83
|
-
})
|
|
84
|
-
sidebar.add(this.tabBar.renderable)
|
|
85
|
-
|
|
86
|
-
// Pane container
|
|
87
|
-
const paneContainer = new BoxRenderable(this.renderer, {
|
|
88
|
-
id: 'pane-container',
|
|
89
|
-
flexGrow: 1,
|
|
90
|
-
border: false
|
|
91
|
-
})
|
|
92
|
-
|
|
93
|
-
// Create a pane per process
|
|
94
|
-
for (const name of this.names) {
|
|
95
|
-
const interactive = this.config.processes[name].interactive === true
|
|
96
|
-
const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
|
|
97
|
-
this.panes.set(name, pane)
|
|
98
|
-
paneContainer.add(pane.scrollBox)
|
|
99
|
-
}
|
|
100
|
-
|
|
101
|
-
// Status bar (only visible during search)
|
|
102
|
-
this.statusBar = new StatusBar(this.renderer)
|
|
103
|
-
|
|
104
|
-
// Assemble layout
|
|
105
|
-
contentRow.add(sidebar)
|
|
106
|
-
contentRow.add(paneContainer)
|
|
107
|
-
layout.add(contentRow)
|
|
108
|
-
layout.add(this.statusBar.renderable)
|
|
109
|
-
this.renderer.root.add(layout)
|
|
110
|
-
|
|
111
|
-
// Wire tab events (mouse clicks)
|
|
112
|
-
this.tabBar.onSelect((_index, name) => this.switchPane(name))
|
|
113
|
-
this.tabBar.onSelectionChanged((_index, name) => this.switchPane(name))
|
|
114
|
-
|
|
115
|
-
// Wire process events
|
|
116
|
-
this.manager.on(event => {
|
|
117
|
-
if (this.destroyed) return
|
|
118
|
-
if (event.type === 'output') {
|
|
119
|
-
this.panes.get(event.name)?.feed(event.data)
|
|
120
|
-
// Detect input-waiting for interactive processes
|
|
121
|
-
if (this.config.processes[event.name]?.interactive) {
|
|
122
|
-
this.checkInputWaiting(event.name, event.data)
|
|
123
|
-
}
|
|
124
|
-
} else if (event.type === 'status') {
|
|
125
|
-
const state = this.manager.getState(event.name)
|
|
126
|
-
this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
|
|
127
|
-
// Clear input-waiting on non-active statuses
|
|
128
|
-
if (event.status !== 'running' && event.status !== 'ready') {
|
|
129
|
-
this.clearInputWaiting(event.name)
|
|
130
|
-
}
|
|
131
|
-
}
|
|
132
|
-
})
|
|
133
|
-
|
|
134
|
-
// Handle resize (debounced to avoid excessive PTY resize calls)
|
|
135
|
-
this.renderer.on('resize', (w: number, h: number) => {
|
|
136
|
-
this.termCols = Math.max(40, w - this.sidebarWidth - 2)
|
|
137
|
-
this.termRows = Math.max(5, h - 2)
|
|
138
|
-
if (this.resizeTimer) clearTimeout(this.resizeTimer)
|
|
139
|
-
this.resizeTimer = setTimeout(() => {
|
|
140
|
-
this.resizeTimer = null
|
|
141
|
-
for (const pane of this.panes.values()) {
|
|
142
|
-
pane.resize(this.termCols, this.termRows)
|
|
143
|
-
}
|
|
144
|
-
this.manager.resizeAll(this.termCols, this.termRows)
|
|
145
|
-
}, 50)
|
|
146
|
-
})
|
|
147
|
-
|
|
148
|
-
// Global keyboard handler
|
|
149
|
-
this.renderer.keyInput.on(
|
|
150
|
-
'keypress',
|
|
151
|
-
(key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
|
|
152
|
-
// Ctrl+C: quit (always works)
|
|
153
|
-
if (key.ctrl && key.name === 'c') {
|
|
154
|
-
if (this.searchMode) {
|
|
155
|
-
this.exitSearch()
|
|
156
|
-
return
|
|
157
|
-
}
|
|
158
|
-
this.shutdown().then(() => {
|
|
159
|
-
process.exit(this.hasFailures() ? 1 : 0)
|
|
160
|
-
})
|
|
161
|
-
return
|
|
162
|
-
}
|
|
163
|
-
|
|
164
|
-
// Search mode input handling
|
|
165
|
-
if (this.searchMode) {
|
|
166
|
-
this.handleSearchInput(key)
|
|
167
|
-
return
|
|
168
|
-
}
|
|
169
|
-
|
|
170
|
-
// Alt+Shift combos
|
|
171
|
-
if (key.meta && key.shift && !key.ctrl) {
|
|
172
|
-
// Alt+Shift+R: restart all processes
|
|
173
|
-
if (key.name === 'r') {
|
|
174
|
-
this.manager.restartAll(this.termCols, this.termRows)
|
|
175
|
-
return
|
|
176
|
-
}
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
if (key.meta && !key.ctrl && !key.shift) {
|
|
180
|
-
// Alt+F: enter search mode
|
|
181
|
-
if (key.name === 'f' && this.activePane) {
|
|
182
|
-
this.enterSearch()
|
|
183
|
-
return
|
|
184
|
-
}
|
|
185
|
-
|
|
186
|
-
// Alt+R: restart active process
|
|
187
|
-
if (key.name === 'r' && this.activePane) {
|
|
188
|
-
this.manager.restart(this.activePane, this.termCols, this.termRows)
|
|
189
|
-
return
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
// Alt+S: stop/start active process
|
|
193
|
-
if (key.name === 's' && this.activePane) {
|
|
194
|
-
const state = this.manager.getState(this.activePane)
|
|
195
|
-
if (state?.status === 'stopped' || state?.status === 'finished' || state?.status === 'failed') {
|
|
196
|
-
this.manager.start(this.activePane, this.termCols, this.termRows)
|
|
197
|
-
} else {
|
|
198
|
-
this.manager.stop(this.activePane)
|
|
199
|
-
}
|
|
200
|
-
return
|
|
201
|
-
}
|
|
202
|
-
|
|
203
|
-
// Alt+L: clear active pane
|
|
204
|
-
if (key.name === 'l' && this.activePane) {
|
|
205
|
-
this.panes.get(this.activePane)?.clear()
|
|
206
|
-
return
|
|
207
|
-
}
|
|
208
|
-
|
|
209
|
-
// Alt+1-9: jump to tab (uses display order from tab bar)
|
|
210
|
-
const num = Number.parseInt(key.name, 10)
|
|
211
|
-
if (num >= 1 && num <= 9 && num <= this.tabBar.count) {
|
|
212
|
-
this.tabBar.setSelectedIndex(num - 1)
|
|
213
|
-
this.switchPane(this.tabBar.getNameAtIndex(num - 1))
|
|
214
|
-
return
|
|
215
|
-
}
|
|
216
|
-
|
|
217
|
-
// Alt+Left/Right: cycle tabs
|
|
218
|
-
if (key.name === 'left' || key.name === 'right') {
|
|
219
|
-
const current = this.tabBar.getSelectedIndex()
|
|
220
|
-
const count = this.tabBar.count
|
|
221
|
-
const next = key.name === 'right' ? (current + 1) % count : (current - 1 + count) % count
|
|
222
|
-
this.tabBar.setSelectedIndex(next)
|
|
223
|
-
this.switchPane(this.tabBar.getNameAtIndex(next))
|
|
224
|
-
return
|
|
225
|
-
}
|
|
226
|
-
|
|
227
|
-
// Alt+PageUp/PageDown: scroll output
|
|
228
|
-
if (this.activePane && (key.name === 'pageup' || key.name === 'pagedown')) {
|
|
229
|
-
const pane = this.panes.get(this.activePane)
|
|
230
|
-
const delta = this.termRows - 2
|
|
231
|
-
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
232
|
-
return
|
|
233
|
-
}
|
|
234
|
-
|
|
235
|
-
// Alt+Home/End: scroll to top/bottom
|
|
236
|
-
if (this.activePane && key.name === 'home') {
|
|
237
|
-
this.panes.get(this.activePane)?.scrollToTop()
|
|
238
|
-
return
|
|
239
|
-
}
|
|
240
|
-
if (this.activePane && key.name === 'end') {
|
|
241
|
-
this.panes.get(this.activePane)?.scrollToBottom()
|
|
242
|
-
return
|
|
243
|
-
}
|
|
244
|
-
}
|
|
245
|
-
|
|
246
|
-
if (!this.activePane) return
|
|
247
|
-
|
|
248
|
-
const isInteractive = this.config.processes[this.activePane]?.interactive === true
|
|
249
|
-
|
|
250
|
-
// Non-interactive panes: arrow keys scroll, all other input is dropped
|
|
251
|
-
if (!isInteractive) {
|
|
252
|
-
if (key.name === 'up' || key.name === 'down') {
|
|
253
|
-
const pane = this.panes.get(this.activePane)
|
|
254
|
-
pane?.scrollBy(key.name === 'up' ? -1 : 1)
|
|
255
|
-
} else if (key.name === 'pageup' || key.name === 'pagedown') {
|
|
256
|
-
const pane = this.panes.get(this.activePane)
|
|
257
|
-
const delta = this.termRows - 2
|
|
258
|
-
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
259
|
-
} else if (key.name === 'home') {
|
|
260
|
-
this.panes.get(this.activePane)?.scrollToTop()
|
|
261
|
-
} else if (key.name === 'end') {
|
|
262
|
-
this.panes.get(this.activePane)?.scrollToBottom()
|
|
263
|
-
}
|
|
264
|
-
return
|
|
265
|
-
}
|
|
266
|
-
|
|
267
|
-
// Forward all other input to the active process
|
|
268
|
-
if (key.sequence) {
|
|
269
|
-
this.manager.write(this.activePane, key.sequence)
|
|
270
|
-
}
|
|
271
|
-
}
|
|
272
|
-
)
|
|
273
|
-
|
|
274
|
-
// Show first pane
|
|
275
|
-
if (this.names.length > 0) {
|
|
276
|
-
this.switchPane(this.names[0])
|
|
277
|
-
}
|
|
278
|
-
|
|
279
|
-
// Start all processes
|
|
280
|
-
await this.manager.startAll(termCols, termRows)
|
|
281
|
-
}
|
|
282
|
-
|
|
283
|
-
private switchPane(name: string): void {
|
|
284
|
-
if (this.activePane === name) return
|
|
285
|
-
// Clear search when switching panes
|
|
286
|
-
if (this.searchMode) {
|
|
287
|
-
this.exitSearch()
|
|
288
|
-
}
|
|
289
|
-
if (this.activePane) {
|
|
290
|
-
this.panes.get(this.activePane)?.hide()
|
|
291
|
-
}
|
|
292
|
-
this.activePane = name
|
|
293
|
-
this.panes.get(name)?.show()
|
|
294
|
-
}
|
|
295
|
-
|
|
296
|
-
/** Detect when an interactive process is likely waiting for user input */
|
|
297
|
-
private checkInputWaiting(name: string, data: Uint8Array): void {
|
|
298
|
-
// Clear existing timer
|
|
299
|
-
const existing = this.inputWaitTimers.get(name)
|
|
300
|
-
if (existing) clearTimeout(existing)
|
|
301
|
-
|
|
302
|
-
// If we were showing awaiting input, clear it since new output arrived
|
|
303
|
-
if (this.awaitingInput.has(name)) {
|
|
304
|
-
this.awaitingInput.delete(name)
|
|
305
|
-
this.tabBar.setInputWaiting(name, false)
|
|
306
|
-
}
|
|
307
|
-
|
|
308
|
-
// If the last byte is not a newline, the process may be showing a prompt
|
|
309
|
-
const lastByte = data[data.length - 1]
|
|
310
|
-
if (lastByte !== 0x0a && lastByte !== 0x0d) {
|
|
311
|
-
const timer = setTimeout(() => {
|
|
312
|
-
this.inputWaitTimers.delete(name)
|
|
313
|
-
const state = this.manager.getState(name)
|
|
314
|
-
if (state && (state.status === 'running' || state.status === 'ready')) {
|
|
315
|
-
this.awaitingInput.add(name)
|
|
316
|
-
this.tabBar.setInputWaiting(name, true)
|
|
317
|
-
}
|
|
318
|
-
}, 200)
|
|
319
|
-
this.inputWaitTimers.set(name, timer)
|
|
320
|
-
}
|
|
321
|
-
}
|
|
322
|
-
|
|
323
|
-
private clearInputWaiting(name: string): void {
|
|
324
|
-
const timer = this.inputWaitTimers.get(name)
|
|
325
|
-
if (timer) {
|
|
326
|
-
clearTimeout(timer)
|
|
327
|
-
this.inputWaitTimers.delete(name)
|
|
328
|
-
}
|
|
329
|
-
if (this.awaitingInput.has(name)) {
|
|
330
|
-
this.awaitingInput.delete(name)
|
|
331
|
-
this.tabBar.setInputWaiting(name, false)
|
|
332
|
-
}
|
|
333
|
-
}
|
|
334
|
-
|
|
335
|
-
private enterSearch(): void {
|
|
336
|
-
this.searchMode = true
|
|
337
|
-
this.searchQuery = ''
|
|
338
|
-
this.searchMatches = []
|
|
339
|
-
this.searchIndex = -1
|
|
340
|
-
this.statusBar.setSearchMode(true)
|
|
341
|
-
}
|
|
342
|
-
|
|
343
|
-
private exitSearch(): void {
|
|
344
|
-
this.searchMode = false
|
|
345
|
-
this.searchQuery = ''
|
|
346
|
-
this.searchMatches = []
|
|
347
|
-
this.searchIndex = -1
|
|
348
|
-
if (this.activePane) {
|
|
349
|
-
this.panes.get(this.activePane)?.clearHighlights()
|
|
350
|
-
}
|
|
351
|
-
this.statusBar.setSearchMode(false)
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
private handleSearchInput(key: {
|
|
355
|
-
ctrl: boolean
|
|
356
|
-
shift: boolean
|
|
357
|
-
meta: boolean
|
|
358
|
-
name: string
|
|
359
|
-
sequence: string
|
|
360
|
-
}): void {
|
|
361
|
-
if (key.name === 'escape') {
|
|
362
|
-
this.exitSearch()
|
|
363
|
-
return
|
|
364
|
-
}
|
|
365
|
-
|
|
366
|
-
if (key.name === 'return') {
|
|
367
|
-
// Enter: next match, Shift+Enter: previous match
|
|
368
|
-
if (this.searchMatches.length === 0) return
|
|
369
|
-
if (key.shift) {
|
|
370
|
-
this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length
|
|
371
|
-
} else {
|
|
372
|
-
this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length
|
|
373
|
-
}
|
|
374
|
-
this.scrollToCurrentMatch()
|
|
375
|
-
this.updateSearchHighlights()
|
|
376
|
-
return
|
|
377
|
-
}
|
|
378
|
-
|
|
379
|
-
if (key.name === 'backspace') {
|
|
380
|
-
if (this.searchQuery.length > 0) {
|
|
381
|
-
this.searchQuery = this.searchQuery.slice(0, -1)
|
|
382
|
-
this.runSearch()
|
|
383
|
-
}
|
|
384
|
-
return
|
|
385
|
-
}
|
|
386
|
-
|
|
387
|
-
// Printable character
|
|
388
|
-
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
389
|
-
this.searchQuery += key.sequence
|
|
390
|
-
this.runSearch()
|
|
391
|
-
}
|
|
392
|
-
}
|
|
393
|
-
|
|
394
|
-
private runSearch(): void {
|
|
395
|
-
if (!this.activePane) return
|
|
396
|
-
const pane = this.panes.get(this.activePane)
|
|
397
|
-
if (!pane) return
|
|
398
|
-
|
|
399
|
-
this.searchMatches = pane.search(this.searchQuery)
|
|
400
|
-
this.searchIndex = this.searchMatches.length > 0 ? 0 : -1
|
|
401
|
-
|
|
402
|
-
this.updateSearchHighlights()
|
|
403
|
-
if (this.searchIndex >= 0) {
|
|
404
|
-
this.scrollToCurrentMatch()
|
|
405
|
-
}
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
private updateSearchHighlights(): void {
|
|
409
|
-
if (!this.activePane) return
|
|
410
|
-
const pane = this.panes.get(this.activePane)
|
|
411
|
-
if (!pane) return
|
|
412
|
-
|
|
413
|
-
if (this.searchMatches.length > 0) {
|
|
414
|
-
pane.setHighlights(this.searchMatches, this.searchIndex)
|
|
415
|
-
} else {
|
|
416
|
-
pane.clearHighlights()
|
|
417
|
-
}
|
|
418
|
-
this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex)
|
|
419
|
-
}
|
|
420
|
-
|
|
421
|
-
private scrollToCurrentMatch(): void {
|
|
422
|
-
if (!this.activePane || this.searchIndex < 0) return
|
|
423
|
-
const pane = this.panes.get(this.activePane)
|
|
424
|
-
if (!pane) return
|
|
425
|
-
const match = this.searchMatches[this.searchIndex]
|
|
426
|
-
pane.scrollToLine(match.line)
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
async shutdown(): Promise<void> {
|
|
430
|
-
if (this.destroyed) return
|
|
431
|
-
this.destroyed = true
|
|
432
|
-
if (this.resizeTimer) {
|
|
433
|
-
clearTimeout(this.resizeTimer)
|
|
434
|
-
this.resizeTimer = null
|
|
435
|
-
}
|
|
436
|
-
// Clear all input-waiting timers
|
|
437
|
-
for (const timer of this.inputWaitTimers.values()) {
|
|
438
|
-
clearTimeout(timer)
|
|
439
|
-
}
|
|
440
|
-
this.inputWaitTimers.clear()
|
|
441
|
-
await this.manager.stopAll()
|
|
442
|
-
for (const pane of this.panes.values()) {
|
|
443
|
-
pane.destroy()
|
|
444
|
-
}
|
|
445
|
-
if (!this.renderer.isDestroyed) {
|
|
446
|
-
this.renderer.destroy()
|
|
447
|
-
}
|
|
448
|
-
}
|
|
449
|
-
|
|
450
|
-
/** Check if any process ended in a failed state */
|
|
451
|
-
hasFailures(): boolean {
|
|
452
|
-
return this.manager.getAllStates().some(s => s.status === 'failed')
|
|
453
|
-
}
|
|
454
|
-
}
|
package/src/ui/pane.ts
DELETED
|
@@ -1,125 +0,0 @@
|
|
|
1
|
-
import { type CliRenderer, ScrollBoxRenderable } from '@opentui/core'
|
|
2
|
-
import { GhosttyTerminalRenderable, type HighlightRegion } from 'ghostty-opentui/terminal-buffer'
|
|
3
|
-
|
|
4
|
-
export interface SearchMatch {
|
|
5
|
-
line: number
|
|
6
|
-
start: number
|
|
7
|
-
end: number
|
|
8
|
-
}
|
|
9
|
-
|
|
10
|
-
export class Pane {
|
|
11
|
-
readonly scrollBox: ScrollBoxRenderable
|
|
12
|
-
readonly terminal: GhosttyTerminalRenderable
|
|
13
|
-
private decoder = new TextDecoder()
|
|
14
|
-
|
|
15
|
-
private _onScroll: (() => void) | null = null
|
|
16
|
-
|
|
17
|
-
constructor(renderer: CliRenderer, name: string, cols: number, rows: number, interactive = false) {
|
|
18
|
-
this.scrollBox = new ScrollBoxRenderable(renderer, {
|
|
19
|
-
id: `pane-${name}`,
|
|
20
|
-
flexGrow: 1,
|
|
21
|
-
width: '100%',
|
|
22
|
-
stickyScroll: true,
|
|
23
|
-
stickyStart: 'bottom',
|
|
24
|
-
visible: false,
|
|
25
|
-
onMouseScroll: () => this._onScroll?.()
|
|
26
|
-
})
|
|
27
|
-
|
|
28
|
-
this.terminal = new GhosttyTerminalRenderable(renderer, {
|
|
29
|
-
id: `term-${name}`,
|
|
30
|
-
cols,
|
|
31
|
-
rows,
|
|
32
|
-
persistent: true,
|
|
33
|
-
showCursor: interactive,
|
|
34
|
-
trimEnd: true
|
|
35
|
-
})
|
|
36
|
-
|
|
37
|
-
this.scrollBox.add(this.terminal)
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
feed(data: Uint8Array): void {
|
|
41
|
-
const text = this.decoder.decode(data, { stream: true })
|
|
42
|
-
this.terminal.feed(text)
|
|
43
|
-
}
|
|
44
|
-
|
|
45
|
-
resize(cols: number, rows: number): void {
|
|
46
|
-
this.terminal.cols = cols
|
|
47
|
-
this.terminal.rows = rows
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
get isAtBottom(): boolean {
|
|
51
|
-
const { scrollTop, scrollHeight, viewport } = this.scrollBox
|
|
52
|
-
if (scrollHeight <= 0) return true
|
|
53
|
-
return scrollTop >= scrollHeight - viewport.height - 2
|
|
54
|
-
}
|
|
55
|
-
|
|
56
|
-
scrollBy(delta: number): void {
|
|
57
|
-
this.scrollBox.scrollBy(delta)
|
|
58
|
-
}
|
|
59
|
-
|
|
60
|
-
scrollToTop(): void {
|
|
61
|
-
this.scrollBox.scrollTo(0)
|
|
62
|
-
}
|
|
63
|
-
|
|
64
|
-
scrollToBottom(): void {
|
|
65
|
-
this.scrollBox.scrollTo(this.scrollBox.scrollHeight)
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
onScroll(handler: () => void): void {
|
|
69
|
-
this._onScroll = handler
|
|
70
|
-
}
|
|
71
|
-
|
|
72
|
-
show(): void {
|
|
73
|
-
this.scrollBox.visible = true
|
|
74
|
-
}
|
|
75
|
-
|
|
76
|
-
hide(): void {
|
|
77
|
-
this.scrollBox.visible = false
|
|
78
|
-
}
|
|
79
|
-
|
|
80
|
-
search(query: string): SearchMatch[] {
|
|
81
|
-
if (!query) return []
|
|
82
|
-
const text = this.terminal.getText()
|
|
83
|
-
const lines = text.split('\n')
|
|
84
|
-
const matches: SearchMatch[] = []
|
|
85
|
-
const lowerQuery = query.toLowerCase()
|
|
86
|
-
for (let line = 0; line < lines.length; line++) {
|
|
87
|
-
const lowerLine = lines[line].toLowerCase()
|
|
88
|
-
let pos = 0
|
|
89
|
-
while (true) {
|
|
90
|
-
const idx = lowerLine.indexOf(lowerQuery, pos)
|
|
91
|
-
if (idx === -1) break
|
|
92
|
-
matches.push({ line, start: idx, end: idx + query.length })
|
|
93
|
-
pos = idx + 1
|
|
94
|
-
}
|
|
95
|
-
}
|
|
96
|
-
return matches
|
|
97
|
-
}
|
|
98
|
-
|
|
99
|
-
setHighlights(matches: SearchMatch[], currentIndex: number): void {
|
|
100
|
-
const regions: HighlightRegion[] = matches.map((m, i) => ({
|
|
101
|
-
line: m.line,
|
|
102
|
-
start: m.start,
|
|
103
|
-
end: m.end,
|
|
104
|
-
backgroundColor: i === currentIndex ? '#b58900' : '#073642'
|
|
105
|
-
}))
|
|
106
|
-
this.terminal.highlights = regions
|
|
107
|
-
}
|
|
108
|
-
|
|
109
|
-
clearHighlights(): void {
|
|
110
|
-
this.terminal.highlights = undefined
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
scrollToLine(line: number): void {
|
|
114
|
-
const pos = this.terminal.getScrollPositionForLine(line)
|
|
115
|
-
this.scrollBox.scrollTo(pos)
|
|
116
|
-
}
|
|
117
|
-
|
|
118
|
-
clear(): void {
|
|
119
|
-
this.terminal.reset()
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
destroy(): void {
|
|
123
|
-
this.terminal.destroy()
|
|
124
|
-
}
|
|
125
|
-
}
|