@k1e1n04/mav 0.1.18 → 0.1.20

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,59 +1,54 @@
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 HORIZONTAL_MARGIN = 1
15
+
16
+ private static readonly ANSI = {
17
+ reset: '\x1b[0m',
18
+ bold: '\x1b[1m',
19
+ dim: '\x1b[2m',
20
+ fgSlate: '\x1b[38;5;245m',
21
+ fgGreen: '\x1b[38;5;42m',
22
+ fgAmber: '\x1b[38;5;221m',
23
+ fgCyan: '\x1b[38;5;81m',
24
+ fgRed: '\x1b[38;5;203m',
25
+ } as const
26
+
9
27
  private static readonly STATUS_GROUPS = [
10
28
  { status: 'running', label: 'Working' },
11
29
  { status: 'idle', label: 'Waiting' },
12
30
  { status: 'done', label: 'Complete' },
13
31
  { status: 'error', label: 'Failed' },
14
32
  ] as const
15
- private static readonly STATUS_COLORS = {
16
- running: 'cyan',
17
- idle: 'yellow',
18
- done: 'green',
19
- error: 'red',
20
- } as const
21
33
 
22
- private screen: Widgets.Screen
34
+ private static readonly AGENT_TYPES = ['claude-code', 'codex', 'gemini-cli', 'copilot'] as const
35
+
36
+ private terminal: TerminalUI
23
37
  private manager: SessionManager
24
38
  private onSessionCreated?: (session: SessionManager['selectedSession']) => void
25
- private listBox: Widgets.ListElement
26
- private promptOpen = false
39
+ private promptState: PromptState = null
40
+ private displaySessionIds: string[] = []
41
+ private visible = false
27
42
 
28
43
  constructor(
29
- screen: Widgets.Screen,
44
+ terminal: TerminalUI,
30
45
  manager: SessionManager,
31
46
  onSessionCreated?: (session: SessionManager['selectedSession']) => void
32
47
  ) {
33
- this.screen = screen
48
+ this.terminal = terminal
34
49
  this.manager = manager
35
50
  this.onSessionCreated = onSessionCreated
36
51
 
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 を設定すると blessed 組み込みの up()/down() もカスタムハンドラーと
51
- // 同時に実行され、selected がヘッダー行(cwd名やステータスグループ名)に一時的に
52
- // 移動してしまう競合が起きる。矢印キーはカスタムハンドラーのみで処理する。
53
- mouse: true,
54
- })
55
-
56
- this.bindKeys()
57
52
  this.syncList()
58
53
 
59
54
  manager.on('data', (sessionId: string) => {
@@ -61,184 +56,152 @@ export class OverviewUI {
61
56
  return
62
57
  }
63
58
  this.syncList()
64
- screen.render()
59
+ this.render()
65
60
  })
66
61
 
67
62
  manager.on('exit', () => {
68
63
  this.syncList()
69
- screen.render()
64
+ this.render()
70
65
  })
71
66
 
72
67
  manager.on('status', () => {
73
68
  this.syncList()
74
- screen.render()
69
+ this.render()
75
70
  })
76
71
 
77
72
  manager.on('name', () => {
78
73
  this.syncList()
79
- screen.render()
74
+ this.render()
80
75
  })
81
76
 
82
- // removeSession() は emitSelection() を呼ぶが、OverviewUI は 'selection' を
83
- // listen していないため、index.ts の exit ハンドラ経由で自動削除された場合に
84
- // UI が再描画されない。'selection' を listen して確実に同期する。
85
77
  manager.on('selection', () => {
86
78
  this.syncList()
87
- screen.render()
79
+ this.render()
88
80
  })
89
81
  }
90
82
 
91
- private bindKeys(): void {
92
- this.listBox.key(['up', 'k'], () => {
93
- if (this.manager.sessions.length === 0) return
83
+ handleKeypress(str: string, key: KeyInfo): void {
84
+ if (this.promptState?.mode === 'agent') {
85
+ this.handleAgentPromptKeypress(key)
86
+ return
87
+ }
88
+ if (this.promptState?.mode === 'cwd') {
89
+ this.handleCwdPromptKeypress(key)
90
+ return
91
+ }
92
+ if (this.promptState?.mode === 'error') {
93
+ if (key.name === 'enter' || key.name === 'return' || key.name === 'escape' || str === 'q') {
94
+ this.promptState = null
95
+ this.render()
96
+ }
97
+ return
98
+ }
99
+
100
+ if ((key.name === 'up' || str === 'k') && this.manager.sessions.length > 0) {
94
101
  this.moveSelection(-1)
95
- // syncList() と screen.render() は moveSelection() → selectSession() →
96
- // emitSelection() → 'selection' イベント経由で自動的に呼ばれる
97
- })
102
+ return
103
+ }
98
104
 
99
- this.listBox.key(['down', 'j'], () => {
100
- if (this.manager.sessions.length === 0) return
105
+ if ((key.name === 'down' || str === 'j') && this.manager.sessions.length > 0) {
101
106
  this.moveSelection(1)
102
- // syncList() と screen.render() は moveSelection() → selectSession() →
103
- // emitSelection() → 'selection' イベント経由で自動的に呼ばれる
104
- })
107
+ return
108
+ }
105
109
 
106
- this.listBox.key('n', () => {
110
+ if (str === 'n') {
107
111
  this.showAddPrompt()
108
- })
112
+ return
113
+ }
109
114
 
110
- this.listBox.key(['d', 'C-x'], () => {
115
+ if (str === 'd' || (key.ctrl && key.name === 'x')) {
111
116
  const session = this.manager.selectedSession
112
117
  if (session) {
113
118
  this.manager.removeSession(session.id)
114
119
  this.syncList()
115
- this.screen.render()
120
+ this.render()
116
121
  }
117
- })
122
+ }
118
123
  }
119
124
 
120
- private showAddPrompt(): void {
121
- if (this.promptOpen) return
122
- this.promptOpen = true
123
-
124
- const agentTypes = ['claude-code', 'codex', 'gemini-cli', 'copilot']
125
-
126
- const prompt = blessed.list({
127
- parent: this.screen,
128
- top: 'center',
129
- left: 'center',
130
- width: 40,
131
- height: agentTypes.length + 4,
132
- border: { type: 'line' },
133
- label: ' Select agent type ',
134
- items: agentTypes,
135
- keys: true,
136
- style: {
137
- selected: { bg: 'blue', fg: 'white' },
138
- border: { fg: 'green' },
139
- },
140
- })
125
+ private handleAgentPromptKeypress(key: KeyInfo): void {
126
+ const state = this.promptState
127
+ if (!state || state.mode !== 'agent') return
141
128
 
142
- const close = () => {
143
- this.promptOpen = false
144
- prompt.destroy()
145
- this.listBox.focus()
146
- this.screen.render()
129
+ if (key.name === 'escape') {
130
+ this.promptState = null
131
+ this.render()
132
+ return
147
133
  }
148
134
 
149
- prompt.key('enter', () => {
150
- const selectedIdx = (prompt as unknown as { selected: number }).selected ?? 0
151
- const selected = agentTypes[selectedIdx]!
152
- prompt.destroy()
153
- this.screen.render()
154
- this.showCwdPrompt(selected)
155
- })
135
+ if (key.name === 'up') {
136
+ state.selectedIndex =
137
+ (state.selectedIndex - 1 + OverviewUI.AGENT_TYPES.length) % OverviewUI.AGENT_TYPES.length
138
+ this.render()
139
+ return
140
+ }
156
141
 
157
- prompt.key('escape', close)
142
+ if (key.name === 'down') {
143
+ state.selectedIndex = (state.selectedIndex + 1) % OverviewUI.AGENT_TYPES.length
144
+ this.render()
145
+ return
146
+ }
158
147
 
159
- prompt.focus()
160
- this.screen.render()
148
+ if (key.name === 'enter' || key.name === 'return') {
149
+ const agentType = OverviewUI.AGENT_TYPES[state.selectedIndex]!
150
+ this.promptState = {
151
+ mode: 'cwd',
152
+ agentType,
153
+ value: process.cwd(),
154
+ candidates: [],
155
+ }
156
+ this.render()
157
+ }
161
158
  }
162
159
 
163
- private showCwdPrompt(agentType: string): void {
164
- let value = process.cwd()
165
- let candidateList: Widgets.ListElement | null = null
160
+ private handleCwdPromptKeypress(key: KeyInfo): void {
161
+ const state = this.promptState
162
+ if (!state || state.mode !== 'cwd') return
166
163
 
167
- const box = blessed.box({
168
- parent: this.screen,
169
- top: 'center',
170
- left: 'center',
171
- width: 60,
172
- height: 3,
173
- border: { type: 'line' },
174
- label: ' cwd Tab:補完 Enter:確定 Esc:キャンセル ',
175
- style: { border: { fg: 'green' } },
176
- })
164
+ if (key.name === 'escape') {
165
+ this.promptState = null
166
+ this.render()
167
+ return
168
+ }
177
169
 
178
- const renderInput = () => {
179
- box.setContent(value)
180
- this.screen.render()
170
+ if (key.name === 'enter' || key.name === 'return') {
171
+ const expanded = state.value.trim().replace(/^~/, process.env.HOME ?? '~') || process.cwd()
172
+ const agentType = state.agentType
173
+ this.promptState = null
174
+ this.render()
175
+ this.spawnAgent(agentType, expanded)
176
+ return
181
177
  }
182
178
 
183
- const closeCandidates = () => {
184
- if (candidateList) {
185
- candidateList.destroy()
186
- candidateList = null
187
- }
179
+ if (key.name === 'tab') {
180
+ const { completed, candidates } = completePath(state.value)
181
+ state.value = completed
182
+ state.candidates = candidates
183
+ this.render()
184
+ return
188
185
  }
189
186
 
190
- const cleanup = () => {
191
- this.screen.removeListener('keypress', onKeypress)
192
- closeCandidates()
193
- this.promptOpen = false
194
- box.destroy()
195
- this.listBox.focus()
196
- this.screen.render()
197
- }
198
-
199
- const onKeypress = (_ch: string, key: { name: string; ctrl?: boolean; meta?: boolean; sequence?: string }) => {
200
- if (!key) return
201
- closeCandidates()
202
-
203
- if (key.name === 'enter' || key.name === 'return') {
204
- const expanded = value.trim().replace(/^~/, process.env.HOME ?? '~') || process.cwd()
205
- cleanup()
206
- this.spawnAgent(agentType, expanded)
207
- } else if (key.name === 'escape') {
208
- cleanup()
209
- } else if (key.name === 'tab') {
210
- const { completed, candidates } = completePath(value)
211
- value = completed
212
- if (candidates.length > 1) {
213
- const maxVisible = Math.min(candidates.length, 8)
214
- candidateList = blessed.list({
215
- parent: this.screen,
216
- top: '50%',
217
- left: 'center',
218
- width: 60,
219
- height: maxVisible + 2,
220
- border: { type: 'line' },
221
- items: candidates,
222
- keys: false,
223
- style: { border: { fg: 'cyan' } },
224
- }) as Widgets.ListElement
225
- }
226
- renderInput()
227
- } else if (key.name === 'backspace') {
228
- value = value.slice(0, -1)
229
- renderInput()
230
- } else if (!key.ctrl && !key.meta && key.sequence?.length === 1) {
231
- value += key.sequence
232
- renderInput()
233
- }
187
+ state.candidates = []
188
+
189
+ if (key.name === 'backspace') {
190
+ state.value = state.value.slice(0, -1)
191
+ this.render()
192
+ return
234
193
  }
235
194
 
236
- // 現在のキーイベント(enter)がこのリスナーに届かないよう1tick遅らせる
237
- setImmediate(() => {
238
- this.screen.on('keypress', onKeypress)
239
- })
240
- box.focus()
241
- renderInput()
195
+ if (!key.ctrl && !key.meta && key.sequence?.length === 1) {
196
+ state.value += key.sequence
197
+ this.render()
198
+ }
199
+ }
200
+
201
+ private showAddPrompt(): void {
202
+ if (this.promptState) return
203
+ this.promptState = { mode: 'agent', selectedIndex: 0 }
204
+ this.render()
242
205
  }
243
206
 
244
207
  private spawnAgent(agentType: string, cwd: string): void {
@@ -268,66 +231,13 @@ export class OverviewUI {
268
231
  }
269
232
 
270
233
  private showError(message: string): void {
271
- const overlay = blessed.box({
272
- parent: this.screen,
273
- top: 'center',
274
- left: 'center',
275
- width: 50,
276
- height: message.split('\n').length + 4,
277
- border: { type: 'line' },
278
- label: ' Error ',
279
- content: `\n ${message.split('\n').join('\n ')}`,
280
- style: { border: { fg: 'red' }, label: { fg: 'red' } },
281
- keys: true,
282
- mouse: true,
283
- })
284
- overlay.key(['enter', 'escape', 'q'], () => {
285
- overlay.destroy()
286
- this.listBox.focus()
287
- this.screen.render()
288
- })
289
- overlay.focus()
290
- this.screen.render()
234
+ this.promptState = { mode: 'error', message }
235
+ this.render()
291
236
  }
292
237
 
293
238
  private syncList(): void {
294
239
  const orderedSessions = this.getOrderedSessions()
295
-
296
- const items: string[] = []
297
- const selectedId = this.manager.selectedSession?.id
298
- let selectedDisplayIndex = -1
299
-
300
- const cwdGroups = this.groupByCwd(orderedSessions)
301
- for (const [cwd, sessions] of cwdGroups) {
302
- items.push(` {bold}${this.shortenPath(cwd)}{/bold}`)
303
-
304
- for (const group of OverviewUI.STATUS_GROUPS) {
305
- const groupSessions = sessions.filter((s) => s.status === group.status)
306
- if (groupSessions.length === 0) continue
307
-
308
- items.push(` ${group.label}`)
309
- for (const session of groupSessions) {
310
- const statusIcon =
311
- session.status === 'running' ? '⣾'
312
- : session.status === 'idle' ? '○'
313
- : session.status === 'done' ? '✓'
314
- : '✗'
315
- const sessionTitle = session.displayName ?? session.id
316
- const sessionLabel = session.type ? `${sessionTitle} (${session.type})` : sessionTitle
317
- const content = `${statusIcon} ${sessionLabel} ${this.getStatusLabel(session.status)}`
318
- const color = OverviewUI.STATUS_COLORS[session.status]
319
- items.push(` {${color}-fg}${content}{/${color}-fg}`)
320
- if (session.id === selectedId) {
321
- selectedDisplayIndex = items.length - 1
322
- }
323
- }
324
- }
325
- }
326
-
327
- this.listBox.setItems(items)
328
- if (selectedDisplayIndex >= 0) {
329
- this.listBox.select(selectedDisplayIndex)
330
- }
240
+ this.displaySessionIds = orderedSessions.map((session) => session.id)
331
241
  }
332
242
 
333
243
  private groupByCwd(sessions: SessionManager['sessions']): Map<string, SessionManager['sessions']> {
@@ -361,18 +271,17 @@ export class OverviewUI {
361
271
  }
362
272
 
363
273
  private moveSelection(direction: -1 | 1): void {
364
- const orderedSessions = this.getOrderedSessions()
365
274
  const selectedId = this.manager.selectedSession?.id
366
- const currentIndex = selectedId
367
- ? orderedSessions.findIndex((session) => session.id === selectedId)
368
- : -1
275
+ if (this.displaySessionIds.length === 0) return
276
+
277
+ const currentIndex = selectedId ? this.displaySessionIds.indexOf(selectedId) : -1
369
278
  const nextIndex = currentIndex === -1
370
279
  ? 0
371
- : Math.max(0, Math.min(orderedSessions.length - 1, currentIndex + direction))
372
- const nextSession = orderedSessions[nextIndex]
373
- if (!nextSession) return
280
+ : (currentIndex + direction + this.displaySessionIds.length) % this.displaySessionIds.length
281
+ const nextSessionId = this.displaySessionIds[nextIndex]
282
+ if (!nextSessionId) return
374
283
 
375
- const managerIndex = this.manager.sessions.findIndex((session) => session.id === nextSession.id)
284
+ const managerIndex = this.manager.sessions.findIndex((session) => session.id === nextSessionId)
376
285
  if (managerIndex >= 0) {
377
286
  this.manager.selectSession(managerIndex)
378
287
  }
@@ -408,22 +317,270 @@ export class OverviewUI {
408
317
  }
409
318
  }
410
319
 
320
+ private countByStatus(status: string): number {
321
+ return this.manager.sessions.filter((session) => session.status === status).length
322
+ }
323
+
324
+ private stripAnsi(value: string): string {
325
+ return value.replace(/\x1b\[[0-9;]*m/g, '')
326
+ }
327
+
328
+ private visibleLength(value: string): number {
329
+ return this.stripAnsi(value).length
330
+ }
331
+
332
+ private getRenderWidth(): number {
333
+ return Math.max(20, this.terminal.cols || 80)
334
+ }
335
+
336
+ private getContentWidth(): number {
337
+ return Math.max(10, this.getRenderWidth() - (OverviewUI.HORIZONTAL_MARGIN * 2))
338
+ }
339
+
340
+ private insetLine(line: string): string {
341
+ if (line.length === 0) return ''
342
+ return `${' '.repeat(OverviewUI.HORIZONTAL_MARGIN)}${line}`
343
+ }
344
+
345
+ private finalizeLines(lines: string[]): string[] {
346
+ const maxWidth = this.getRenderWidth()
347
+ return lines.map((line) => {
348
+ const inset = this.insetLine(line)
349
+ return this.fitVisible(inset, maxWidth)
350
+ })
351
+ }
352
+
353
+ private padRight(value: string, width: number): string {
354
+ const visibleLength = this.visibleLength(value)
355
+ if (visibleLength >= width) {
356
+ return value
357
+ }
358
+ return value + ' '.repeat(width - visibleLength)
359
+ }
360
+
361
+ private fitPlain(value: string, width: number): string {
362
+ if (width <= 0) return ''
363
+ if (value.length <= width) return value
364
+ if (width === 1) return '…'
365
+ return `${value.slice(0, width - 1)}…`
366
+ }
367
+
368
+ private fitVisible(value: string, width: number): string {
369
+ const plain = this.stripAnsi(value)
370
+ return plain.length <= width ? value : this.fitPlain(plain, width)
371
+ }
372
+
373
+ private color(text: string, ...codes: string[]): string {
374
+ return `${codes.join('')}${text}${OverviewUI.ANSI.reset}`
375
+ }
376
+
377
+ private getStatusColor(status: string): string {
378
+ switch (status) {
379
+ case 'running':
380
+ return OverviewUI.ANSI.fgGreen
381
+ case 'idle':
382
+ return OverviewUI.ANSI.fgAmber
383
+ case 'done':
384
+ return OverviewUI.ANSI.fgCyan
385
+ case 'error':
386
+ return OverviewUI.ANSI.fgRed
387
+ default:
388
+ return OverviewUI.ANSI.fgSlate
389
+ }
390
+ }
391
+
392
+ private makeRule(label: string, width = this.getRenderWidth()): string {
393
+ const text = ` ${label} `
394
+ if (text.length >= width) {
395
+ return this.fitPlain(text, width)
396
+ }
397
+ const fill = '─'.repeat(width - text.length)
398
+ return text + fill
399
+ }
400
+
401
+ private wrapPlainText(text: string, width: number): string[] {
402
+ if (width <= 0) return ['']
403
+ const words = text.split(/\s+/).filter(Boolean)
404
+ if (words.length === 0) return ['']
405
+
406
+ const lines: string[] = []
407
+ let current = ''
408
+ for (const word of words) {
409
+ const next = current ? `${current} ${word}` : word
410
+ if (next.length <= width) {
411
+ current = next
412
+ continue
413
+ }
414
+
415
+ if (current) {
416
+ lines.push(current)
417
+ current = ''
418
+ }
419
+
420
+ if (word.length <= width) {
421
+ current = word
422
+ } else {
423
+ lines.push(this.fitPlain(word, width))
424
+ }
425
+ }
426
+
427
+ if (current) {
428
+ lines.push(current)
429
+ }
430
+
431
+ return lines
432
+ }
433
+
434
+ private wrapVisibleParts(parts: string[], width: number): string[] {
435
+ if (width <= 0) return ['']
436
+
437
+ const lines: string[] = []
438
+ let current = ''
439
+ for (const part of parts) {
440
+ const next = current ? `${current} · ${part}` : part
441
+ if (this.visibleLength(next) <= width) {
442
+ current = next
443
+ continue
444
+ }
445
+
446
+ if (current) {
447
+ lines.push(current)
448
+ }
449
+
450
+ current = this.visibleLength(part) <= width ? part : this.fitVisible(part, width)
451
+ }
452
+
453
+ if (current) {
454
+ lines.push(current)
455
+ }
456
+
457
+ return lines
458
+ }
459
+
460
+ private formatSummaryLines(width: number): string[] {
461
+ const parts = OverviewUI.STATUS_GROUPS.map((group) => {
462
+ const count = this.countByStatus(group.status)
463
+ return this.color(`${group.label} ${count}`, OverviewUI.ANSI.bold, this.getStatusColor(group.status))
464
+ })
465
+ return this.wrapVisibleParts(parts, width)
466
+ }
467
+
468
+ private buildMainLines(): string[] {
469
+ const orderedSessions = this.getOrderedSessions()
470
+ const selectedId = this.manager.selectedSession?.id
471
+ const width = this.getContentWidth()
472
+ const lines = [
473
+ this.color(this.makeRule('mav overview', width), OverviewUI.ANSI.dim),
474
+ 'AGENTS',
475
+ ...this.formatSummaryLines(width),
476
+ '',
477
+ ]
478
+
479
+ const cwdGroups = this.groupByCwd(orderedSessions)
480
+ for (const [cwd, sessions] of cwdGroups) {
481
+ lines.push(this.color(this.makeRule(this.shortenPath(cwd)), OverviewUI.ANSI.dim))
482
+
483
+ for (const group of OverviewUI.STATUS_GROUPS) {
484
+ const groupSessions = sessions.filter((s) => s.status === group.status)
485
+ if (groupSessions.length === 0) continue
486
+
487
+ lines.push(this.color(` ${group.label} (${groupSessions.length})`, OverviewUI.ANSI.bold, this.getStatusColor(group.status)))
488
+ for (const session of groupSessions) {
489
+ const color = this.getStatusColor(session.status)
490
+ const statusIcon =
491
+ session.status === 'running' ? '⣾'
492
+ : session.status === 'idle' ? '○'
493
+ : session.status === 'done' ? '✓'
494
+ : '✗'
495
+ const sessionTitle = session.displayName ?? session.id
496
+ const sessionLabel = session.type ? `${sessionTitle} (${session.type})` : sessionTitle
497
+ const prefix = session.id === selectedId ? '> ' : ' '
498
+ const sessionLine = this.fitPlain(
499
+ `${prefix}${statusIcon} ${sessionLabel} ${this.getStatusLabel(session.status)}`,
500
+ width
501
+ )
502
+ lines.push(session.id === selectedId
503
+ ? this.color(sessionLine, OverviewUI.ANSI.bold, color)
504
+ : this.color(sessionLine, color))
505
+ }
506
+
507
+ lines.push('')
508
+ }
509
+ }
510
+
511
+ if (orderedSessions.length === 0) {
512
+ lines.push(this.color(this.makeRule('empty', width), OverviewUI.ANSI.dim))
513
+ lines.push(this.fitPlain(' No sessions. Press n to add one.', width))
514
+ lines.push('')
515
+ }
516
+
517
+ lines.push(this.color(this.makeRule('controls', width), OverviewUI.ANSI.dim))
518
+ lines.push(...this.wrapPlainText('↑/↓ or j/k move Enter detail Ctrl+] back n new d delete q quit', width))
519
+ return this.finalizeLines(lines)
520
+ }
521
+
522
+ private buildPromptLines(): string[] {
523
+ const state = this.promptState
524
+ if (!state) return []
525
+
526
+ const width = this.getContentWidth()
527
+ const lines = ['', this.color(this.makeRule('prompt', width), OverviewUI.ANSI.dim)]
528
+ if (state.mode === 'agent') {
529
+ lines.push(this.fitPlain('Select agent type', width))
530
+ lines.push('')
531
+ for (const [index, agentType] of OverviewUI.AGENT_TYPES.entries()) {
532
+ lines.push(this.fitPlain(`${index === state.selectedIndex ? '> ' : ' '}${agentType}`, width))
533
+ }
534
+ lines.push('')
535
+ lines.push(...this.wrapPlainText('Enter: select Esc: cancel', width))
536
+ return this.finalizeLines(lines)
537
+ }
538
+
539
+ if (state.mode === 'cwd') {
540
+ lines.push(this.fitPlain(`cwd for ${state.agentType}`, width))
541
+ lines.push(this.fitPlain(state.value, width))
542
+ if (state.candidates.length > 1) {
543
+ lines.push('')
544
+ lines.push(this.fitPlain('Candidates:', width))
545
+ for (const candidate of state.candidates.slice(0, 8)) {
546
+ lines.push(this.fitPlain(` ${candidate}`, width))
547
+ }
548
+ }
549
+ lines.push('')
550
+ lines.push(...this.wrapPlainText('Tab: complete Enter: confirm Esc: cancel', width))
551
+ return this.finalizeLines(lines)
552
+ }
553
+
554
+ lines.push(this.fitPlain('Error', width))
555
+ for (const line of state.message.split('\n')) {
556
+ lines.push(this.fitPlain(line, width))
557
+ }
558
+ lines.push('')
559
+ lines.push(...this.wrapPlainText('Enter/Esc/q: close', width))
560
+ return this.finalizeLines(lines)
561
+ }
562
+
563
+ private render(): void {
564
+ if (!this.visible) return
565
+ this.terminal.render([...this.buildMainLines(), ...this.buildPromptLines()].join('\n'))
566
+ }
567
+
411
568
  show(): void {
412
- this.listBox.show()
413
- this.listBox.focus()
569
+ this.visible = true
414
570
  this.syncList()
415
- this.screen.render()
571
+ this.render()
416
572
  }
417
573
 
418
574
  isPromptOpen(): boolean {
419
- return this.promptOpen
575
+ return this.promptState != null
420
576
  }
421
577
 
422
578
  resizeSelectedSession(): void {
423
579
  this.syncList()
580
+ this.render()
424
581
  }
425
582
 
426
583
  hide(): void {
427
- this.listBox.hide()
584
+ this.visible = false
428
585
  }
429
586
  }