@k1e1n04/mav 0.1.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 +212 -0
- package/bin/mav.ts +31 -0
- package/dist/bin/mav.d.ts +2 -0
- package/dist/bin/mav.js +24 -0
- package/dist/bin/mav.js.map +1 -0
- package/dist/src/agent-launch.d.ts +15 -0
- package/dist/src/agent-launch.js +36 -0
- package/dist/src/agent-launch.js.map +1 -0
- package/dist/src/agent.d.ts +42 -0
- package/dist/src/agent.js +209 -0
- package/dist/src/agent.js.map +1 -0
- package/dist/src/config.d.ts +11 -0
- package/dist/src/config.js +32 -0
- package/dist/src/config.js.map +1 -0
- package/dist/src/current-session.d.ts +15 -0
- package/dist/src/current-session.js +17 -0
- package/dist/src/current-session.js.map +1 -0
- package/dist/src/index.d.ts +6 -0
- package/dist/src/index.js +90 -0
- package/dist/src/index.js.map +1 -0
- package/dist/src/process-cwd.d.ts +1 -0
- package/dist/src/process-cwd.js +24 -0
- package/dist/src/process-cwd.js.map +1 -0
- package/dist/src/session-manager.d.ts +16 -0
- package/dist/src/session-manager.js +102 -0
- package/dist/src/session-manager.js.map +1 -0
- package/dist/src/state.d.ts +19 -0
- package/dist/src/state.js +30 -0
- package/dist/src/state.js.map +1 -0
- package/dist/src/ui/app.d.ts +15 -0
- package/dist/src/ui/app.js +109 -0
- package/dist/src/ui/app.js.map +1 -0
- package/dist/src/ui/detail.d.ts +28 -0
- package/dist/src/ui/detail.js +137 -0
- package/dist/src/ui/detail.js.map +1 -0
- package/dist/src/ui/overview.d.ts +24 -0
- package/dist/src/ui/overview.js +267 -0
- package/dist/src/ui/overview.js.map +1 -0
- package/lua/mav/follow.lua +37 -0
- package/lua/mav/init.lua +92 -0
- package/lua/mav/state.lua +22 -0
- package/package.json +54 -0
- package/plugin/mav.lua +24 -0
- package/src/agent-launch.ts +43 -0
- package/src/agent.ts +243 -0
- package/src/config.ts +51 -0
- package/src/current-session.ts +34 -0
- package/src/index.ts +121 -0
- package/src/process-cwd.ts +31 -0
- package/src/session-manager.ts +122 -0
- package/src/state.ts +49 -0
- package/src/types/neo-blessed.d.ts +5 -0
- package/src/ui/app.ts +121 -0
- package/src/ui/detail.ts +164 -0
- package/src/ui/overview.ts +306 -0
package/src/ui/app.ts
ADDED
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
import blessed from 'neo-blessed'
|
|
2
|
+
import type { Widgets } from 'neo-blessed'
|
|
3
|
+
import type { AgentSession } from '../agent.js'
|
|
4
|
+
import type { SessionManager } from '../session-manager.js'
|
|
5
|
+
import { saveState } from '../state.js'
|
|
6
|
+
import { OverviewUI } from './overview.js'
|
|
7
|
+
import { DetailUI } from './detail.js'
|
|
8
|
+
|
|
9
|
+
type Mode = 'overview' | 'detail'
|
|
10
|
+
|
|
11
|
+
export class App {
|
|
12
|
+
private screen: Widgets.Screen
|
|
13
|
+
private manager: SessionManager
|
|
14
|
+
private statePath: string
|
|
15
|
+
private overviewUI: OverviewUI
|
|
16
|
+
private detailUI: DetailUI
|
|
17
|
+
private mode: Mode = 'overview'
|
|
18
|
+
|
|
19
|
+
constructor(manager: SessionManager, statePath: string) {
|
|
20
|
+
this.manager = manager
|
|
21
|
+
this.statePath = statePath
|
|
22
|
+
|
|
23
|
+
this.screen = blessed.screen({
|
|
24
|
+
smartCSR: true,
|
|
25
|
+
title: 'mav',
|
|
26
|
+
fullUnicode: true,
|
|
27
|
+
})
|
|
28
|
+
|
|
29
|
+
this.overviewUI = new OverviewUI(this.screen, manager, (session) => {
|
|
30
|
+
if (session) {
|
|
31
|
+
this.switchToDetail(session)
|
|
32
|
+
}
|
|
33
|
+
})
|
|
34
|
+
this.detailUI = new DetailUI(this.screen, () => {
|
|
35
|
+
if (this.mode === 'detail') {
|
|
36
|
+
this.switchToOverview()
|
|
37
|
+
}
|
|
38
|
+
})
|
|
39
|
+
|
|
40
|
+
const proto = Object.getPrototypeOf(this.screen) as { render?: () => void }
|
|
41
|
+
const baseRender = proto.render ?? this.screen.render
|
|
42
|
+
const protoRender = baseRender.bind(this.screen)
|
|
43
|
+
;(this.screen as unknown as { render: () => void }).render = () => {
|
|
44
|
+
if (this.mode === 'detail') return
|
|
45
|
+
protoRender()
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
this.bindGlobalKeys()
|
|
49
|
+
|
|
50
|
+
this.screen.on('resize', () => {
|
|
51
|
+
const cols = this.screen.width as number
|
|
52
|
+
const rows = this.screen.height as number
|
|
53
|
+
if (this.mode === 'detail') {
|
|
54
|
+
this.detailUI.resize(cols, rows)
|
|
55
|
+
return
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
this.overviewUI.resizeSelectedSession()
|
|
59
|
+
})
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
private bindGlobalKeys(): void {
|
|
63
|
+
this.screen.key('q', () => {
|
|
64
|
+
if (this.mode === 'detail') return
|
|
65
|
+
this.shutdown()
|
|
66
|
+
})
|
|
67
|
+
|
|
68
|
+
this.screen.key('C-c', () => {
|
|
69
|
+
if (this.mode === 'overview') {
|
|
70
|
+
this.shutdown()
|
|
71
|
+
}
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
this.screen.key(['right', 'enter'], () => {
|
|
75
|
+
if (this.mode !== 'overview') return
|
|
76
|
+
if (this.overviewUI.isPromptOpen()) return
|
|
77
|
+
const session = this.manager.selectedSession
|
|
78
|
+
if (!session) return
|
|
79
|
+
this.switchToDetail()
|
|
80
|
+
})
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
private switchToDetail(session: AgentSession | null = this.manager.selectedSession): void {
|
|
84
|
+
if (!session) return
|
|
85
|
+
this.mode = 'detail'
|
|
86
|
+
this.overviewUI.hide()
|
|
87
|
+
this.screen.program.normalBuffer()
|
|
88
|
+
this.screen.program.disableMouse()
|
|
89
|
+
this.screen.realloc()
|
|
90
|
+
this.detailUI.attach(session)
|
|
91
|
+
this.detailUI.resize(this.screen.width as number, this.screen.height as number)
|
|
92
|
+
this.detailUI.show()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
private switchToOverview(): void {
|
|
96
|
+
this.mode = 'overview'
|
|
97
|
+
this.detailUI.detach()
|
|
98
|
+
this.detailUI.hide()
|
|
99
|
+
this.screen.program.alternateBuffer()
|
|
100
|
+
this.screen.program.enableMouse()
|
|
101
|
+
this.screen.realloc()
|
|
102
|
+
this.overviewUI.show()
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
private shutdown(): void {
|
|
106
|
+
try {
|
|
107
|
+
saveState(this.statePath, this.manager)
|
|
108
|
+
} catch {
|
|
109
|
+
// 終了シーケンスは継続する
|
|
110
|
+
}
|
|
111
|
+
this.manager.killAll()
|
|
112
|
+
this.screen.destroy()
|
|
113
|
+
process.exit(0)
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
start(): void {
|
|
117
|
+
this.overviewUI.show()
|
|
118
|
+
this.overviewUI.resizeSelectedSession()
|
|
119
|
+
this.screen.render()
|
|
120
|
+
}
|
|
121
|
+
}
|
package/src/ui/detail.ts
ADDED
|
@@ -0,0 +1,164 @@
|
|
|
1
|
+
import { appendFileSync } from 'node:fs'
|
|
2
|
+
import type { Widgets } from 'neo-blessed'
|
|
3
|
+
import type { AgentSession } from '../agent.js'
|
|
4
|
+
|
|
5
|
+
export class DetailUI {
|
|
6
|
+
private static readonly KEYBOARD_FLAGS_RESPONSE_PATTERN = /\x1b\[\?(\d+)u/g
|
|
7
|
+
private static readonly PENDING_INPUT_FLUSH_DELAY_MS = 25
|
|
8
|
+
|
|
9
|
+
private static readonly EXIT_SEQUENCES = [
|
|
10
|
+
'\x1d',
|
|
11
|
+
'\x1b[93;5u',
|
|
12
|
+
'\x1b[27;5;93~',
|
|
13
|
+
] as const
|
|
14
|
+
private static readonly EXIT_SEQUENCE_PATTERNS = [
|
|
15
|
+
/^\x1b\[93(?:::\d+)?;5(?::\d+)?u$/,
|
|
16
|
+
/^\x1b\[99;5u$/,
|
|
17
|
+
] as const
|
|
18
|
+
|
|
19
|
+
private screen: Widgets.Screen
|
|
20
|
+
private onExitDetail: () => void
|
|
21
|
+
private currentSession: AgentSession | null = null
|
|
22
|
+
private dataListener: ((data: string) => void) | null = null
|
|
23
|
+
private rawInputListener: ((chunk: unknown) => void) | null = null
|
|
24
|
+
private pendingInput = ''
|
|
25
|
+
private pendingInputTimer: ReturnType<typeof setTimeout> | null = null
|
|
26
|
+
private keyboardEnhancementFlags: string | null = null
|
|
27
|
+
|
|
28
|
+
constructor(screen: Widgets.Screen, onExitDetail: () => void) {
|
|
29
|
+
this.screen = screen
|
|
30
|
+
this.onExitDetail = onExitDetail
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
private isExitShortcut(input: string): boolean {
|
|
34
|
+
return (
|
|
35
|
+
DetailUI.EXIT_SEQUENCES.includes(
|
|
36
|
+
input as (typeof DetailUI.EXIT_SEQUENCES)[number]
|
|
37
|
+
) ||
|
|
38
|
+
DetailUI.EXIT_SEQUENCE_PATTERNS.some((pattern) => pattern.test(input))
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
private isExitShortcutPrefix(input: string): boolean {
|
|
43
|
+
return (
|
|
44
|
+
DetailUI.EXIT_SEQUENCES.some((sequence) => sequence.startsWith(input)) ||
|
|
45
|
+
'\x1b[93::92;5u'.startsWith(input) ||
|
|
46
|
+
'\x1b[93::92;5:3u'.startsWith(input) ||
|
|
47
|
+
'\x1b[99;5u'.startsWith(input)
|
|
48
|
+
)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
private debugLogInput(str: string): void {
|
|
52
|
+
const path = process.env.MAV_DEBUG_KEYS_PATH
|
|
53
|
+
if (!path) return
|
|
54
|
+
|
|
55
|
+
const hex = Buffer.from(str, 'utf8').toString('hex')
|
|
56
|
+
appendFileSync(path, `raw=${JSON.stringify(str)} hex=${hex}\n`)
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
private rememberKeyboardEnhancementFlags(str: string): void {
|
|
60
|
+
for (const match of str.matchAll(DetailUI.KEYBOARD_FLAGS_RESPONSE_PATTERN)) {
|
|
61
|
+
this.keyboardEnhancementFlags = match[1] ?? null
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
private clearPendingInputTimer(): void {
|
|
66
|
+
if (!this.pendingInputTimer) {
|
|
67
|
+
return
|
|
68
|
+
}
|
|
69
|
+
clearTimeout(this.pendingInputTimer)
|
|
70
|
+
this.pendingInputTimer = null
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
private flushPendingInput(): void {
|
|
74
|
+
if (!this.pendingInput) {
|
|
75
|
+
return
|
|
76
|
+
}
|
|
77
|
+
this.currentSession?.write(this.pendingInput)
|
|
78
|
+
this.pendingInput = ''
|
|
79
|
+
this.clearPendingInputTimer()
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
attach(session: AgentSession): void {
|
|
83
|
+
this.detach()
|
|
84
|
+
this.currentSession = session
|
|
85
|
+
this.pendingInput = ''
|
|
86
|
+
|
|
87
|
+
const { input, output } = this.screen.program
|
|
88
|
+
|
|
89
|
+
const safeLog = session.logBuffer
|
|
90
|
+
.join('')
|
|
91
|
+
.replace(/\x1b\[\?104[79][hl]|\x1b\[\?47[hl]/g, '')
|
|
92
|
+
.replace(/\x1b\[(?:>?\d*c|\?u|>q|\?\d+\$p)/g, '')
|
|
93
|
+
if (this.keyboardEnhancementFlags) {
|
|
94
|
+
output.write(`\x1b[=${this.keyboardEnhancementFlags}u`)
|
|
95
|
+
}
|
|
96
|
+
output.write('\x1b[?1l')
|
|
97
|
+
output.write('\x1b[H\x1b[2J')
|
|
98
|
+
output.write(safeLog)
|
|
99
|
+
|
|
100
|
+
this.dataListener = (data: string) => {
|
|
101
|
+
output.write(data)
|
|
102
|
+
}
|
|
103
|
+
session.on('data', this.dataListener)
|
|
104
|
+
|
|
105
|
+
this.rawInputListener = (chunk: unknown) => {
|
|
106
|
+
const str = Buffer.isBuffer(chunk)
|
|
107
|
+
? chunk.toString('utf8')
|
|
108
|
+
: typeof chunk === 'string'
|
|
109
|
+
? chunk
|
|
110
|
+
: ''
|
|
111
|
+
if (!str) return
|
|
112
|
+
this.debugLogInput(str)
|
|
113
|
+
this.rememberKeyboardEnhancementFlags(str)
|
|
114
|
+
if (this.isExitShortcut(str)) {
|
|
115
|
+
this.onExitDetail()
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
this.pendingInput += str
|
|
119
|
+
|
|
120
|
+
if (this.isExitShortcut(this.pendingInput)) {
|
|
121
|
+
this.clearPendingInputTimer()
|
|
122
|
+
this.pendingInput = ''
|
|
123
|
+
this.onExitDetail()
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (this.isExitShortcutPrefix(this.pendingInput)) {
|
|
128
|
+
this.clearPendingInputTimer()
|
|
129
|
+
this.pendingInputTimer = setTimeout(() => {
|
|
130
|
+
this.pendingInputTimer = null
|
|
131
|
+
this.flushPendingInput()
|
|
132
|
+
}, DetailUI.PENDING_INPUT_FLUSH_DELAY_MS)
|
|
133
|
+
return
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
this.flushPendingInput()
|
|
137
|
+
}
|
|
138
|
+
input.on('data', this.rawInputListener)
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
detach(): void {
|
|
142
|
+
if (this.currentSession && this.dataListener) {
|
|
143
|
+
this.currentSession.off('data', this.dataListener)
|
|
144
|
+
this.dataListener = null
|
|
145
|
+
}
|
|
146
|
+
if (this.rawInputListener) {
|
|
147
|
+
this.screen.program.input.removeListener('data', this.rawInputListener)
|
|
148
|
+
this.rawInputListener = null
|
|
149
|
+
}
|
|
150
|
+
if (this.keyboardEnhancementFlags) {
|
|
151
|
+
this.screen.program.output.write('\x1b[=0u')
|
|
152
|
+
}
|
|
153
|
+
this.clearPendingInputTimer()
|
|
154
|
+
this.pendingInput = ''
|
|
155
|
+
this.currentSession = null
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
show(): void {}
|
|
159
|
+
hide(): void {}
|
|
160
|
+
|
|
161
|
+
resize(cols: number, rows: number): void {
|
|
162
|
+
this.currentSession?.resize(cols, rows)
|
|
163
|
+
}
|
|
164
|
+
}
|
|
@@ -0,0 +1,306 @@
|
|
|
1
|
+
import blessed from 'neo-blessed'
|
|
2
|
+
import type { Widgets } from 'neo-blessed'
|
|
3
|
+
import type { SessionManager } from '../session-manager.js'
|
|
4
|
+
import type { AgentSession } from '../agent.js'
|
|
5
|
+
import { getAgentDefaults, resolveSessionArgs } from '../agent-launch.js'
|
|
6
|
+
|
|
7
|
+
export class OverviewUI {
|
|
8
|
+
private static readonly STATUS_GROUPS = [
|
|
9
|
+
{ status: 'running', label: 'Working' },
|
|
10
|
+
{ status: 'idle', label: 'Waiting' },
|
|
11
|
+
{ status: 'done', label: 'Complete' },
|
|
12
|
+
{ status: 'error', label: 'Failed' },
|
|
13
|
+
] as const
|
|
14
|
+
private static readonly STATUS_COLORS = {
|
|
15
|
+
running: 'cyan',
|
|
16
|
+
idle: 'yellow',
|
|
17
|
+
done: 'green',
|
|
18
|
+
error: 'red',
|
|
19
|
+
} as const
|
|
20
|
+
|
|
21
|
+
private screen: Widgets.Screen
|
|
22
|
+
private manager: SessionManager
|
|
23
|
+
private onSessionCreated?: (session: SessionManager['selectedSession']) => void
|
|
24
|
+
private listBox: Widgets.ListElement
|
|
25
|
+
private promptOpen = false
|
|
26
|
+
|
|
27
|
+
constructor(
|
|
28
|
+
screen: Widgets.Screen,
|
|
29
|
+
manager: SessionManager,
|
|
30
|
+
onSessionCreated?: (session: SessionManager['selectedSession']) => void
|
|
31
|
+
) {
|
|
32
|
+
this.screen = screen
|
|
33
|
+
this.manager = manager
|
|
34
|
+
this.onSessionCreated = onSessionCreated
|
|
35
|
+
|
|
36
|
+
this.listBox = blessed.list({
|
|
37
|
+
parent: screen,
|
|
38
|
+
top: 0,
|
|
39
|
+
left: 0,
|
|
40
|
+
width: '100%',
|
|
41
|
+
height: '100%',
|
|
42
|
+
border: { type: 'line' },
|
|
43
|
+
label: ' AGENTS ',
|
|
44
|
+
tags: true,
|
|
45
|
+
style: {
|
|
46
|
+
selected: { bg: 'blue', fg: 'white' },
|
|
47
|
+
border: { fg: 'cyan' },
|
|
48
|
+
},
|
|
49
|
+
keys: true,
|
|
50
|
+
mouse: true,
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
this.bindKeys()
|
|
54
|
+
this.syncList()
|
|
55
|
+
|
|
56
|
+
manager.on('data', (sessionId: string) => {
|
|
57
|
+
if (sessionId !== this.manager.selectedSession?.id) {
|
|
58
|
+
return
|
|
59
|
+
}
|
|
60
|
+
this.syncList()
|
|
61
|
+
screen.render()
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
manager.on('exit', () => {
|
|
65
|
+
this.syncList()
|
|
66
|
+
screen.render()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
manager.on('status', () => {
|
|
70
|
+
this.syncList()
|
|
71
|
+
screen.render()
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
manager.on('name', () => {
|
|
75
|
+
this.syncList()
|
|
76
|
+
screen.render()
|
|
77
|
+
})
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
private bindKeys(): void {
|
|
81
|
+
this.listBox.key(['up', 'k'], () => {
|
|
82
|
+
if (this.manager.sessions.length === 0) return
|
|
83
|
+
this.moveSelection(-1)
|
|
84
|
+
this.syncList()
|
|
85
|
+
this.screen.render()
|
|
86
|
+
})
|
|
87
|
+
|
|
88
|
+
this.listBox.key(['down', 'j'], () => {
|
|
89
|
+
if (this.manager.sessions.length === 0) return
|
|
90
|
+
this.moveSelection(1)
|
|
91
|
+
this.syncList()
|
|
92
|
+
this.screen.render()
|
|
93
|
+
})
|
|
94
|
+
|
|
95
|
+
this.listBox.key('n', () => {
|
|
96
|
+
this.showAddPrompt()
|
|
97
|
+
})
|
|
98
|
+
|
|
99
|
+
this.listBox.key(['d', 'C-x'], () => {
|
|
100
|
+
const session = this.manager.selectedSession
|
|
101
|
+
if (session) {
|
|
102
|
+
this.manager.removeSession(session.id)
|
|
103
|
+
this.syncList()
|
|
104
|
+
this.screen.render()
|
|
105
|
+
}
|
|
106
|
+
})
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
private showAddPrompt(): void {
|
|
110
|
+
if (this.promptOpen) return
|
|
111
|
+
this.promptOpen = true
|
|
112
|
+
|
|
113
|
+
const agentTypes = ['claude-code', 'codex', 'gemini-cli', 'copilot']
|
|
114
|
+
|
|
115
|
+
const prompt = blessed.list({
|
|
116
|
+
parent: this.screen,
|
|
117
|
+
top: 'center',
|
|
118
|
+
left: 'center',
|
|
119
|
+
width: 40,
|
|
120
|
+
height: agentTypes.length + 4,
|
|
121
|
+
border: { type: 'line' },
|
|
122
|
+
label: ' Select agent type ',
|
|
123
|
+
items: agentTypes,
|
|
124
|
+
keys: true,
|
|
125
|
+
style: {
|
|
126
|
+
selected: { bg: 'blue', fg: 'white' },
|
|
127
|
+
border: { fg: 'green' },
|
|
128
|
+
},
|
|
129
|
+
})
|
|
130
|
+
|
|
131
|
+
const close = () => {
|
|
132
|
+
this.promptOpen = false
|
|
133
|
+
prompt.destroy()
|
|
134
|
+
this.listBox.focus()
|
|
135
|
+
this.screen.render()
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
prompt.key('enter', () => {
|
|
139
|
+
const selectedIdx = (prompt as unknown as { selected: number }).selected ?? 0
|
|
140
|
+
const selected = agentTypes[selectedIdx]!
|
|
141
|
+
close()
|
|
142
|
+
|
|
143
|
+
const defaults = getAgentDefaults(selected)
|
|
144
|
+
const { args, newSessionId } = resolveSessionArgs(selected, defaults.args, undefined, false)
|
|
145
|
+
const session = this.manager.addSession({
|
|
146
|
+
type: selected,
|
|
147
|
+
cmd: defaults.cmd,
|
|
148
|
+
args,
|
|
149
|
+
cwd: process.cwd(),
|
|
150
|
+
}) as AgentSession & { sessionId?: string }
|
|
151
|
+
session.baseArgs = defaults.args
|
|
152
|
+
if (newSessionId != null) {
|
|
153
|
+
session.sessionId = newSessionId
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
if (session.status === 'error') {
|
|
157
|
+
this.manager.removeSession(session.id)
|
|
158
|
+
this.showError(`'${defaults.cmd}' command not found.\nIs ${selected} installed?`)
|
|
159
|
+
this.syncList()
|
|
160
|
+
return
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
this.manager.selectSession(this.manager.sessions.length - 1)
|
|
164
|
+
this.syncList()
|
|
165
|
+
this.onSessionCreated?.(session)
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
prompt.key('escape', close)
|
|
169
|
+
|
|
170
|
+
prompt.focus()
|
|
171
|
+
this.screen.render()
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
private showError(message: string): void {
|
|
175
|
+
const overlay = blessed.box({
|
|
176
|
+
parent: this.screen,
|
|
177
|
+
top: 'center',
|
|
178
|
+
left: 'center',
|
|
179
|
+
width: 50,
|
|
180
|
+
height: message.split('\n').length + 4,
|
|
181
|
+
border: { type: 'line' },
|
|
182
|
+
label: ' Error ',
|
|
183
|
+
content: `\n ${message.split('\n').join('\n ')}`,
|
|
184
|
+
style: { border: { fg: 'red' }, label: { fg: 'red' } },
|
|
185
|
+
keys: true,
|
|
186
|
+
mouse: true,
|
|
187
|
+
})
|
|
188
|
+
overlay.key(['enter', 'escape', 'q'], () => {
|
|
189
|
+
overlay.destroy()
|
|
190
|
+
this.listBox.focus()
|
|
191
|
+
this.screen.render()
|
|
192
|
+
})
|
|
193
|
+
overlay.focus()
|
|
194
|
+
this.screen.render()
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
private syncList(): void {
|
|
198
|
+
const orderedSessions = this.getOrderedSessions()
|
|
199
|
+
|
|
200
|
+
const items: string[] = []
|
|
201
|
+
const selectedId = this.manager.selectedSession?.id
|
|
202
|
+
let selectedDisplayIndex = -1
|
|
203
|
+
|
|
204
|
+
for (const group of OverviewUI.STATUS_GROUPS) {
|
|
205
|
+
const sessions = orderedSessions.filter((session) => session.status === group.status)
|
|
206
|
+
if (sessions.length === 0) continue
|
|
207
|
+
|
|
208
|
+
items.push(` ${group.label}`)
|
|
209
|
+
for (const session of sessions) {
|
|
210
|
+
const statusIcon =
|
|
211
|
+
session.status === 'running' ? '⣾'
|
|
212
|
+
: session.status === 'idle' ? '○'
|
|
213
|
+
: session.status === 'done' ? '✓'
|
|
214
|
+
: '✗'
|
|
215
|
+
const sessionTitle = session.displayName ?? session.id
|
|
216
|
+
const sessionLabel = session.type ? `${sessionTitle} (${session.type})` : sessionTitle
|
|
217
|
+
const content = `${statusIcon} ${sessionLabel} ${this.getStatusLabel(session.status)}`
|
|
218
|
+
const color = OverviewUI.STATUS_COLORS[session.status]
|
|
219
|
+
items.push(` {${color}-fg}${content}{/${color}-fg}`)
|
|
220
|
+
if (session.id === selectedId) {
|
|
221
|
+
selectedDisplayIndex = items.length - 1
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
this.listBox.setItems(items)
|
|
227
|
+
if (selectedDisplayIndex >= 0) {
|
|
228
|
+
this.listBox.select(selectedDisplayIndex)
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
private getOrderedSessions(): SessionManager['sessions'] {
|
|
233
|
+
return [...this.manager.sessions].sort((a, b) => {
|
|
234
|
+
const rankDiff = this.getStatusRank(a.status) - this.getStatusRank(b.status)
|
|
235
|
+
if (rankDiff !== 0) return rankDiff
|
|
236
|
+
return a.id.localeCompare(b.id)
|
|
237
|
+
})
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
private moveSelection(direction: -1 | 1): void {
|
|
241
|
+
const orderedSessions = this.getOrderedSessions()
|
|
242
|
+
const selectedId = this.manager.selectedSession?.id
|
|
243
|
+
const currentIndex = selectedId
|
|
244
|
+
? orderedSessions.findIndex((session) => session.id === selectedId)
|
|
245
|
+
: -1
|
|
246
|
+
const nextIndex = currentIndex === -1
|
|
247
|
+
? 0
|
|
248
|
+
: Math.max(0, Math.min(orderedSessions.length - 1, currentIndex + direction))
|
|
249
|
+
const nextSession = orderedSessions[nextIndex]
|
|
250
|
+
if (!nextSession) return
|
|
251
|
+
|
|
252
|
+
const managerIndex = this.manager.sessions.findIndex((session) => session.id === nextSession.id)
|
|
253
|
+
if (managerIndex >= 0) {
|
|
254
|
+
this.manager.selectSession(managerIndex)
|
|
255
|
+
}
|
|
256
|
+
}
|
|
257
|
+
|
|
258
|
+
private getStatusLabel(status: string): string {
|
|
259
|
+
switch (status) {
|
|
260
|
+
case 'running':
|
|
261
|
+
return 'working'
|
|
262
|
+
case 'idle':
|
|
263
|
+
return 'waiting'
|
|
264
|
+
case 'done':
|
|
265
|
+
return 'complete'
|
|
266
|
+
case 'error':
|
|
267
|
+
return 'failed'
|
|
268
|
+
default:
|
|
269
|
+
return status
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
private getStatusRank(status: string): number {
|
|
274
|
+
switch (status) {
|
|
275
|
+
case 'running':
|
|
276
|
+
return 0
|
|
277
|
+
case 'idle':
|
|
278
|
+
return 1
|
|
279
|
+
case 'done':
|
|
280
|
+
return 2
|
|
281
|
+
case 'error':
|
|
282
|
+
return 3
|
|
283
|
+
default:
|
|
284
|
+
return 99
|
|
285
|
+
}
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
show(): void {
|
|
289
|
+
this.listBox.show()
|
|
290
|
+
this.listBox.focus()
|
|
291
|
+
this.syncList()
|
|
292
|
+
this.screen.render()
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
isPromptOpen(): boolean {
|
|
296
|
+
return this.promptOpen
|
|
297
|
+
}
|
|
298
|
+
|
|
299
|
+
resizeSelectedSession(): void {
|
|
300
|
+
this.syncList()
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
hide(): void {
|
|
304
|
+
this.listBox.hide()
|
|
305
|
+
}
|
|
306
|
+
}
|