numux 0.0.1 → 1.0.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/LICENSE +21 -0
- package/README.md +292 -0
- package/package.json +42 -4
- package/src/cli.ts +207 -0
- package/src/completions.ts +119 -0
- package/src/config/interpolate.ts +50 -0
- package/src/config/loader.ts +76 -0
- package/src/config/resolver.ts +67 -0
- package/src/config/validator.ts +140 -0
- package/src/config.ts +8 -0
- package/src/index.ts +229 -0
- package/src/process/manager.ts +377 -0
- package/src/process/ready.ts +45 -0
- package/src/process/runner.ts +243 -0
- package/src/types.ts +45 -0
- package/src/ui/app.ts +424 -0
- package/src/ui/pane.ts +125 -0
- package/src/ui/prefix.ts +199 -0
- package/src/ui/status-bar.ts +119 -0
- package/src/ui/tabs.ts +90 -0
- package/src/utils/color.ts +85 -0
- package/src/utils/env-file.ts +58 -0
- package/src/utils/log-writer.ts +48 -0
- package/src/utils/logger.ts +32 -0
- package/src/utils/shutdown.ts +39 -0
- package/src/utils/watcher.ts +53 -0
package/src/ui/app.ts
ADDED
|
@@ -0,0 +1,424 @@
|
|
|
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
|
+
private processHexColors: Map<string, string>
|
|
24
|
+
|
|
25
|
+
private resizeTimer: ReturnType<typeof setTimeout> | null = null
|
|
26
|
+
|
|
27
|
+
// Search state
|
|
28
|
+
private searchMode = false
|
|
29
|
+
private searchQuery = ''
|
|
30
|
+
private searchMatches: SearchMatch[] = []
|
|
31
|
+
private searchIndex = -1
|
|
32
|
+
|
|
33
|
+
constructor(manager: ProcessManager, config: ResolvedNumuxConfig) {
|
|
34
|
+
this.manager = manager
|
|
35
|
+
this.config = config
|
|
36
|
+
this.names = manager.getProcessNames()
|
|
37
|
+
this.processHexColors = buildProcessHexColorMap(this.names, config)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async start(): Promise<void> {
|
|
41
|
+
this.renderer = await createCliRenderer({
|
|
42
|
+
exitOnCtrlC: false,
|
|
43
|
+
useMouse: true
|
|
44
|
+
})
|
|
45
|
+
|
|
46
|
+
const { width, height } = this.renderer
|
|
47
|
+
const maxNameLen = Math.max(...this.names.map(n => n.length))
|
|
48
|
+
this.sidebarWidth = Math.min(30, Math.max(16, maxNameLen + 5))
|
|
49
|
+
this.termCols = Math.max(40, width - this.sidebarWidth - 2)
|
|
50
|
+
this.termRows = Math.max(5, height - 2)
|
|
51
|
+
const { termCols, termRows } = this
|
|
52
|
+
|
|
53
|
+
// Layout root
|
|
54
|
+
const layout = new BoxRenderable(this.renderer, {
|
|
55
|
+
id: 'root',
|
|
56
|
+
flexDirection: 'column',
|
|
57
|
+
width: '100%',
|
|
58
|
+
height: '100%',
|
|
59
|
+
border: false
|
|
60
|
+
})
|
|
61
|
+
|
|
62
|
+
// Tab bar (vertical sidebar)
|
|
63
|
+
this.tabBar = new TabBar(this.renderer, this.names)
|
|
64
|
+
|
|
65
|
+
// Content row: sidebar | pane
|
|
66
|
+
const contentRow = new BoxRenderable(this.renderer, {
|
|
67
|
+
id: 'content-row',
|
|
68
|
+
flexDirection: 'row',
|
|
69
|
+
flexGrow: 1,
|
|
70
|
+
width: '100%',
|
|
71
|
+
border: false
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
const sidebar = new BoxRenderable(this.renderer, {
|
|
75
|
+
id: 'sidebar',
|
|
76
|
+
width: this.sidebarWidth,
|
|
77
|
+
height: '100%',
|
|
78
|
+
border: ['right'],
|
|
79
|
+
borderColor: '#444'
|
|
80
|
+
})
|
|
81
|
+
sidebar.add(this.tabBar.renderable)
|
|
82
|
+
|
|
83
|
+
// Pane container
|
|
84
|
+
const paneContainer = new BoxRenderable(this.renderer, {
|
|
85
|
+
id: 'pane-container',
|
|
86
|
+
flexGrow: 1,
|
|
87
|
+
border: false
|
|
88
|
+
})
|
|
89
|
+
|
|
90
|
+
// Create a pane per process
|
|
91
|
+
for (const name of this.names) {
|
|
92
|
+
const interactive = this.config.processes[name].interactive === true
|
|
93
|
+
const pane = new Pane(this.renderer, name, termCols, termRows, interactive)
|
|
94
|
+
pane.onScroll(() => {
|
|
95
|
+
if (name === this.activePane) this.updateScrollIndicator()
|
|
96
|
+
})
|
|
97
|
+
this.panes.set(name, pane)
|
|
98
|
+
paneContainer.add(pane.scrollBox)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
// Status bar
|
|
102
|
+
this.statusBar = new StatusBar(this.renderer, this.names, this.processHexColors)
|
|
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
|
+
if (event.name === this.activePane) {
|
|
121
|
+
this.updateScrollIndicator()
|
|
122
|
+
}
|
|
123
|
+
} else if (event.type === 'status') {
|
|
124
|
+
const state = this.manager.getState(event.name)
|
|
125
|
+
this.tabBar.updateStatus(event.name, event.status, state?.exitCode, state?.restartCount)
|
|
126
|
+
this.statusBar.updateStatus(event.name, event.status)
|
|
127
|
+
}
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
// Handle resize (debounced to avoid excessive PTY resize calls)
|
|
131
|
+
this.renderer.on('resize', (w: number, h: number) => {
|
|
132
|
+
this.termCols = Math.max(40, w - this.sidebarWidth - 2)
|
|
133
|
+
this.termRows = Math.max(5, h - 2)
|
|
134
|
+
if (this.resizeTimer) clearTimeout(this.resizeTimer)
|
|
135
|
+
this.resizeTimer = setTimeout(() => {
|
|
136
|
+
this.resizeTimer = null
|
|
137
|
+
for (const pane of this.panes.values()) {
|
|
138
|
+
pane.resize(this.termCols, this.termRows)
|
|
139
|
+
}
|
|
140
|
+
this.manager.resizeAll(this.termCols, this.termRows)
|
|
141
|
+
}, 50)
|
|
142
|
+
})
|
|
143
|
+
|
|
144
|
+
// Global keyboard handler
|
|
145
|
+
this.renderer.keyInput.on(
|
|
146
|
+
'keypress',
|
|
147
|
+
(key: { ctrl: boolean; shift: boolean; meta: boolean; name: string; sequence: string }) => {
|
|
148
|
+
// Ctrl+C: quit (always works)
|
|
149
|
+
if (key.ctrl && key.name === 'c') {
|
|
150
|
+
if (this.searchMode) {
|
|
151
|
+
this.exitSearch()
|
|
152
|
+
return
|
|
153
|
+
}
|
|
154
|
+
this.shutdown().then(() => {
|
|
155
|
+
process.exit(this.hasFailures() ? 1 : 0)
|
|
156
|
+
})
|
|
157
|
+
return
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Search mode input handling
|
|
161
|
+
if (this.searchMode) {
|
|
162
|
+
this.handleSearchInput(key)
|
|
163
|
+
return
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
// Alt+Shift combos
|
|
167
|
+
if (key.meta && key.shift && !key.ctrl) {
|
|
168
|
+
// Alt+Shift+R: restart all processes
|
|
169
|
+
if (key.name === 'r') {
|
|
170
|
+
this.manager.restartAll(this.termCols, this.termRows)
|
|
171
|
+
return
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
if (key.meta && !key.ctrl && !key.shift) {
|
|
176
|
+
// Alt+F: enter search mode
|
|
177
|
+
if (key.name === 'f' && this.activePane) {
|
|
178
|
+
this.enterSearch()
|
|
179
|
+
return
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
// Alt+R: restart active process
|
|
183
|
+
if (key.name === 'r' && this.activePane) {
|
|
184
|
+
this.manager.restart(this.activePane, this.termCols, this.termRows)
|
|
185
|
+
return
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Alt+S: stop/start active process
|
|
189
|
+
if (key.name === 's' && this.activePane) {
|
|
190
|
+
const state = this.manager.getState(this.activePane)
|
|
191
|
+
if (state?.status === 'stopped' || state?.status === 'failed') {
|
|
192
|
+
this.manager.start(this.activePane, this.termCols, this.termRows)
|
|
193
|
+
} else {
|
|
194
|
+
this.manager.stop(this.activePane)
|
|
195
|
+
}
|
|
196
|
+
return
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Alt+L: clear active pane
|
|
200
|
+
if (key.name === 'l' && this.activePane) {
|
|
201
|
+
this.panes.get(this.activePane)?.clear()
|
|
202
|
+
return
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
// Alt+1-9: jump to tab
|
|
206
|
+
const num = Number.parseInt(key.name, 10)
|
|
207
|
+
if (num >= 1 && num <= 9 && num <= this.names.length) {
|
|
208
|
+
this.tabBar.setSelectedIndex(num - 1)
|
|
209
|
+
this.switchPane(this.names[num - 1])
|
|
210
|
+
return
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
// Alt+Left/Right: cycle tabs
|
|
214
|
+
if (key.name === 'left' || key.name === 'right') {
|
|
215
|
+
const current = this.tabBar.getSelectedIndex()
|
|
216
|
+
const next =
|
|
217
|
+
key.name === 'right'
|
|
218
|
+
? (current + 1) % this.names.length
|
|
219
|
+
: (current - 1 + this.names.length) % this.names.length
|
|
220
|
+
this.tabBar.setSelectedIndex(next)
|
|
221
|
+
this.switchPane(this.names[next])
|
|
222
|
+
return
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// Alt+PageUp/PageDown: scroll output
|
|
226
|
+
if (this.activePane && (key.name === 'pageup' || key.name === 'pagedown')) {
|
|
227
|
+
const pane = this.panes.get(this.activePane)
|
|
228
|
+
const delta = this.termRows - 2
|
|
229
|
+
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
230
|
+
this.updateScrollIndicator()
|
|
231
|
+
return
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
// Alt+Home/End: scroll to top/bottom
|
|
235
|
+
if (this.activePane && key.name === 'home') {
|
|
236
|
+
this.panes.get(this.activePane)?.scrollToTop()
|
|
237
|
+
this.updateScrollIndicator()
|
|
238
|
+
return
|
|
239
|
+
}
|
|
240
|
+
if (this.activePane && key.name === 'end') {
|
|
241
|
+
this.panes.get(this.activePane)?.scrollToBottom()
|
|
242
|
+
this.updateScrollIndicator()
|
|
243
|
+
return
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
|
|
247
|
+
if (!this.activePane) return
|
|
248
|
+
|
|
249
|
+
const isInteractive = this.config.processes[this.activePane]?.interactive === true
|
|
250
|
+
|
|
251
|
+
// Non-interactive panes: arrow keys scroll, all other input is dropped
|
|
252
|
+
if (!isInteractive) {
|
|
253
|
+
if (key.name === 'up' || key.name === 'down') {
|
|
254
|
+
const pane = this.panes.get(this.activePane)
|
|
255
|
+
pane?.scrollBy(key.name === 'up' ? -1 : 1)
|
|
256
|
+
this.updateScrollIndicator()
|
|
257
|
+
} else if (key.name === 'pageup' || key.name === 'pagedown') {
|
|
258
|
+
const pane = this.panes.get(this.activePane)
|
|
259
|
+
const delta = this.termRows - 2
|
|
260
|
+
pane?.scrollBy(key.name === 'pageup' ? -delta : delta)
|
|
261
|
+
this.updateScrollIndicator()
|
|
262
|
+
} else if (key.name === 'home') {
|
|
263
|
+
this.panes.get(this.activePane)?.scrollToTop()
|
|
264
|
+
this.updateScrollIndicator()
|
|
265
|
+
} else if (key.name === 'end') {
|
|
266
|
+
this.panes.get(this.activePane)?.scrollToBottom()
|
|
267
|
+
this.updateScrollIndicator()
|
|
268
|
+
}
|
|
269
|
+
return
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// Forward all other input to the active process
|
|
273
|
+
if (key.sequence) {
|
|
274
|
+
this.manager.write(this.activePane, key.sequence)
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
// Show first pane
|
|
280
|
+
if (this.names.length > 0) {
|
|
281
|
+
this.switchPane(this.names[0])
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Start all processes
|
|
285
|
+
await this.manager.startAll(termCols, termRows)
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
private switchPane(name: string): void {
|
|
289
|
+
if (this.activePane === name) return
|
|
290
|
+
// Clear search when switching panes
|
|
291
|
+
if (this.searchMode) {
|
|
292
|
+
this.exitSearch()
|
|
293
|
+
}
|
|
294
|
+
if (this.activePane) {
|
|
295
|
+
this.panes.get(this.activePane)?.hide()
|
|
296
|
+
}
|
|
297
|
+
this.activePane = name
|
|
298
|
+
this.panes.get(name)?.show()
|
|
299
|
+
this.updateScrollIndicator()
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
private updateScrollIndicator(): void {
|
|
303
|
+
if (!this.activePane) return
|
|
304
|
+
const pane = this.panes.get(this.activePane)
|
|
305
|
+
if (!pane) return
|
|
306
|
+
this.statusBar.setScrollIndicator(!pane.isAtBottom)
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
private enterSearch(): void {
|
|
310
|
+
this.searchMode = true
|
|
311
|
+
this.searchQuery = ''
|
|
312
|
+
this.searchMatches = []
|
|
313
|
+
this.searchIndex = -1
|
|
314
|
+
this.statusBar.setSearchMode(true)
|
|
315
|
+
}
|
|
316
|
+
|
|
317
|
+
private exitSearch(): void {
|
|
318
|
+
this.searchMode = false
|
|
319
|
+
this.searchQuery = ''
|
|
320
|
+
this.searchMatches = []
|
|
321
|
+
this.searchIndex = -1
|
|
322
|
+
if (this.activePane) {
|
|
323
|
+
this.panes.get(this.activePane)?.clearHighlights()
|
|
324
|
+
}
|
|
325
|
+
this.statusBar.setSearchMode(false)
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
private handleSearchInput(key: {
|
|
329
|
+
ctrl: boolean
|
|
330
|
+
shift: boolean
|
|
331
|
+
meta: boolean
|
|
332
|
+
name: string
|
|
333
|
+
sequence: string
|
|
334
|
+
}): void {
|
|
335
|
+
if (key.name === 'escape') {
|
|
336
|
+
this.exitSearch()
|
|
337
|
+
return
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
if (key.name === 'return') {
|
|
341
|
+
// Enter: next match, Shift+Enter: previous match
|
|
342
|
+
if (this.searchMatches.length === 0) return
|
|
343
|
+
if (key.shift) {
|
|
344
|
+
this.searchIndex = (this.searchIndex - 1 + this.searchMatches.length) % this.searchMatches.length
|
|
345
|
+
} else {
|
|
346
|
+
this.searchIndex = (this.searchIndex + 1) % this.searchMatches.length
|
|
347
|
+
}
|
|
348
|
+
this.scrollToCurrentMatch()
|
|
349
|
+
this.updateSearchHighlights()
|
|
350
|
+
return
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
if (key.name === 'backspace') {
|
|
354
|
+
if (this.searchQuery.length > 0) {
|
|
355
|
+
this.searchQuery = this.searchQuery.slice(0, -1)
|
|
356
|
+
this.runSearch()
|
|
357
|
+
}
|
|
358
|
+
return
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
// Printable character
|
|
362
|
+
if (key.sequence && key.sequence.length === 1 && !key.ctrl && !key.meta) {
|
|
363
|
+
this.searchQuery += key.sequence
|
|
364
|
+
this.runSearch()
|
|
365
|
+
}
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
private runSearch(): void {
|
|
369
|
+
if (!this.activePane) return
|
|
370
|
+
const pane = this.panes.get(this.activePane)
|
|
371
|
+
if (!pane) return
|
|
372
|
+
|
|
373
|
+
this.searchMatches = pane.search(this.searchQuery)
|
|
374
|
+
this.searchIndex = this.searchMatches.length > 0 ? 0 : -1
|
|
375
|
+
|
|
376
|
+
this.updateSearchHighlights()
|
|
377
|
+
if (this.searchIndex >= 0) {
|
|
378
|
+
this.scrollToCurrentMatch()
|
|
379
|
+
}
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
private updateSearchHighlights(): void {
|
|
383
|
+
if (!this.activePane) return
|
|
384
|
+
const pane = this.panes.get(this.activePane)
|
|
385
|
+
if (!pane) return
|
|
386
|
+
|
|
387
|
+
if (this.searchMatches.length > 0) {
|
|
388
|
+
pane.setHighlights(this.searchMatches, this.searchIndex)
|
|
389
|
+
} else {
|
|
390
|
+
pane.clearHighlights()
|
|
391
|
+
}
|
|
392
|
+
this.statusBar.setSearchMode(true, this.searchQuery, this.searchMatches.length, this.searchIndex)
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
private scrollToCurrentMatch(): void {
|
|
396
|
+
if (!this.activePane || this.searchIndex < 0) return
|
|
397
|
+
const pane = this.panes.get(this.activePane)
|
|
398
|
+
if (!pane) return
|
|
399
|
+
const match = this.searchMatches[this.searchIndex]
|
|
400
|
+
pane.scrollToLine(match.line)
|
|
401
|
+
this.updateScrollIndicator()
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
async shutdown(): Promise<void> {
|
|
405
|
+
if (this.destroyed) return
|
|
406
|
+
this.destroyed = true
|
|
407
|
+
if (this.resizeTimer) {
|
|
408
|
+
clearTimeout(this.resizeTimer)
|
|
409
|
+
this.resizeTimer = null
|
|
410
|
+
}
|
|
411
|
+
await this.manager.stopAll()
|
|
412
|
+
for (const pane of this.panes.values()) {
|
|
413
|
+
pane.destroy()
|
|
414
|
+
}
|
|
415
|
+
if (!this.renderer.isDestroyed) {
|
|
416
|
+
this.renderer.destroy()
|
|
417
|
+
}
|
|
418
|
+
}
|
|
419
|
+
|
|
420
|
+
/** Check if any process ended in a failed state */
|
|
421
|
+
hasFailures(): boolean {
|
|
422
|
+
return this.manager.getAllStates().some(s => s.status === 'failed')
|
|
423
|
+
}
|
|
424
|
+
}
|
package/src/ui/pane.ts
ADDED
|
@@ -0,0 +1,125 @@
|
|
|
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
|
+
}
|