@k1e1n04/mav 0.1.7 → 0.1.19

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.
@@ -1,57 +1,52 @@
1
- import blessed from 'neo-blessed'
2
- import type { Widgets } from 'neo-blessed'
3
1
  import type { SessionManager } from '../session-manager.js'
4
2
  import type { AgentSession } from '../agent.js'
5
3
  import { getAgentDefaults, resolveSessionArgs } from '../agent-launch.js'
6
4
  import { completePath } from './path-completion.js'
5
+ import type { KeyInfo, TerminalUI } from './terminal.js'
6
+
7
+ type PromptState =
8
+ | { mode: 'agent'; selectedIndex: number }
9
+ | { mode: 'cwd'; agentType: string; value: string; candidates: string[] }
10
+ | { mode: 'error'; message: string }
11
+ | null
7
12
 
8
13
  export class OverviewUI {
14
+ private static readonly ANSI = {
15
+ reset: '\x1b[0m',
16
+ bold: '\x1b[1m',
17
+ dim: '\x1b[2m',
18
+ fgSlate: '\x1b[38;5;245m',
19
+ fgGreen: '\x1b[38;5;42m',
20
+ fgAmber: '\x1b[38;5;221m',
21
+ fgCyan: '\x1b[38;5;81m',
22
+ fgRed: '\x1b[38;5;203m',
23
+ } as const
24
+
9
25
  private static readonly STATUS_GROUPS = [
10
26
  { status: 'running', label: 'Working' },
11
27
  { status: 'idle', label: 'Waiting' },
12
28
  { status: 'done', label: 'Complete' },
13
29
  { status: 'error', label: 'Failed' },
14
30
  ] as const
15
- private static readonly STATUS_COLORS = {
16
- running: 'cyan',
17
- idle: 'yellow',
18
- done: 'green',
19
- error: 'red',
20
- } as const
21
31
 
22
- private screen: Widgets.Screen
32
+ private static readonly AGENT_TYPES = ['claude-code', 'codex', 'gemini-cli', 'copilot'] as const
33
+
34
+ private terminal: TerminalUI
23
35
  private manager: SessionManager
24
36
  private onSessionCreated?: (session: SessionManager['selectedSession']) => void
25
- private listBox: Widgets.ListElement
26
- private promptOpen = false
37
+ private promptState: PromptState = null
38
+ private displaySessionIds: string[] = []
39
+ private visible = false
27
40
 
28
41
  constructor(
29
- screen: Widgets.Screen,
42
+ terminal: TerminalUI,
30
43
  manager: SessionManager,
31
44
  onSessionCreated?: (session: SessionManager['selectedSession']) => void
32
45
  ) {
33
- this.screen = screen
46
+ this.terminal = terminal
34
47
  this.manager = manager
35
48
  this.onSessionCreated = onSessionCreated
36
49
 
37
- this.listBox = blessed.list({
38
- parent: screen,
39
- top: 0,
40
- left: 0,
41
- width: '100%',
42
- height: '100%',
43
- border: { type: 'line' },
44
- label: ' AGENTS ',
45
- tags: true,
46
- style: {
47
- selected: { bg: 'blue', fg: 'white' },
48
- border: { fg: 'cyan' },
49
- },
50
- keys: true,
51
- mouse: true,
52
- })
53
-
54
- this.bindKeys()
55
50
  this.syncList()
56
51
 
57
52
  manager.on('data', (sessionId: string) => {
@@ -59,176 +54,152 @@ export class OverviewUI {
59
54
  return
60
55
  }
61
56
  this.syncList()
62
- screen.render()
57
+ this.render()
63
58
  })
64
59
 
65
60
  manager.on('exit', () => {
66
61
  this.syncList()
67
- screen.render()
62
+ this.render()
68
63
  })
69
64
 
70
65
  manager.on('status', () => {
71
66
  this.syncList()
72
- screen.render()
67
+ this.render()
73
68
  })
74
69
 
75
70
  manager.on('name', () => {
76
71
  this.syncList()
77
- screen.render()
72
+ this.render()
78
73
  })
79
- }
80
74
 
81
- private bindKeys(): void {
82
- this.listBox.key(['up', 'k'], () => {
83
- if (this.manager.sessions.length === 0) return
84
- this.moveSelection(-1)
75
+ manager.on('selection', () => {
85
76
  this.syncList()
86
- this.screen.render()
77
+ this.render()
87
78
  })
79
+ }
88
80
 
89
- this.listBox.key(['down', 'j'], () => {
90
- if (this.manager.sessions.length === 0) return
81
+ handleKeypress(str: string, key: KeyInfo): void {
82
+ if (this.promptState?.mode === 'agent') {
83
+ this.handleAgentPromptKeypress(key)
84
+ return
85
+ }
86
+ if (this.promptState?.mode === 'cwd') {
87
+ this.handleCwdPromptKeypress(key)
88
+ return
89
+ }
90
+ if (this.promptState?.mode === 'error') {
91
+ if (key.name === 'enter' || key.name === 'return' || key.name === 'escape' || str === 'q') {
92
+ this.promptState = null
93
+ this.render()
94
+ }
95
+ return
96
+ }
97
+
98
+ if ((key.name === 'up' || str === 'k') && this.manager.sessions.length > 0) {
99
+ this.moveSelection(-1)
100
+ return
101
+ }
102
+
103
+ if ((key.name === 'down' || str === 'j') && this.manager.sessions.length > 0) {
91
104
  this.moveSelection(1)
92
- this.syncList()
93
- this.screen.render()
94
- })
105
+ return
106
+ }
95
107
 
96
- this.listBox.key('n', () => {
108
+ if (str === 'n') {
97
109
  this.showAddPrompt()
98
- })
110
+ return
111
+ }
99
112
 
100
- this.listBox.key(['d', 'C-x'], () => {
113
+ if (str === 'd' || (key.ctrl && key.name === 'x')) {
101
114
  const session = this.manager.selectedSession
102
115
  if (session) {
103
116
  this.manager.removeSession(session.id)
104
117
  this.syncList()
105
- this.screen.render()
118
+ this.render()
106
119
  }
107
- })
120
+ }
108
121
  }
109
122
 
110
- private showAddPrompt(): void {
111
- if (this.promptOpen) return
112
- this.promptOpen = true
113
-
114
- const agentTypes = ['claude-code', 'codex', 'gemini-cli', 'copilot']
115
-
116
- const prompt = blessed.list({
117
- parent: this.screen,
118
- top: 'center',
119
- left: 'center',
120
- width: 40,
121
- height: agentTypes.length + 4,
122
- border: { type: 'line' },
123
- label: ' Select agent type ',
124
- items: agentTypes,
125
- keys: true,
126
- style: {
127
- selected: { bg: 'blue', fg: 'white' },
128
- border: { fg: 'green' },
129
- },
130
- })
123
+ private handleAgentPromptKeypress(key: KeyInfo): void {
124
+ const state = this.promptState
125
+ if (!state || state.mode !== 'agent') return
131
126
 
132
- const close = () => {
133
- this.promptOpen = false
134
- prompt.destroy()
135
- this.listBox.focus()
136
- this.screen.render()
127
+ if (key.name === 'escape') {
128
+ this.promptState = null
129
+ this.render()
130
+ return
137
131
  }
138
132
 
139
- prompt.key('enter', () => {
140
- const selectedIdx = (prompt as unknown as { selected: number }).selected ?? 0
141
- const selected = agentTypes[selectedIdx]!
142
- prompt.destroy()
143
- this.screen.render()
144
- this.showCwdPrompt(selected)
145
- })
133
+ if (key.name === 'up') {
134
+ state.selectedIndex =
135
+ (state.selectedIndex - 1 + OverviewUI.AGENT_TYPES.length) % OverviewUI.AGENT_TYPES.length
136
+ this.render()
137
+ return
138
+ }
146
139
 
147
- prompt.key('escape', close)
140
+ if (key.name === 'down') {
141
+ state.selectedIndex = (state.selectedIndex + 1) % OverviewUI.AGENT_TYPES.length
142
+ this.render()
143
+ return
144
+ }
148
145
 
149
- prompt.focus()
150
- this.screen.render()
146
+ if (key.name === 'enter' || key.name === 'return') {
147
+ const agentType = OverviewUI.AGENT_TYPES[state.selectedIndex]!
148
+ this.promptState = {
149
+ mode: 'cwd',
150
+ agentType,
151
+ value: process.cwd(),
152
+ candidates: [],
153
+ }
154
+ this.render()
155
+ }
151
156
  }
152
157
 
153
- private showCwdPrompt(agentType: string): void {
154
- let value = process.cwd()
155
- let candidateList: Widgets.ListElement | null = null
156
-
157
- const box = blessed.box({
158
- parent: this.screen,
159
- top: 'center',
160
- left: 'center',
161
- width: 60,
162
- height: 3,
163
- border: { type: 'line' },
164
- label: ' cwd Tab:補完 Enter:確定 Esc:キャンセル ',
165
- style: { border: { fg: 'green' } },
166
- })
158
+ private handleCwdPromptKeypress(key: KeyInfo): void {
159
+ const state = this.promptState
160
+ if (!state || state.mode !== 'cwd') return
167
161
 
168
- const renderInput = () => {
169
- box.setContent(value)
170
- this.screen.render()
162
+ if (key.name === 'escape') {
163
+ this.promptState = null
164
+ this.render()
165
+ return
171
166
  }
172
167
 
173
- const closeCandidates = () => {
174
- if (candidateList) {
175
- candidateList.destroy()
176
- candidateList = null
177
- }
168
+ if (key.name === 'enter' || key.name === 'return') {
169
+ const expanded = state.value.trim().replace(/^~/, process.env.HOME ?? '~') || process.cwd()
170
+ const agentType = state.agentType
171
+ this.promptState = null
172
+ this.render()
173
+ this.spawnAgent(agentType, expanded)
174
+ return
175
+ }
176
+
177
+ if (key.name === 'tab') {
178
+ const { completed, candidates } = completePath(state.value)
179
+ state.value = completed
180
+ state.candidates = candidates
181
+ this.render()
182
+ return
178
183
  }
179
184
 
180
- const cleanup = () => {
181
- this.screen.removeListener('keypress', onKeypress)
182
- closeCandidates()
183
- this.promptOpen = false
184
- box.destroy()
185
- this.listBox.focus()
186
- this.screen.render()
185
+ state.candidates = []
186
+
187
+ if (key.name === 'backspace') {
188
+ state.value = state.value.slice(0, -1)
189
+ this.render()
190
+ return
187
191
  }
188
192
 
189
- const onKeypress = (_ch: string, key: { name: string; ctrl?: boolean; meta?: boolean; sequence?: string }) => {
190
- if (!key) return
191
- closeCandidates()
192
-
193
- if (key.name === 'enter' || key.name === 'return') {
194
- const expanded = value.trim().replace(/^~/, process.env.HOME ?? '~') || process.cwd()
195
- cleanup()
196
- this.spawnAgent(agentType, expanded)
197
- } else if (key.name === 'escape') {
198
- cleanup()
199
- } else if (key.name === 'tab') {
200
- const { completed, candidates } = completePath(value)
201
- value = completed
202
- if (candidates.length > 1) {
203
- const maxVisible = Math.min(candidates.length, 8)
204
- candidateList = blessed.list({
205
- parent: this.screen,
206
- top: '50%',
207
- left: 'center',
208
- width: 60,
209
- height: maxVisible + 2,
210
- border: { type: 'line' },
211
- items: candidates,
212
- keys: false,
213
- style: { border: { fg: 'cyan' } },
214
- }) as Widgets.ListElement
215
- }
216
- renderInput()
217
- } else if (key.name === 'backspace') {
218
- value = value.slice(0, -1)
219
- renderInput()
220
- } else if (!key.ctrl && !key.meta && key.sequence?.length === 1) {
221
- value += key.sequence
222
- renderInput()
223
- }
193
+ if (!key.ctrl && !key.meta && key.sequence?.length === 1) {
194
+ state.value += key.sequence
195
+ this.render()
224
196
  }
197
+ }
225
198
 
226
- // 現在のキーイベント(enter)がこのリスナーに届かないよう1tick遅らせる
227
- setImmediate(() => {
228
- this.screen.on('keypress', onKeypress)
229
- })
230
- box.focus()
231
- renderInput()
199
+ private showAddPrompt(): void {
200
+ if (this.promptState) return
201
+ this.promptState = { mode: 'agent', selectedIndex: 0 }
202
+ this.render()
232
203
  }
233
204
 
234
205
  private spawnAgent(agentType: string, cwd: string): void {
@@ -258,66 +229,13 @@ export class OverviewUI {
258
229
  }
259
230
 
260
231
  private showError(message: string): void {
261
- const overlay = blessed.box({
262
- parent: this.screen,
263
- top: 'center',
264
- left: 'center',
265
- width: 50,
266
- height: message.split('\n').length + 4,
267
- border: { type: 'line' },
268
- label: ' Error ',
269
- content: `\n ${message.split('\n').join('\n ')}`,
270
- style: { border: { fg: 'red' }, label: { fg: 'red' } },
271
- keys: true,
272
- mouse: true,
273
- })
274
- overlay.key(['enter', 'escape', 'q'], () => {
275
- overlay.destroy()
276
- this.listBox.focus()
277
- this.screen.render()
278
- })
279
- overlay.focus()
280
- this.screen.render()
232
+ this.promptState = { mode: 'error', message }
233
+ this.render()
281
234
  }
282
235
 
283
236
  private syncList(): void {
284
237
  const orderedSessions = this.getOrderedSessions()
285
-
286
- const items: string[] = []
287
- const selectedId = this.manager.selectedSession?.id
288
- let selectedDisplayIndex = -1
289
-
290
- const cwdGroups = this.groupByCwd(orderedSessions)
291
- for (const [cwd, sessions] of cwdGroups) {
292
- items.push(` {bold}${this.shortenPath(cwd)}{/bold}`)
293
-
294
- for (const group of OverviewUI.STATUS_GROUPS) {
295
- const groupSessions = sessions.filter((s) => s.status === group.status)
296
- if (groupSessions.length === 0) continue
297
-
298
- items.push(` ${group.label}`)
299
- for (const session of groupSessions) {
300
- const statusIcon =
301
- session.status === 'running' ? '⣾'
302
- : session.status === 'idle' ? '○'
303
- : session.status === 'done' ? '✓'
304
- : '✗'
305
- const sessionTitle = session.displayName ?? session.id
306
- const sessionLabel = session.type ? `${sessionTitle} (${session.type})` : sessionTitle
307
- const content = `${statusIcon} ${sessionLabel} ${this.getStatusLabel(session.status)}`
308
- const color = OverviewUI.STATUS_COLORS[session.status]
309
- items.push(` {${color}-fg}${content}{/${color}-fg}`)
310
- if (session.id === selectedId) {
311
- selectedDisplayIndex = items.length - 1
312
- }
313
- }
314
- }
315
- }
316
-
317
- this.listBox.setItems(items)
318
- if (selectedDisplayIndex >= 0) {
319
- this.listBox.select(selectedDisplayIndex)
320
- }
238
+ this.displaySessionIds = orderedSessions.map((session) => session.id)
321
239
  }
322
240
 
323
241
  private groupByCwd(sessions: SessionManager['sessions']): Map<string, SessionManager['sessions']> {
@@ -351,18 +269,17 @@ export class OverviewUI {
351
269
  }
352
270
 
353
271
  private moveSelection(direction: -1 | 1): void {
354
- const orderedSessions = this.getOrderedSessions()
355
272
  const selectedId = this.manager.selectedSession?.id
356
- const currentIndex = selectedId
357
- ? orderedSessions.findIndex((session) => session.id === selectedId)
358
- : -1
273
+ if (this.displaySessionIds.length === 0) return
274
+
275
+ const currentIndex = selectedId ? this.displaySessionIds.indexOf(selectedId) : -1
359
276
  const nextIndex = currentIndex === -1
360
277
  ? 0
361
- : Math.max(0, Math.min(orderedSessions.length - 1, currentIndex + direction))
362
- const nextSession = orderedSessions[nextIndex]
363
- if (!nextSession) return
278
+ : (currentIndex + direction + this.displaySessionIds.length) % this.displaySessionIds.length
279
+ const nextSessionId = this.displaySessionIds[nextIndex]
280
+ if (!nextSessionId) return
364
281
 
365
- const managerIndex = this.manager.sessions.findIndex((session) => session.id === nextSession.id)
282
+ const managerIndex = this.manager.sessions.findIndex((session) => session.id === nextSessionId)
366
283
  if (managerIndex >= 0) {
367
284
  this.manager.selectSession(managerIndex)
368
285
  }
@@ -398,22 +315,174 @@ export class OverviewUI {
398
315
  }
399
316
  }
400
317
 
318
+ private countByStatus(status: string): number {
319
+ return this.manager.sessions.filter((session) => session.status === status).length
320
+ }
321
+
322
+ private stripAnsi(value: string): string {
323
+ return value.replace(/\x1b\[[0-9;]*m/g, '')
324
+ }
325
+
326
+ private padRight(value: string, width: number): string {
327
+ const visibleLength = this.stripAnsi(value).length
328
+ if (visibleLength >= width) {
329
+ return value
330
+ }
331
+ return value + ' '.repeat(width - visibleLength)
332
+ }
333
+
334
+ private color(text: string, ...codes: string[]): string {
335
+ return `${codes.join('')}${text}${OverviewUI.ANSI.reset}`
336
+ }
337
+
338
+ private getStatusColor(status: string): string {
339
+ switch (status) {
340
+ case 'running':
341
+ return OverviewUI.ANSI.fgGreen
342
+ case 'idle':
343
+ return OverviewUI.ANSI.fgAmber
344
+ case 'done':
345
+ return OverviewUI.ANSI.fgCyan
346
+ case 'error':
347
+ return OverviewUI.ANSI.fgRed
348
+ default:
349
+ return OverviewUI.ANSI.fgSlate
350
+ }
351
+ }
352
+
353
+ private makeRule(label: string, width = 64): string {
354
+ const text = ` ${label} `
355
+ if (text.length >= width) {
356
+ return text
357
+ }
358
+ const fill = '─'.repeat(width - text.length)
359
+ return text + fill
360
+ }
361
+
362
+ private formatSummary(): string {
363
+ const parts = OverviewUI.STATUS_GROUPS.map((group) => {
364
+ const count = this.countByStatus(group.status)
365
+ return this.color(`${group.label} ${count}`, OverviewUI.ANSI.bold, this.getStatusColor(group.status))
366
+ })
367
+ return parts.join(' · ')
368
+ }
369
+
370
+ private buildMainLines(): string[] {
371
+ const orderedSessions = this.getOrderedSessions()
372
+ const selectedId = this.manager.selectedSession?.id
373
+ const lines = [
374
+ '╭────────────────────────────── mav overview ──────────────────────────────╮',
375
+ `│ ${this.padRight('AGENTS', 74)} │`,
376
+ `│ ${this.padRight(this.formatSummary(), 74)} │`,
377
+ '╰──────────────────────────────────────────────────────────────────────────╯',
378
+ '',
379
+ ]
380
+
381
+ const cwdGroups = this.groupByCwd(orderedSessions)
382
+ for (const [cwd, sessions] of cwdGroups) {
383
+ lines.push(this.color(this.makeRule(this.shortenPath(cwd)), OverviewUI.ANSI.dim))
384
+
385
+ for (const group of OverviewUI.STATUS_GROUPS) {
386
+ const groupSessions = sessions.filter((s) => s.status === group.status)
387
+ if (groupSessions.length === 0) continue
388
+
389
+ lines.push(this.color(` ${group.label} (${groupSessions.length})`, OverviewUI.ANSI.bold, this.getStatusColor(group.status)))
390
+ for (const session of groupSessions) {
391
+ const color = this.getStatusColor(session.status)
392
+ const statusIcon =
393
+ session.status === 'running' ? '⣾'
394
+ : session.status === 'idle' ? '○'
395
+ : session.status === 'done' ? '✓'
396
+ : '✗'
397
+ const sessionTitle = session.displayName ?? session.id
398
+ const sessionLabel = session.type ? `${sessionTitle} (${session.type})` : sessionTitle
399
+ const prefix = session.id === selectedId ? '> ' : ' '
400
+ const sessionLine = `${prefix}${statusIcon} ${sessionLabel} ${this.getStatusLabel(session.status)}`
401
+ lines.push(session.id === selectedId
402
+ ? this.color(sessionLine, OverviewUI.ANSI.bold, color)
403
+ : this.color(sessionLine, color))
404
+ }
405
+
406
+ lines.push('')
407
+ }
408
+ }
409
+
410
+ if (orderedSessions.length === 0) {
411
+ lines.push(this.makeRule('empty'))
412
+ lines.push(' No sessions. Press n to add one.')
413
+ lines.push('')
414
+ }
415
+
416
+ lines.push('╭──────────────────────────────── controls ────────────────────────────────╮')
417
+ lines.push(`│ ${this.padRight('↑/↓ or j/k move Enter detail Ctrl+] back n new d delete q quit', 74)} │`)
418
+ lines.push('╰──────────────────────────────────────────────────────────────────────────╯')
419
+ return lines
420
+ }
421
+
422
+ private buildPromptLines(): string[] {
423
+ const state = this.promptState
424
+ if (!state) return []
425
+
426
+ const lines = ['', '╭──────────────────────────────── prompt ──────────────────────────────────╮']
427
+ if (state.mode === 'agent') {
428
+ lines.push(`│ ${this.padRight('Select agent type', 74)} │`)
429
+ lines.push('│ │')
430
+ for (const [index, agentType] of OverviewUI.AGENT_TYPES.entries()) {
431
+ lines.push(`│ ${this.padRight(`${index === state.selectedIndex ? '> ' : ' '}${agentType}`, 74)} │`)
432
+ }
433
+ lines.push('│ │')
434
+ lines.push(`│ ${this.padRight('Enter: select Esc: cancel', 74)} │`)
435
+ lines.push('╰──────────────────────────────────────────────────────────────────────────╯')
436
+ return lines
437
+ }
438
+
439
+ if (state.mode === 'cwd') {
440
+ lines.push(`│ ${this.padRight(`cwd for ${state.agentType}`, 74)} │`)
441
+ lines.push(`│ ${this.padRight(state.value, 74)} │`)
442
+ if (state.candidates.length > 1) {
443
+ lines.push('│ │')
444
+ lines.push(`│ ${this.padRight('Candidates:', 74)} │`)
445
+ for (const candidate of state.candidates.slice(0, 8)) {
446
+ lines.push(`│ ${this.padRight(` ${candidate}`, 74)} │`)
447
+ }
448
+ }
449
+ lines.push('│ │')
450
+ lines.push(`│ ${this.padRight('Tab: complete Enter: confirm Esc: cancel', 74)} │`)
451
+ lines.push('╰──────────────────────────────────────────────────────────────────────────╯')
452
+ return lines
453
+ }
454
+
455
+ lines.push(`│ ${this.padRight('Error', 74)} │`)
456
+ for (const line of state.message.split('\n')) {
457
+ lines.push(`│ ${this.padRight(line, 74)} │`)
458
+ }
459
+ lines.push('│ │')
460
+ lines.push(`│ ${this.padRight('Enter/Esc/q: close', 74)} │`)
461
+ lines.push('╰──────────────────────────────────────────────────────────────────────────╯')
462
+ return lines
463
+ }
464
+
465
+ private render(): void {
466
+ if (!this.visible) return
467
+ this.terminal.render([...this.buildMainLines(), ...this.buildPromptLines()].join('\n'))
468
+ }
469
+
401
470
  show(): void {
402
- this.listBox.show()
403
- this.listBox.focus()
471
+ this.visible = true
404
472
  this.syncList()
405
- this.screen.render()
473
+ this.render()
406
474
  }
407
475
 
408
476
  isPromptOpen(): boolean {
409
- return this.promptOpen
477
+ return this.promptState != null
410
478
  }
411
479
 
412
480
  resizeSelectedSession(): void {
413
481
  this.syncList()
482
+ this.render()
414
483
  }
415
484
 
416
485
  hide(): void {
417
- this.listBox.hide()
486
+ this.visible = false
418
487
  }
419
488
  }