@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.
Files changed (56) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +212 -0
  3. package/bin/mav.ts +31 -0
  4. package/dist/bin/mav.d.ts +2 -0
  5. package/dist/bin/mav.js +24 -0
  6. package/dist/bin/mav.js.map +1 -0
  7. package/dist/src/agent-launch.d.ts +15 -0
  8. package/dist/src/agent-launch.js +36 -0
  9. package/dist/src/agent-launch.js.map +1 -0
  10. package/dist/src/agent.d.ts +42 -0
  11. package/dist/src/agent.js +209 -0
  12. package/dist/src/agent.js.map +1 -0
  13. package/dist/src/config.d.ts +11 -0
  14. package/dist/src/config.js +32 -0
  15. package/dist/src/config.js.map +1 -0
  16. package/dist/src/current-session.d.ts +15 -0
  17. package/dist/src/current-session.js +17 -0
  18. package/dist/src/current-session.js.map +1 -0
  19. package/dist/src/index.d.ts +6 -0
  20. package/dist/src/index.js +90 -0
  21. package/dist/src/index.js.map +1 -0
  22. package/dist/src/process-cwd.d.ts +1 -0
  23. package/dist/src/process-cwd.js +24 -0
  24. package/dist/src/process-cwd.js.map +1 -0
  25. package/dist/src/session-manager.d.ts +16 -0
  26. package/dist/src/session-manager.js +102 -0
  27. package/dist/src/session-manager.js.map +1 -0
  28. package/dist/src/state.d.ts +19 -0
  29. package/dist/src/state.js +30 -0
  30. package/dist/src/state.js.map +1 -0
  31. package/dist/src/ui/app.d.ts +15 -0
  32. package/dist/src/ui/app.js +109 -0
  33. package/dist/src/ui/app.js.map +1 -0
  34. package/dist/src/ui/detail.d.ts +28 -0
  35. package/dist/src/ui/detail.js +137 -0
  36. package/dist/src/ui/detail.js.map +1 -0
  37. package/dist/src/ui/overview.d.ts +24 -0
  38. package/dist/src/ui/overview.js +267 -0
  39. package/dist/src/ui/overview.js.map +1 -0
  40. package/lua/mav/follow.lua +37 -0
  41. package/lua/mav/init.lua +92 -0
  42. package/lua/mav/state.lua +22 -0
  43. package/package.json +54 -0
  44. package/plugin/mav.lua +24 -0
  45. package/src/agent-launch.ts +43 -0
  46. package/src/agent.ts +243 -0
  47. package/src/config.ts +51 -0
  48. package/src/current-session.ts +34 -0
  49. package/src/index.ts +121 -0
  50. package/src/process-cwd.ts +31 -0
  51. package/src/session-manager.ts +122 -0
  52. package/src/state.ts +49 -0
  53. package/src/types/neo-blessed.d.ts +5 -0
  54. package/src/ui/app.ts +121 -0
  55. package/src/ui/detail.ts +164 -0
  56. 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
+ }
@@ -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
+ }