@brimveyn/aimux 1.16.2 → 1.18.1
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/package.json +4 -2
- package/src/app-runtime/backend-runtime-events.ts +179 -0
- package/src/app-runtime/side-effects.ts +3 -1
- package/src/app.tsx +26 -0
- package/src/cli/chord.ts +77 -0
- package/src/cli/client/bootstrap.ts +76 -0
- package/src/cli/client/daemon-client.ts +228 -0
- package/src/cli/client/workspace-resolver.ts +57 -0
- package/src/cli/commands/tab/close.ts +33 -0
- package/src/cli/commands/tab/create.ts +109 -0
- package/src/cli/commands/tab/focus.ts +33 -0
- package/src/cli/commands/tab/list.ts +52 -0
- package/src/cli/commands/tab/send.ts +83 -0
- package/src/cli/commands/tab/snapshot.ts +127 -0
- package/src/cli/commands/tab/tail.ts +171 -0
- package/src/cli/commands/tab/wait.ts +86 -0
- package/src/cli/commands/workspace/close.ts +32 -0
- package/src/cli/commands/workspace/create.ts +92 -0
- package/src/cli/commands/workspace/list.ts +25 -0
- package/src/cli/commands/workspace/show.ts +32 -0
- package/src/cli/commands/workspace/switch.ts +75 -0
- package/src/cli/commands/worktree/create.ts +113 -0
- package/src/cli/commands/worktree/list.ts +44 -0
- package/src/cli/commands/worktree/remove.ts +59 -0
- package/src/cli/context.ts +16 -0
- package/src/cli/flags.ts +101 -0
- package/src/cli/index.ts +113 -0
- package/src/cli/output.ts +30 -0
- package/src/cli/registry.ts +54 -0
- package/src/cli/snapshot-render.ts +54 -0
- package/src/daemon/catalog-writer.ts +123 -0
- package/src/daemon/daemon.ts +566 -11
- package/src/daemon/reexec-client.ts +147 -0
- package/src/daemon/runtime-paths.ts +161 -1
- package/src/daemon/session-registry.ts +17 -0
- package/src/index.tsx +9 -0
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +2 -0
- package/src/input/modes/types.ts +1 -0
- package/src/ipc/README.md +112 -0
- package/src/ipc/manager-protocol.ts +54 -4
- package/src/ipc/protocol.ts +466 -25
- package/src/platform/daemon-control.ts +14 -0
- package/src/restart-daemon.ts +36 -4
- package/src/session-backend/bootstrap.ts +110 -0
- package/src/session-backend/local-session-backend.ts +6 -0
- package/src/session-backend/remote-session-backend.ts +63 -0
- package/src/session-backend/types.ts +34 -0
- package/src/state/reducers/modal-state.ts +66 -1
- package/src/state/types.ts +40 -0
- package/src/state/validation.ts +1 -1
- package/src/terminal-manager/manager-client.ts +24 -7
- package/src/ui/components/flash/flash-label-badge.tsx +38 -0
- package/src/ui/components/layout/sidebar/tab-item.tsx +2 -0
- package/src/ui/components/layout/sidebar/workspace-list.tsx +4 -0
- package/src/ui/components/layout/sidebar/worktree-row.tsx +2 -0
- package/src/ui/components/layout/top-tab-bar.tsx +2 -0
- package/src/ui/flash/assign-labels.ts +126 -0
- package/src/ui/flash/build-labels.ts +78 -0
- package/src/ui/hooks/use-flash-label.ts +40 -0
- package/src/ui/root.tsx +3 -0
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { SessionRecord } from '../../state/types'
|
|
2
|
+
|
|
3
|
+
import { findMostRecentSession, loadSessionCatalog } from '../../state/session-catalog'
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Resolve `--workspace W` to a session record from the catalog. Falls back to
|
|
7
|
+
* the most recently opened session when the flag is absent. Throws when the
|
|
8
|
+
* catalog is empty (no session has ever been created) or when the explicit
|
|
9
|
+
* name/id doesn't match.
|
|
10
|
+
*
|
|
11
|
+
* Matching: exact id wins; otherwise exact name (case-sensitive); otherwise
|
|
12
|
+
* unique case-insensitive name match.
|
|
13
|
+
*/
|
|
14
|
+
export function resolveWorkspace(name: string | undefined): SessionRecord {
|
|
15
|
+
const sessions = loadSessionCatalog()
|
|
16
|
+
if (sessions.length === 0) {
|
|
17
|
+
throw new Error(
|
|
18
|
+
'no sessions found — create one from the aimux UI first (or pass --workspace once created)'
|
|
19
|
+
)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (name === undefined || name === '') {
|
|
23
|
+
const active = findMostRecentSession(sessions)
|
|
24
|
+
if (!active) {
|
|
25
|
+
throw new Error('no active workspace and the catalog is empty')
|
|
26
|
+
}
|
|
27
|
+
return active
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
const byId = sessions.find((session) => session.id === name)
|
|
31
|
+
if (byId) return byId
|
|
32
|
+
|
|
33
|
+
const exactNameMatches = sessions.filter((session) => session.name === name)
|
|
34
|
+
if (exactNameMatches.length > 1) {
|
|
35
|
+
throw new Error(
|
|
36
|
+
`workspace "${name}" matches multiple sessions: ${exactNameMatches.map((s) => s.id).join(', ')}`
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
const exactOnly = exactNameMatches[0]
|
|
40
|
+
if (exactOnly) return exactOnly
|
|
41
|
+
|
|
42
|
+
const lower = name.toLowerCase()
|
|
43
|
+
const ciMatches = sessions.filter((session) => session.name.toLowerCase() === lower)
|
|
44
|
+
if (ciMatches.length > 1) {
|
|
45
|
+
throw new Error(
|
|
46
|
+
`workspace "${name}" matches multiple sessions: ${ciMatches.map((s) => s.id).join(', ')}`
|
|
47
|
+
)
|
|
48
|
+
}
|
|
49
|
+
const ciOnly = ciMatches[0]
|
|
50
|
+
if (ciOnly) return ciOnly
|
|
51
|
+
|
|
52
|
+
throw new Error(`workspace not found: ${name}`)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function listWorkspaces(): SessionRecord[] {
|
|
56
|
+
return loadSessionCatalog()
|
|
57
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
4
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
5
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
6
|
+
|
|
7
|
+
export const tabClose: CliCommand = {
|
|
8
|
+
args: [{ name: 'tabId', required: true }],
|
|
9
|
+
flags: SHARED_FLAGS,
|
|
10
|
+
group: 'tab',
|
|
11
|
+
run: async (ctx) => {
|
|
12
|
+
const tabId = ctx.args.positionals[0]
|
|
13
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
14
|
+
throw new Error('tabId is required')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workspace = ctx.getWorkspace()
|
|
18
|
+
const daemon = await ctx.getDaemon()
|
|
19
|
+
|
|
20
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
26
|
+
await daemon.expectOk('closeTab', { tabId })
|
|
27
|
+
|
|
28
|
+
writeJson({ ok: true })
|
|
29
|
+
return EXIT_OK
|
|
30
|
+
},
|
|
31
|
+
summary: 'Close a tab in the workspace',
|
|
32
|
+
verb: 'close',
|
|
33
|
+
}
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
import { resolve as resolvePath } from 'node:path'
|
|
2
|
+
|
|
3
|
+
import type { CliCommand } from '../../registry'
|
|
4
|
+
|
|
5
|
+
import {
|
|
6
|
+
IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK,
|
|
7
|
+
IPC_CAPABILITY_THIN_ATTACH,
|
|
8
|
+
} from '../../../ipc/protocol'
|
|
9
|
+
import { createPrefixedId } from '../../../platform/id'
|
|
10
|
+
import { getAllAssistantOptions, parseCommand } from '../../../pty/command-registry'
|
|
11
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
12
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
13
|
+
|
|
14
|
+
const FALLBACK_COLS = 200
|
|
15
|
+
const FALLBACK_ROWS = 60
|
|
16
|
+
|
|
17
|
+
export const tabCreate: CliCommand = {
|
|
18
|
+
args: [],
|
|
19
|
+
flags: [
|
|
20
|
+
...SHARED_FLAGS,
|
|
21
|
+
{
|
|
22
|
+
description: 'assistant id (claude, codex, opencode, terminal, ...)',
|
|
23
|
+
kind: 'string',
|
|
24
|
+
name: 'assistant',
|
|
25
|
+
},
|
|
26
|
+
{ description: 'tab title (defaults to assistant label)', kind: 'string', name: 'title' },
|
|
27
|
+
{ description: 'cwd for the spawned PTY', kind: 'string', name: 'cwd' },
|
|
28
|
+
{
|
|
29
|
+
description: 'explicit command (overrides the assistant default)',
|
|
30
|
+
kind: 'string',
|
|
31
|
+
name: 'command',
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
description: 'worktree id the tab belongs to (defaults to the workspace’s active worktree)',
|
|
35
|
+
kind: 'string',
|
|
36
|
+
name: 'worktree',
|
|
37
|
+
},
|
|
38
|
+
],
|
|
39
|
+
group: 'tab',
|
|
40
|
+
run: async (ctx) => {
|
|
41
|
+
const assistantId = ctx.args.flags.assistant
|
|
42
|
+
if (typeof assistantId !== 'string' || assistantId.length === 0) {
|
|
43
|
+
throw new Error('--assistant is required')
|
|
44
|
+
}
|
|
45
|
+
const options = getAllAssistantOptions({})
|
|
46
|
+
const option = options.find((o) => o.id === assistantId)
|
|
47
|
+
if (!option) {
|
|
48
|
+
throw new Error(
|
|
49
|
+
`unknown assistant: ${assistantId} (known: ${options.map((o) => o.id).join(', ')})`
|
|
50
|
+
)
|
|
51
|
+
}
|
|
52
|
+
const command =
|
|
53
|
+
typeof ctx.args.flags.command === 'string' ? ctx.args.flags.command : option.command
|
|
54
|
+
const title = typeof ctx.args.flags.title === 'string' ? ctx.args.flags.title : option.label
|
|
55
|
+
const cwdRaw = typeof ctx.args.flags.cwd === 'string' ? ctx.args.flags.cwd : undefined
|
|
56
|
+
const cwd = cwdRaw === undefined ? undefined : resolvePath(cwdRaw)
|
|
57
|
+
|
|
58
|
+
const workspace = ctx.getWorkspace()
|
|
59
|
+
const daemon = await ctx.getDaemon()
|
|
60
|
+
|
|
61
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
62
|
+
throw new Error(
|
|
63
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
64
|
+
)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Resolve worktreeId: explicit flag wins, otherwise the workspace's
|
|
68
|
+
// currently-active worktree, otherwise undefined (= no grouping).
|
|
69
|
+
const worktreeFlag =
|
|
70
|
+
typeof ctx.args.flags.worktree === 'string' ? ctx.args.flags.worktree : undefined
|
|
71
|
+
let worktreeId: string | undefined = worktreeFlag ?? workspace.activeWorktreeId
|
|
72
|
+
if (worktreeFlag !== undefined) {
|
|
73
|
+
const known = workspace.worktrees?.some((w) => w.id === worktreeFlag) ?? false
|
|
74
|
+
if (!known) {
|
|
75
|
+
const ids = workspace.worktrees?.map((w) => w.id).join(', ') ?? '(none)'
|
|
76
|
+
throw new Error(`unknown worktree id: ${worktreeFlag} (known: ${ids})`)
|
|
77
|
+
}
|
|
78
|
+
worktreeId = worktreeFlag
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Thin-attach so we don't clobber the UI's dimensions on the same session.
|
|
82
|
+
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
83
|
+
|
|
84
|
+
const { args, executable } = parseCommand(command)
|
|
85
|
+
const tabId = createPrefixedId('tab')
|
|
86
|
+
|
|
87
|
+
// cols/rows = 0 means "fall back to the session's last attached size" on
|
|
88
|
+
// v11 daemons. Without that capability we have nothing reasonable to put
|
|
89
|
+
// here (the CLI has no terminal of its own), so use a roomy fallback —
|
|
90
|
+
// PTYs are reflowable, so 200×60 won't break anything that adapts.
|
|
91
|
+
const useFallback = daemon.hasCapability(IPC_CAPABILITY_CREATE_TAB_SIZE_FALLBACK)
|
|
92
|
+
await daemon.expectOk('createTab', {
|
|
93
|
+
args,
|
|
94
|
+
assistant: assistantId,
|
|
95
|
+
cols: useFallback ? 0 : FALLBACK_COLS,
|
|
96
|
+
command: executable,
|
|
97
|
+
cwd,
|
|
98
|
+
rows: useFallback ? 0 : FALLBACK_ROWS,
|
|
99
|
+
tabId,
|
|
100
|
+
title,
|
|
101
|
+
worktreeId,
|
|
102
|
+
})
|
|
103
|
+
|
|
104
|
+
writeJson({ assistant: assistantId, command, tabId, title, worktreeId })
|
|
105
|
+
return EXIT_OK
|
|
106
|
+
},
|
|
107
|
+
summary: 'Create a new tab in the active workspace',
|
|
108
|
+
verb: 'create',
|
|
109
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
4
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
5
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
6
|
+
|
|
7
|
+
export const tabFocus: CliCommand = {
|
|
8
|
+
args: [{ name: 'tabId', required: true }],
|
|
9
|
+
flags: SHARED_FLAGS,
|
|
10
|
+
group: 'tab',
|
|
11
|
+
run: async (ctx) => {
|
|
12
|
+
const tabId = ctx.args.positionals[0]
|
|
13
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
14
|
+
throw new Error('tabId is required')
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
const workspace = ctx.getWorkspace()
|
|
18
|
+
const daemon = await ctx.getDaemon()
|
|
19
|
+
|
|
20
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
21
|
+
throw new Error(
|
|
22
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
23
|
+
)
|
|
24
|
+
}
|
|
25
|
+
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
26
|
+
await daemon.expectOk('setActiveTab', { tabId })
|
|
27
|
+
|
|
28
|
+
writeJson({ ok: true })
|
|
29
|
+
return EXIT_OK
|
|
30
|
+
},
|
|
31
|
+
summary: 'Set the active tab in the workspace',
|
|
32
|
+
verb: 'focus',
|
|
33
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { IPC_CAPABILITY_LIST_TABS, IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
4
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
5
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
6
|
+
|
|
7
|
+
export const tabList: CliCommand = {
|
|
8
|
+
args: [],
|
|
9
|
+
flags: SHARED_FLAGS,
|
|
10
|
+
group: 'tab',
|
|
11
|
+
run: async (ctx) => {
|
|
12
|
+
const workspace = ctx.getWorkspace()
|
|
13
|
+
const daemon = await ctx.getDaemon()
|
|
14
|
+
|
|
15
|
+
if (daemon.hasCapability(IPC_CAPABILITY_LIST_TABS)) {
|
|
16
|
+
const result = await daemon.listTabs(workspace.id)
|
|
17
|
+
writeJson({ activeTabId: result.activeTabId, tabs: result.tabs })
|
|
18
|
+
return EXIT_OK
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
22
|
+
throw new Error(
|
|
23
|
+
'daemon predates listTabs and thinAttach capabilities — restart aimux to pick up the new daemon'
|
|
24
|
+
)
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
// Fallback: thin-attach to read the same information without resizing the
|
|
28
|
+
// session. Pre-listTabs daemons that advertise thinAttach would in
|
|
29
|
+
// principle land here, but that combination shouldn't ship.
|
|
30
|
+
const attach = await daemon.attach({
|
|
31
|
+
cols: 0,
|
|
32
|
+
rows: 0,
|
|
33
|
+
sessionId: workspace.id,
|
|
34
|
+
thin: true,
|
|
35
|
+
})
|
|
36
|
+
writeJson({
|
|
37
|
+
activeTabId: attach.activeTabId,
|
|
38
|
+
tabs: attach.tabs.map((tab) => ({
|
|
39
|
+
activity: tab.activity,
|
|
40
|
+
assistant: tab.assistant,
|
|
41
|
+
command: tab.command,
|
|
42
|
+
id: tab.id,
|
|
43
|
+
status: tab.status,
|
|
44
|
+
title: tab.title,
|
|
45
|
+
worktreeId: tab.worktreeId,
|
|
46
|
+
})),
|
|
47
|
+
})
|
|
48
|
+
return EXIT_OK
|
|
49
|
+
},
|
|
50
|
+
summary: 'List tabs in the active workspace',
|
|
51
|
+
verb: 'list',
|
|
52
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import type { CliCommand } from '../../registry'
|
|
2
|
+
|
|
3
|
+
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
4
|
+
import { bracketedPaste, notationToBytes } from '../../chord'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Lower a chord/paste buffer of bytes into the string the protocol expects.
|
|
10
|
+
* Every byte we emit is < 0x80 (control chars or printable ASCII), so a
|
|
11
|
+
* Latin-1 decode is faithful — the receiving PTY's UTF-8 path treats each
|
|
12
|
+
* single byte as itself.
|
|
13
|
+
*/
|
|
14
|
+
function bytesToString(bytes: Buffer): string {
|
|
15
|
+
let out = ''
|
|
16
|
+
for (const byte of bytes) {
|
|
17
|
+
out += String.fromCharCode(byte)
|
|
18
|
+
}
|
|
19
|
+
return out
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export const tabSend: CliCommand = {
|
|
23
|
+
args: [{ name: 'tabId', required: true }, { name: 'text' }],
|
|
24
|
+
flags: [
|
|
25
|
+
...SHARED_FLAGS,
|
|
26
|
+
{ description: 'append \\r so the receiving CLI submits', kind: 'boolean', name: 'enter' },
|
|
27
|
+
{
|
|
28
|
+
description: 'interpret <text> as a vim-style key chord (e.g. <C-c>, <Esc>, <Up><Up>)',
|
|
29
|
+
kind: 'boolean',
|
|
30
|
+
name: 'keys',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
description: 'read the payload from stdin instead of <text>',
|
|
34
|
+
kind: 'boolean',
|
|
35
|
+
name: 'stdin',
|
|
36
|
+
},
|
|
37
|
+
],
|
|
38
|
+
group: 'tab',
|
|
39
|
+
run: async (ctx) => {
|
|
40
|
+
const tabId = ctx.args.positionals[0]
|
|
41
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
42
|
+
throw new Error('tabId is required')
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
const fromStdin = ctx.args.flags.stdin === true
|
|
46
|
+
const asKeys = ctx.args.flags.keys === true
|
|
47
|
+
const appendEnter = ctx.args.flags.enter === true
|
|
48
|
+
|
|
49
|
+
let data: string
|
|
50
|
+
if (fromStdin) {
|
|
51
|
+
const stdinText = await Bun.stdin.text()
|
|
52
|
+
data = asKeys ? bytesToString(notationToBytes(stdinText)) : bracketedPaste(stdinText)
|
|
53
|
+
} else {
|
|
54
|
+
const text = ctx.args.positionals[1] ?? ''
|
|
55
|
+
if (asKeys) {
|
|
56
|
+
if (text === '') throw new Error('--keys requires the chord notation as <text>')
|
|
57
|
+
data = bytesToString(notationToBytes(text))
|
|
58
|
+
} else {
|
|
59
|
+
data = bracketedPaste(text)
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
if (appendEnter) {
|
|
64
|
+
data = `${data}\r`
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
const workspace = ctx.getWorkspace()
|
|
68
|
+
const daemon = await ctx.getDaemon()
|
|
69
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
70
|
+
throw new Error(
|
|
71
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
72
|
+
)
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
76
|
+
await daemon.expectOk('write', { data, tabId })
|
|
77
|
+
|
|
78
|
+
writeJson({ bytesWritten: Buffer.byteLength(data, 'utf8'), ok: true })
|
|
79
|
+
return EXIT_OK
|
|
80
|
+
},
|
|
81
|
+
summary: 'Write text or a key chord to a tab',
|
|
82
|
+
verb: 'send',
|
|
83
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import type { TerminalSnapshot } from '../../../state/types'
|
|
2
|
+
import type { CliCommand } from '../../registry'
|
|
3
|
+
|
|
4
|
+
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, writeJson } from '../../output'
|
|
7
|
+
import { snapshotTailLines, snapshotToLines } from '../../snapshot-render'
|
|
8
|
+
|
|
9
|
+
const RENDER_WAIT_MS = 500
|
|
10
|
+
|
|
11
|
+
export const tabSnapshot: CliCommand = {
|
|
12
|
+
args: [{ name: 'tabId', required: true }],
|
|
13
|
+
flags: [
|
|
14
|
+
...SHARED_FLAGS,
|
|
15
|
+
{ description: 'return only the last N non-blank lines', kind: 'number', name: 'tail' },
|
|
16
|
+
{
|
|
17
|
+
description: 'output format: json (default) or text (raw screen dump)',
|
|
18
|
+
kind: 'string',
|
|
19
|
+
name: 'format',
|
|
20
|
+
},
|
|
21
|
+
{
|
|
22
|
+
description: 'do not strip trailing whitespace from each line',
|
|
23
|
+
kind: 'boolean',
|
|
24
|
+
name: 'no-trim',
|
|
25
|
+
},
|
|
26
|
+
],
|
|
27
|
+
group: 'tab',
|
|
28
|
+
run: async (ctx) => {
|
|
29
|
+
const tabId = ctx.args.positionals[0]
|
|
30
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
31
|
+
throw new Error('tabId is required')
|
|
32
|
+
}
|
|
33
|
+
const tail = typeof ctx.args.flags.tail === 'number' ? ctx.args.flags.tail : 0
|
|
34
|
+
const format = ctx.args.flags.format ?? 'json'
|
|
35
|
+
if (format !== 'json' && format !== 'text') {
|
|
36
|
+
throw new Error(`--format must be "json" or "text" (got: ${String(format)})`)
|
|
37
|
+
}
|
|
38
|
+
const renderOptions = { trim: ctx.args.flags['no-trim'] !== true }
|
|
39
|
+
|
|
40
|
+
const workspace = ctx.getWorkspace()
|
|
41
|
+
const daemon = await ctx.getDaemon()
|
|
42
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
43
|
+
throw new Error(
|
|
44
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
45
|
+
)
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Subscribe BEFORE attaching so a render fired between attach completion
|
|
49
|
+
// and our subscription doesn't slip through.
|
|
50
|
+
const renderState: { cancel: (() => void) | null } = { cancel: null }
|
|
51
|
+
const renderPromise = new Promise<TerminalSnapshot | null>((resolve) => {
|
|
52
|
+
const off = daemon.on('tabRender', (payload) => {
|
|
53
|
+
if (payload.tabId !== tabId) return
|
|
54
|
+
off()
|
|
55
|
+
clearTimeout(timer)
|
|
56
|
+
renderState.cancel = null
|
|
57
|
+
resolve(payload.viewport)
|
|
58
|
+
})
|
|
59
|
+
const timer = setTimeout(() => {
|
|
60
|
+
off()
|
|
61
|
+
renderState.cancel = null
|
|
62
|
+
resolve(null)
|
|
63
|
+
}, RENDER_WAIT_MS)
|
|
64
|
+
renderState.cancel = () => {
|
|
65
|
+
clearTimeout(timer)
|
|
66
|
+
off()
|
|
67
|
+
resolve(null)
|
|
68
|
+
}
|
|
69
|
+
})
|
|
70
|
+
|
|
71
|
+
const attach = await daemon.attach({
|
|
72
|
+
cols: 0,
|
|
73
|
+
rows: 0,
|
|
74
|
+
sessionId: workspace.id,
|
|
75
|
+
thin: true,
|
|
76
|
+
})
|
|
77
|
+
const tab = attach.tabs.find((t) => t.id === tabId)
|
|
78
|
+
if (!tab) {
|
|
79
|
+
renderState.cancel?.()
|
|
80
|
+
throw new Error(`tab not found: ${tabId}`)
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
let snapshot = tab.viewport
|
|
84
|
+
if (!snapshot || snapshot.lines.length === 0) {
|
|
85
|
+
const awaited = await renderPromise
|
|
86
|
+
if (awaited) snapshot = awaited
|
|
87
|
+
} else {
|
|
88
|
+
renderState.cancel?.()
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (!snapshot) {
|
|
92
|
+
throw new Error('no snapshot available within timeout')
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
const lines =
|
|
96
|
+
tail > 0
|
|
97
|
+
? snapshotTailLines(snapshot, tail, renderOptions)
|
|
98
|
+
: snapshotToLines(snapshot, renderOptions)
|
|
99
|
+
|
|
100
|
+
if (format === 'text') {
|
|
101
|
+
// Raw screen dump: best fit when piping into an LLM prompt — no JSON
|
|
102
|
+
// escape noise, the model sees the terminal exactly as it appears.
|
|
103
|
+
// The tail-trim and per-line trim already ran above; just join.
|
|
104
|
+
process.stdout.write(`${lines.join('\n')}\n`)
|
|
105
|
+
return EXIT_OK
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
let widest = 0
|
|
109
|
+
for (const line of lines) {
|
|
110
|
+
if (line.length > widest) widest = line.length
|
|
111
|
+
}
|
|
112
|
+
writeJson({
|
|
113
|
+
cols: widest,
|
|
114
|
+
cursor: {
|
|
115
|
+
col: snapshot.cursorCol ?? null,
|
|
116
|
+
row: snapshot.cursorRow ?? null,
|
|
117
|
+
visible: snapshot.cursorVisible,
|
|
118
|
+
},
|
|
119
|
+
lines,
|
|
120
|
+
rows: lines.length,
|
|
121
|
+
tabId,
|
|
122
|
+
})
|
|
123
|
+
return EXIT_OK
|
|
124
|
+
},
|
|
125
|
+
summary: 'Snapshot the visible viewport of a tab as plain text',
|
|
126
|
+
verb: 'snapshot',
|
|
127
|
+
}
|
|
@@ -0,0 +1,171 @@
|
|
|
1
|
+
import type { TerminalSnapshot } from '../../../state/types'
|
|
2
|
+
import type { CliCommand } from '../../registry'
|
|
3
|
+
|
|
4
|
+
import { IPC_CAPABILITY_THIN_ATTACH } from '../../../ipc/protocol'
|
|
5
|
+
import { SHARED_FLAGS } from '../../flags'
|
|
6
|
+
import { EXIT_OK, EXIT_RUNTIME, EXIT_TIMEOUT, writeNdjson } from '../../output'
|
|
7
|
+
import { snapshotToLines } from '../../snapshot-render'
|
|
8
|
+
|
|
9
|
+
interface Cursor {
|
|
10
|
+
row: number | null
|
|
11
|
+
col: number | null
|
|
12
|
+
visible: boolean
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
function toCursor(snapshot: TerminalSnapshot): Cursor {
|
|
16
|
+
return {
|
|
17
|
+
col: snapshot.cursorCol ?? null,
|
|
18
|
+
row: snapshot.cursorRow ?? null,
|
|
19
|
+
visible: snapshot.cursorVisible,
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export const tabTail: CliCommand = {
|
|
24
|
+
args: [{ name: 'tabId', required: true }],
|
|
25
|
+
flags: [
|
|
26
|
+
...SHARED_FLAGS,
|
|
27
|
+
{
|
|
28
|
+
description: 'emit the raw TerminalSnapshot instead of the trimmed text lines',
|
|
29
|
+
kind: 'boolean',
|
|
30
|
+
name: 'raw',
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
description: 'coalesce renders arriving within N ms (default 0 = no coalescing)',
|
|
34
|
+
kind: 'number',
|
|
35
|
+
name: 'rate-limit-ms',
|
|
36
|
+
},
|
|
37
|
+
{
|
|
38
|
+
description: 'also emit tabStatus records interleaved with renders',
|
|
39
|
+
kind: 'boolean',
|
|
40
|
+
name: 'follow-status',
|
|
41
|
+
},
|
|
42
|
+
{
|
|
43
|
+
description: 'exit after N milliseconds even if the tab is still alive',
|
|
44
|
+
kind: 'number',
|
|
45
|
+
name: 'timeout',
|
|
46
|
+
},
|
|
47
|
+
],
|
|
48
|
+
group: 'tab',
|
|
49
|
+
run: async (ctx) => {
|
|
50
|
+
const tabId = ctx.args.positionals[0]
|
|
51
|
+
if (typeof tabId !== 'string' || tabId.length === 0) {
|
|
52
|
+
throw new Error('tabId is required')
|
|
53
|
+
}
|
|
54
|
+
const raw = ctx.args.flags.raw === true
|
|
55
|
+
const rateLimitMs =
|
|
56
|
+
typeof ctx.args.flags['rate-limit-ms'] === 'number' ? ctx.args.flags['rate-limit-ms'] : 0
|
|
57
|
+
const followStatus = ctx.args.flags['follow-status'] === true
|
|
58
|
+
const timeoutMs = typeof ctx.args.flags.timeout === 'number' ? ctx.args.flags.timeout : 0
|
|
59
|
+
|
|
60
|
+
const workspace = ctx.getWorkspace()
|
|
61
|
+
const daemon = await ctx.getDaemon()
|
|
62
|
+
if (!daemon.hasCapability(IPC_CAPABILITY_THIN_ATTACH)) {
|
|
63
|
+
throw new Error(
|
|
64
|
+
'daemon predates thinAttach capability — restart aimux to pick up the new daemon'
|
|
65
|
+
)
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const start = Date.now()
|
|
69
|
+
// Attach before wiring subscribers so replay renders arrive after we
|
|
70
|
+
// print the "attached" marker; that keeps NDJSON output deterministic
|
|
71
|
+
// for downstream consumers.
|
|
72
|
+
const attach = await daemon.attach({ cols: 0, rows: 0, sessionId: workspace.id, thin: true })
|
|
73
|
+
|
|
74
|
+
// Fail fast on a bad tabId — otherwise tail would sit silent until
|
|
75
|
+
// --timeout, since no matching tabRender/tabExit would ever fire.
|
|
76
|
+
if (!attach.tabs.some((t) => t.id === tabId)) {
|
|
77
|
+
throw new Error(`tab not found: ${tabId}`)
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
return new Promise<number>((resolve) => {
|
|
81
|
+
let lastEmitAt = 0
|
|
82
|
+
let pendingViewport: TerminalSnapshot | null = null
|
|
83
|
+
let coalesceTimer: ReturnType<typeof setTimeout> | null = null
|
|
84
|
+
|
|
85
|
+
const emitRender = (viewport: TerminalSnapshot): void => {
|
|
86
|
+
const ts = Date.now() - start
|
|
87
|
+
if (raw) {
|
|
88
|
+
writeNdjson({ tabId, ts, type: 'render', viewport })
|
|
89
|
+
} else {
|
|
90
|
+
writeNdjson({
|
|
91
|
+
cursor: toCursor(viewport),
|
|
92
|
+
lines: snapshotToLines(viewport, { trim: true }),
|
|
93
|
+
tabId,
|
|
94
|
+
ts,
|
|
95
|
+
type: 'render',
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
lastEmitAt = Date.now()
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const scheduleCoalesced = (viewport: TerminalSnapshot): void => {
|
|
102
|
+
pendingViewport = viewport
|
|
103
|
+
if (coalesceTimer) return
|
|
104
|
+
const elapsed = Date.now() - lastEmitAt
|
|
105
|
+
const wait = Math.max(0, rateLimitMs - elapsed)
|
|
106
|
+
coalesceTimer = setTimeout(() => {
|
|
107
|
+
coalesceTimer = null
|
|
108
|
+
const v = pendingViewport
|
|
109
|
+
pendingViewport = null
|
|
110
|
+
if (v) emitRender(v)
|
|
111
|
+
}, wait)
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
const offRender = daemon.on('tabRender', (payload) => {
|
|
115
|
+
if (payload.tabId !== tabId) return
|
|
116
|
+
if (rateLimitMs > 0) {
|
|
117
|
+
scheduleCoalesced(payload.viewport)
|
|
118
|
+
} else {
|
|
119
|
+
emitRender(payload.viewport)
|
|
120
|
+
}
|
|
121
|
+
})
|
|
122
|
+
const offExit = daemon.on('tabExit', (payload) => {
|
|
123
|
+
if (payload.tabId !== tabId) return
|
|
124
|
+
writeNdjson({ exitCode: payload.exitCode, tabId, ts: Date.now() - start, type: 'exit' })
|
|
125
|
+
cleanup()
|
|
126
|
+
resolve(EXIT_OK)
|
|
127
|
+
})
|
|
128
|
+
const offError = daemon.on('tabError', (payload) => {
|
|
129
|
+
if (payload.tabId !== tabId) return
|
|
130
|
+
writeNdjson({ error: payload.message, tabId, ts: Date.now() - start, type: 'error' })
|
|
131
|
+
cleanup()
|
|
132
|
+
resolve(EXIT_RUNTIME)
|
|
133
|
+
})
|
|
134
|
+
const noop = (): void => {}
|
|
135
|
+
const offStatus = followStatus
|
|
136
|
+
? daemon.on('tabStatus', (payload) => {
|
|
137
|
+
if (payload.tabId !== tabId) return
|
|
138
|
+
writeNdjson({
|
|
139
|
+
status: payload.status,
|
|
140
|
+
tabId,
|
|
141
|
+
ts: Date.now() - start,
|
|
142
|
+
type: 'status',
|
|
143
|
+
})
|
|
144
|
+
})
|
|
145
|
+
: noop
|
|
146
|
+
|
|
147
|
+
const timer =
|
|
148
|
+
timeoutMs > 0
|
|
149
|
+
? setTimeout(() => {
|
|
150
|
+
writeNdjson({ tabId, ts: Date.now() - start, type: 'timeout' })
|
|
151
|
+
cleanup()
|
|
152
|
+
resolve(EXIT_TIMEOUT)
|
|
153
|
+
}, timeoutMs)
|
|
154
|
+
: null
|
|
155
|
+
|
|
156
|
+
const cleanup = (): void => {
|
|
157
|
+
offRender()
|
|
158
|
+
offExit()
|
|
159
|
+
offError()
|
|
160
|
+
offStatus()
|
|
161
|
+
if (coalesceTimer) {
|
|
162
|
+
clearTimeout(coalesceTimer)
|
|
163
|
+
coalesceTimer = null
|
|
164
|
+
}
|
|
165
|
+
if (timer) clearTimeout(timer)
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
},
|
|
169
|
+
summary: 'Stream a tab’s render events as NDJSON',
|
|
170
|
+
verb: 'tail',
|
|
171
|
+
}
|