@brimveyn/aimux 1.7.3 → 1.8.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/README.md +27 -15
- package/package.json +2 -2
- package/src/app-runtime/auto-commit-driver.ts +286 -0
- package/src/app-runtime/auto-commit-ref.ts +20 -0
- package/src/app-runtime/backend-attach-runtime.ts +3 -1
- package/src/app-runtime/session-actions.ts +7 -2
- package/src/app-runtime/side-effects.ts +111 -4
- package/src/app-runtime/split-drag-controller.ts +31 -1
- package/src/app-runtime/use-auto-commit-driver.ts +133 -0
- package/src/app-runtime/use-mouse-handlers.ts +93 -5
- package/src/app-runtime/use-terminal-resize.ts +24 -16
- package/src/app.tsx +71 -19
- package/src/auto-commit/default-auto-commit-prompt.md +46 -0
- package/src/auto-commit/headless-commands.ts +40 -0
- package/src/auto-commit/output-parser.ts +21 -0
- package/src/auto-commit/prompt-loader.ts +33 -0
- package/src/auto-commit/staging-mode.ts +5 -0
- package/src/auto-commit/strip-ansi.ts +13 -0
- package/src/auto-commit/suggestion-runner.ts +55 -0
- package/src/auto-commit/working-tree-hash.ts +24 -0
- package/src/config.ts +45 -2
- package/src/daemon/session-registry.ts +1 -0
- package/src/index.tsx +1 -1
- package/src/input/keymap/help-entries.ts +4 -4
- package/src/input/modes/bridge.ts +6 -0
- package/src/input/modes/transitions.ts +3 -1
- package/src/input/modes/types.ts +4 -0
- package/src/ipc/manager-protocol.ts +2 -2
- package/src/ipc/protocol.ts +2 -8
- package/src/pty/assistant-status-detector.ts +1 -1
- package/src/pty/terminal-snapshot.ts +38 -4
- package/src/services/ai-usage/adapters/claude.ts +139 -0
- package/src/services/ai-usage/adapters/codex.ts +191 -0
- package/src/services/ai-usage/provider.ts +84 -0
- package/src/services/ai-usage/spawn.ts +49 -0
- package/src/services/ai-usage/types.ts +20 -0
- package/src/session-backend/local-session-backend.ts +10 -2
- package/src/state/ai-usage-store.ts +29 -0
- package/src/state/git-pane-sizing.ts +15 -0
- package/src/state/reducers/auto-commit-state.ts +59 -0
- package/src/state/reducers/git-panel-state.ts +12 -7
- package/src/state/reducers/modal-state.ts +106 -2
- package/src/state/reducers/session-state.ts +26 -14
- package/src/state/reducers/ui-state.ts +6 -0
- package/src/state/session-persistence.ts +14 -5
- package/src/state/store.ts +26 -15
- package/src/state/types.ts +59 -3
- package/src/state/workspace-save.ts +6 -1
- package/src/ui/ai-usage/controller.ts +35 -0
- package/src/ui/components/ai-usage-indicator.tsx +131 -0
- package/src/ui/components/ai-usage-popover.tsx +152 -0
- package/src/ui/components/context-menu-overlay.tsx +4 -2
- package/src/ui/components/create-session-modal.tsx +2 -2
- package/src/ui/components/git-commit-modal.tsx +167 -18
- package/src/ui/components/git-pane-context-menu.ts +26 -0
- package/src/ui/components/session-bar.tsx +2 -2
- package/src/ui/components/session-picker-modal.tsx +4 -4
- package/src/ui/components/sidebar.tsx +117 -31
- package/src/ui/components/status-bar.tsx +2 -0
- package/src/ui/components/terminal-pane.tsx +18 -3
- package/src/ui/root.tsx +114 -13
- package/src/ui/status-bar-model.ts +1 -1
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export interface ParsedSuggestion {
|
|
2
|
+
title: string
|
|
3
|
+
body: string
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
const TITLE_RE = /^[ \t]*TITLE:[ \t]*(.*?)[ \t]*$/m
|
|
7
|
+
const BODY_MARKER_RE = /^[ \t]*BODY:[ \t]*$/m
|
|
8
|
+
|
|
9
|
+
export function parseSuggestion(raw: string): ParsedSuggestion | null {
|
|
10
|
+
const titleMatch = TITLE_RE.exec(raw)
|
|
11
|
+
if (!titleMatch) return null
|
|
12
|
+
const title = (titleMatch[1] ?? '').trim()
|
|
13
|
+
if (!title) return null
|
|
14
|
+
|
|
15
|
+
const bodyMarker = BODY_MARKER_RE.exec(raw)
|
|
16
|
+
if (!bodyMarker) return { body: '', title }
|
|
17
|
+
const bodyStart = bodyMarker.index + bodyMarker[0].length
|
|
18
|
+
const body = raw.slice(bodyStart).trim()
|
|
19
|
+
|
|
20
|
+
return { body, title }
|
|
21
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { existsSync } from 'node:fs'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { join } from 'node:path'
|
|
4
|
+
|
|
5
|
+
const OVERRIDE_FILENAME = 'auto-commit-prompt.md'
|
|
6
|
+
const DEFAULT_PATH = new URL('./default-auto-commit-prompt.md', import.meta.url)
|
|
7
|
+
|
|
8
|
+
export interface LoadOptions {
|
|
9
|
+
profileConfigRoot: string
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export async function loadBriefingTemplate(opts: LoadOptions): Promise<string> {
|
|
13
|
+
const override = join(opts.profileConfigRoot, OVERRIDE_FILENAME)
|
|
14
|
+
if (existsSync(override)) {
|
|
15
|
+
return await readFile(override, 'utf8')
|
|
16
|
+
}
|
|
17
|
+
return await readFile(DEFAULT_PATH, 'utf8')
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export interface PromptSlots {
|
|
21
|
+
recentCommits: string
|
|
22
|
+
diff: string
|
|
23
|
+
branch: string
|
|
24
|
+
sessionTail: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export function composePromptFromTemplate(template: string, slots: PromptSlots): string {
|
|
28
|
+
return template
|
|
29
|
+
.replaceAll('{recentCommits}', slots.recentCommits)
|
|
30
|
+
.replaceAll('{diff}', slots.diff)
|
|
31
|
+
.replaceAll('{branch}', slots.branch)
|
|
32
|
+
.replaceAll('{sessionTail}', slots.sessionTail)
|
|
33
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
// Matches the common subset of terminal escape sequences we care about:
|
|
2
|
+
// - CSI sequences (ESC [ ... letter) — SGR colors, cursor moves
|
|
3
|
+
// - OSC sequences (ESC ] ... BEL | ESC \\) — title/hyperlink setters
|
|
4
|
+
// - Single-char ESC + letter/digit — simple mode toggles
|
|
5
|
+
// - Other C0/C1 controls outside tab/newline — stripped
|
|
6
|
+
// Reference: https://en.wikipedia.org/wiki/ANSI_escape_code
|
|
7
|
+
const ANSI_RE =
|
|
8
|
+
// eslint-disable-next-line no-control-regex
|
|
9
|
+
/\x1B(?:\]([\s\S]*?)(?:\x07|\x1B\\)|\[[0-?]*[ -/]*[@-~]|[@-_])|[\x00-\x08\x0B-\x1F\x7F]/g
|
|
10
|
+
|
|
11
|
+
export function stripAnsi(input: string): string {
|
|
12
|
+
return input.replace(ANSI_RE, '')
|
|
13
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { HeadlessInvocation } from './headless-commands'
|
|
2
|
+
|
|
3
|
+
import { type ParsedSuggestion, parseSuggestion } from './output-parser'
|
|
4
|
+
|
|
5
|
+
export type SpawnFn = (
|
|
6
|
+
invocation: HeadlessInvocation,
|
|
7
|
+
signal: AbortSignal
|
|
8
|
+
) => Promise<{ stdout: string; exitCode: number } | null>
|
|
9
|
+
|
|
10
|
+
export interface RunOptions {
|
|
11
|
+
invocation: HeadlessInvocation
|
|
12
|
+
signal: AbortSignal
|
|
13
|
+
timeoutMs: number
|
|
14
|
+
spawn?: SpawnFn
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export async function runSuggestion(opts: RunOptions): Promise<ParsedSuggestion | null> {
|
|
18
|
+
const spawnFn = opts.spawn ?? defaultSpawn
|
|
19
|
+
const composite = AbortSignal.any([opts.signal, AbortSignal.timeout(opts.timeoutMs)])
|
|
20
|
+
try {
|
|
21
|
+
const result = await spawnFn(opts.invocation, composite)
|
|
22
|
+
if (!result) return null
|
|
23
|
+
if (result.exitCode !== 0) return null
|
|
24
|
+
return parseSuggestion(result.stdout)
|
|
25
|
+
} catch {
|
|
26
|
+
return null
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function defaultSpawn(
|
|
31
|
+
invocation: HeadlessInvocation,
|
|
32
|
+
signal: AbortSignal
|
|
33
|
+
): Promise<{ stdout: string; exitCode: number } | null> {
|
|
34
|
+
try {
|
|
35
|
+
const proc = Bun.spawn([invocation.executable, ...invocation.args], {
|
|
36
|
+
stderr: 'ignore',
|
|
37
|
+
stdin: 'ignore',
|
|
38
|
+
stdout: 'pipe',
|
|
39
|
+
})
|
|
40
|
+
const onAbort = () => {
|
|
41
|
+
try {
|
|
42
|
+
proc.kill()
|
|
43
|
+
} catch {
|
|
44
|
+
// ignore
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
signal.addEventListener('abort', onAbort, { once: true })
|
|
48
|
+
const [stdout, exitCode] = await Promise.all([new Response(proc.stdout).text(), proc.exited])
|
|
49
|
+
signal.removeEventListener('abort', onAbort)
|
|
50
|
+
if (signal.aborted) return null
|
|
51
|
+
return { exitCode: exitCode ?? 1, stdout }
|
|
52
|
+
} catch {
|
|
53
|
+
return null
|
|
54
|
+
}
|
|
55
|
+
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import type { GitFileEntry, GitRefreshPayload } from '../state/types'
|
|
2
|
+
|
|
3
|
+
import { diffHash } from '../git/diff-hash'
|
|
4
|
+
|
|
5
|
+
function fileKey(f: GitFileEntry): string {
|
|
6
|
+
return [f.path, f.status, f.section, f.added ?? '∅', f.removed ?? '∅'].join('|')
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function workingTreeHash(payload: GitRefreshPayload): string {
|
|
10
|
+
const sortedFiles = [...payload.files].sort((x, y) => {
|
|
11
|
+
const kx = fileKey(x)
|
|
12
|
+
const ky = fileKey(y)
|
|
13
|
+
if (kx < ky) return -1
|
|
14
|
+
if (kx > ky) return 1
|
|
15
|
+
return 0
|
|
16
|
+
})
|
|
17
|
+
const body = [
|
|
18
|
+
payload.branch ?? '∅',
|
|
19
|
+
String(payload.ahead),
|
|
20
|
+
String(payload.behind),
|
|
21
|
+
sortedFiles.map(fileKey).join('\n'),
|
|
22
|
+
].join('\n---\n')
|
|
23
|
+
return diffHash(body)
|
|
24
|
+
}
|
package/src/config.ts
CHANGED
|
@@ -22,7 +22,14 @@ export interface PersistedGitPane {
|
|
|
22
22
|
visible: boolean
|
|
23
23
|
mode: 'embedded' | 'pane'
|
|
24
24
|
position: 'top' | 'bottom' | 'left' | 'right'
|
|
25
|
-
|
|
25
|
+
paneRatio?: number
|
|
26
|
+
embeddedRatio?: number
|
|
27
|
+
ratio?: number
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export interface PersistedSidebar {
|
|
31
|
+
visible: boolean
|
|
32
|
+
width: number
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
export interface AimuxConfig {
|
|
@@ -31,6 +38,7 @@ export interface AimuxConfig {
|
|
|
31
38
|
themeId?: ThemeId
|
|
32
39
|
themeTransparent?: boolean
|
|
33
40
|
gitPane?: PersistedGitPane
|
|
41
|
+
sidebar?: PersistedSidebar
|
|
34
42
|
sessionBarVisible?: boolean
|
|
35
43
|
sessionBarPosition?: SessionBarPosition
|
|
36
44
|
workspaceSnapshot?: WorkspaceSnapshotV1
|
|
@@ -47,7 +55,20 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
|
|
|
47
55
|
v.position === 'left' ||
|
|
48
56
|
v.position === 'right'
|
|
49
57
|
const ratioOk =
|
|
50
|
-
|
|
58
|
+
v.ratio === undefined ||
|
|
59
|
+
(typeof v.ratio === 'number' && Number.isFinite(v.ratio) && v.ratio > 0 && v.ratio < 1)
|
|
60
|
+
const paneRatioOk =
|
|
61
|
+
v.paneRatio === undefined ||
|
|
62
|
+
(typeof v.paneRatio === 'number' &&
|
|
63
|
+
Number.isFinite(v.paneRatio) &&
|
|
64
|
+
v.paneRatio > 0 &&
|
|
65
|
+
v.paneRatio < 1)
|
|
66
|
+
const embeddedRatioOk =
|
|
67
|
+
v.embeddedRatio === undefined ||
|
|
68
|
+
(typeof v.embeddedRatio === 'number' &&
|
|
69
|
+
Number.isFinite(v.embeddedRatio) &&
|
|
70
|
+
v.embeddedRatio > 0 &&
|
|
71
|
+
v.embeddedRatio < 1)
|
|
51
72
|
const diffModeRatioOk =
|
|
52
73
|
v.diffModeRatio === undefined ||
|
|
53
74
|
(typeof v.diffModeRatio === 'number' &&
|
|
@@ -68,6 +89,8 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
|
|
|
68
89
|
!modeOk ||
|
|
69
90
|
!positionOk ||
|
|
70
91
|
!ratioOk ||
|
|
92
|
+
!paneRatioOk ||
|
|
93
|
+
!embeddedRatioOk ||
|
|
71
94
|
!diffModeRatioOk ||
|
|
72
95
|
!visibleOk ||
|
|
73
96
|
!fileListModeOk ||
|
|
@@ -82,6 +105,17 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
|
|
|
82
105
|
return true
|
|
83
106
|
}
|
|
84
107
|
|
|
108
|
+
function isPersistedSidebar(value: unknown): value is PersistedSidebar {
|
|
109
|
+
if (typeof value !== 'object' || value === null) return false
|
|
110
|
+
const v = value as Record<string, unknown>
|
|
111
|
+
return (
|
|
112
|
+
typeof v.visible === 'boolean' &&
|
|
113
|
+
typeof v.width === 'number' &&
|
|
114
|
+
Number.isFinite(v.width) &&
|
|
115
|
+
v.width > 0
|
|
116
|
+
)
|
|
117
|
+
}
|
|
118
|
+
|
|
85
119
|
const DEFAULT_CONFIG: AimuxConfig = {
|
|
86
120
|
customCommands: {},
|
|
87
121
|
version: 2,
|
|
@@ -121,6 +155,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
121
155
|
themeId?: unknown
|
|
122
156
|
themeTransparent?: unknown
|
|
123
157
|
gitPane?: unknown
|
|
158
|
+
sidebar?: unknown
|
|
124
159
|
gitPanelVisible?: unknown
|
|
125
160
|
gitPanelRatio?: unknown
|
|
126
161
|
sessionBarVisible?: unknown
|
|
@@ -154,6 +189,11 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
154
189
|
issues.push('ignored invalid gitPane')
|
|
155
190
|
}
|
|
156
191
|
|
|
192
|
+
const validSidebar = isPersistedSidebar(parsed.sidebar) ? parsed.sidebar : undefined
|
|
193
|
+
if (parsed.sidebar !== undefined && validSidebar === undefined) {
|
|
194
|
+
issues.push('ignored invalid sidebar')
|
|
195
|
+
}
|
|
196
|
+
|
|
157
197
|
// Legacy migration: previous schema stored gitPanelVisible/gitPanelRatio at
|
|
158
198
|
// top level. If the new `gitPane` field is absent, synthesize it from legacy
|
|
159
199
|
// keys so users don't lose their toggle/ratio on upgrade.
|
|
@@ -169,7 +209,9 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
169
209
|
: undefined
|
|
170
210
|
if (legacyVisible !== undefined || legacyRatio !== undefined) {
|
|
171
211
|
validGitPane = {
|
|
212
|
+
embeddedRatio: legacyRatio ?? 0.5,
|
|
172
213
|
mode: 'embedded',
|
|
214
|
+
paneRatio: legacyRatio ?? 0.5,
|
|
173
215
|
position: 'bottom',
|
|
174
216
|
ratio: legacyRatio ?? 0.5,
|
|
175
217
|
visible: legacyVisible ?? true,
|
|
@@ -216,6 +258,7 @@ export function loadConfigResult(): ConfigLoadResult {
|
|
|
216
258
|
gitPane: validGitPane,
|
|
217
259
|
sessionBarPosition: validSessionBarPosition,
|
|
218
260
|
sessionBarVisible: validSessionBarVisible,
|
|
261
|
+
sidebar: validSidebar,
|
|
219
262
|
skippedUpdateVersion: validSkippedUpdateVersion,
|
|
220
263
|
themeId: migrateThemeId(parsed.themeId),
|
|
221
264
|
themeTransparent: validThemeTransparent,
|
|
@@ -89,6 +89,7 @@ export class SessionRegistry extends EventEmitter<SessionRegistryEvents> {
|
|
|
89
89
|
const existing = this.tabs.get(persisted.id)
|
|
90
90
|
if (existing) {
|
|
91
91
|
existing.title = persisted.title
|
|
92
|
+
existing.scrollIntent = persisted.scrollIntent ?? DEFAULT_SCROLL_INTENT
|
|
92
93
|
}
|
|
93
94
|
}
|
|
94
95
|
if (snapshot.activeTabId && this.tabs.has(snapshot.activeTabId)) {
|
package/src/index.tsx
CHANGED
|
@@ -52,7 +52,7 @@ if (command === 'terminal-manager') {
|
|
|
52
52
|
|
|
53
53
|
if (command === '--help' || command === '-h') {
|
|
54
54
|
process.stdout.write(
|
|
55
|
-
'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart IPC daemon\n aimux restart-terminal-manager Restart terminal-manager (kills live
|
|
55
|
+
'aimux -- terminal multiplexer for AI CLIs\n\nUsage:\n aimux Start aimux\n aimux update Update to latest version\n aimux doctor Diagnose setup issues\n aimux restart-daemon Restart IPC daemon\n aimux restart-terminal-manager Restart terminal-manager (kills live workspaces)\n\n'
|
|
56
56
|
)
|
|
57
57
|
process.exit(0)
|
|
58
58
|
}
|
|
@@ -14,10 +14,10 @@ export const HELP_MODE_LABELS: { modeId: ModeId; label: string }[] = [
|
|
|
14
14
|
{ label: 'Git commit', modeId: 'modal.git-commit' },
|
|
15
15
|
{ label: 'New tab', modeId: 'modal.new-tab.command-edit' },
|
|
16
16
|
{ label: 'New tab — command', modeId: 'modal.new-tab.command-edit' },
|
|
17
|
-
{ label: '
|
|
18
|
-
{ label: '
|
|
19
|
-
{ label: '
|
|
20
|
-
{ label: 'Create
|
|
17
|
+
{ label: 'Workspace picker', modeId: 'modal.session-picker.filtering' },
|
|
18
|
+
{ label: 'Workspace picker — filter', modeId: 'modal.session-picker.filtering' },
|
|
19
|
+
{ label: 'Workspace name', modeId: 'modal.session-name' },
|
|
20
|
+
{ label: 'Create workspace', modeId: 'modal.create-session' },
|
|
21
21
|
{ label: 'Rename tab', modeId: 'modal.rename-tab' },
|
|
22
22
|
{ label: 'Snippet picker', modeId: 'modal.snippet-picker.filtering' },
|
|
23
23
|
{ label: 'Snippet picker — filter', modeId: 'modal.snippet-picker.filtering' },
|
|
@@ -43,6 +43,9 @@ export function deriveModeId(state: AppState): ModeId {
|
|
|
43
43
|
if (state.modal.type === 'new-tab' && state.modal.editingCommand !== null) {
|
|
44
44
|
return 'modal.new-tab.editing-command'
|
|
45
45
|
}
|
|
46
|
+
if (state.modal.type === 'git-commit' && state.modal.stage === 'confirm') {
|
|
47
|
+
return 'modal.git-commit.confirm'
|
|
48
|
+
}
|
|
46
49
|
const modalType = state.modal.type
|
|
47
50
|
const commandEditMode = modalType ? COMMAND_EDIT_MODE_IDS[modalType] : undefined
|
|
48
51
|
if (commandEditMode) {
|
|
@@ -53,6 +56,9 @@ export function deriveModeId(state: AppState): ModeId {
|
|
|
53
56
|
}
|
|
54
57
|
|
|
55
58
|
if (state.focusMode === 'modal') {
|
|
59
|
+
if (state.modal.type === 'git-commit' && state.modal.stage === 'generating') {
|
|
60
|
+
return 'modal.git-commit.generating'
|
|
61
|
+
}
|
|
56
62
|
const modalType = state.modal.type
|
|
57
63
|
const modalMode = modalType ? MODAL_MODE_IDS[modalType] : undefined
|
|
58
64
|
if (modalMode) {
|
|
@@ -3,7 +3,9 @@ import type { ModeId } from './types'
|
|
|
3
3
|
const TRANSITIONS: Record<ModeId, readonly ModeId[]> = {
|
|
4
4
|
'git-mode': ['navigation', 'modal.git-commit'],
|
|
5
5
|
'modal.create-session': ['navigation', 'modal.session-picker.filtering'],
|
|
6
|
-
'modal.git-commit': ['git-mode'],
|
|
6
|
+
'modal.git-commit': ['git-mode', 'modal.git-commit.confirm', 'modal.git-commit.generating'],
|
|
7
|
+
'modal.git-commit.confirm': ['modal.git-commit', 'git-mode'],
|
|
8
|
+
'modal.git-commit.generating': ['modal.git-commit', 'modal.git-commit.confirm', 'git-mode'],
|
|
7
9
|
'modal.help.filtering': ['navigation'],
|
|
8
10
|
'modal.new-tab.command-edit': ['navigation', 'modal.new-tab.editing-command'],
|
|
9
11
|
'modal.new-tab.editing-command': ['navigation', 'modal.new-tab.command-edit'],
|
package/src/input/modes/types.ts
CHANGED
|
@@ -18,6 +18,8 @@ export type ModeId =
|
|
|
18
18
|
| 'modal.help.filtering'
|
|
19
19
|
| 'modal.split-picker'
|
|
20
20
|
| 'modal.git-commit'
|
|
21
|
+
| 'modal.git-commit.confirm'
|
|
22
|
+
| 'modal.git-commit.generating'
|
|
21
23
|
| 'modal.update-available'
|
|
22
24
|
|
|
23
25
|
export type SideEffect =
|
|
@@ -55,6 +57,8 @@ export type SideEffect =
|
|
|
55
57
|
| { type: 'git-restore'; path: string }
|
|
56
58
|
| { type: 'git-rm'; path: string }
|
|
57
59
|
| { type: 'git-commit'; title: string; body: string }
|
|
60
|
+
| { type: 'git-commit-auto'; title: string; body: string }
|
|
61
|
+
| { type: 'generate-auto-commit-now'; sessionId: string }
|
|
58
62
|
| { type: 'git-push' }
|
|
59
63
|
| { type: 'confirm-update-selection' }
|
|
60
64
|
| { type: 'switch-session-by-index'; index: number }
|
|
@@ -14,8 +14,8 @@ import {
|
|
|
14
14
|
negotiateProtocolVersion,
|
|
15
15
|
} from './protocol'
|
|
16
16
|
|
|
17
|
-
export const MANAGER_PROTOCOL_MIN_VERSION =
|
|
18
|
-
export const MANAGER_PROTOCOL_VERSION =
|
|
17
|
+
export const MANAGER_PROTOCOL_MIN_VERSION = 3
|
|
18
|
+
export const MANAGER_PROTOCOL_VERSION = 3
|
|
19
19
|
|
|
20
20
|
export interface ManagerHelloRequest {
|
|
21
21
|
minVersion: number
|
package/src/ipc/protocol.ts
CHANGED
|
@@ -10,14 +10,8 @@ import type {
|
|
|
10
10
|
|
|
11
11
|
import { isWorkspaceSnapshotV1 } from '../state/validation'
|
|
12
12
|
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
// v5 folds initial tab activities and session statuses into attachResult so
|
|
16
|
-
// the client applies them atomically with tab creation — previously they
|
|
17
|
-
// arrived as separate events and could lose to the unknown-tab no-op in
|
|
18
|
-
// the reducer.
|
|
19
|
-
export const IPC_PROTOCOL_MIN_VERSION = 6
|
|
20
|
-
export const IPC_PROTOCOL_VERSION = 6
|
|
13
|
+
export const IPC_PROTOCOL_MIN_VERSION = 7
|
|
14
|
+
export const IPC_PROTOCOL_VERSION = 7
|
|
21
15
|
|
|
22
16
|
export interface ProtocolHelloRequest {
|
|
23
17
|
minVersion: number
|
|
@@ -85,10 +85,10 @@ export class AssistantStatusDetector {
|
|
|
85
85
|
}
|
|
86
86
|
|
|
87
87
|
function extractTailText(viewport: TerminalSnapshot, lineCount: number): string {
|
|
88
|
+
const lines = viewport.tailLines ?? viewport.lines
|
|
88
89
|
// Full-screen TUIs (claude, opencode) paint in the alternate buffer and
|
|
89
90
|
// often leave the last rows blank, putting their status bar higher up.
|
|
90
91
|
// Skip trailing blank rows before taking the last `lineCount`.
|
|
91
|
-
const lines = viewport.lines
|
|
92
92
|
let end = lines.length
|
|
93
93
|
while (end > 0) {
|
|
94
94
|
const line = lines[end - 1]
|
|
@@ -4,6 +4,8 @@ import type { TerminalLine, TerminalSnapshot, TerminalSpan } from '../state/type
|
|
|
4
4
|
|
|
5
5
|
import { getCurrentTheme } from '../ui/theme'
|
|
6
6
|
|
|
7
|
+
const SNAPSHOT_TAIL_LINE_COUNT = 10
|
|
8
|
+
|
|
7
9
|
const ANSI_PALETTE = [
|
|
8
10
|
'#000000',
|
|
9
11
|
'#cd0000',
|
|
@@ -142,9 +144,11 @@ function buildLine(
|
|
|
142
144
|
export function snapshotTerminal(terminal: Terminal, cursorVisible = true): TerminalSnapshot {
|
|
143
145
|
const buffer = terminal.buffer.active
|
|
144
146
|
const startLine = buffer.viewportY
|
|
147
|
+
const tailStartLine = Math.max(0, buffer.baseY + terminal.rows - SNAPSHOT_TAIL_LINE_COUNT)
|
|
145
148
|
const cursorRow = buffer.cursorY
|
|
146
149
|
const cursorColumn = Math.min(buffer.cursorX, Math.max(terminal.cols - 1, 0))
|
|
147
150
|
const lines: TerminalLine[] = []
|
|
151
|
+
const tailLines: TerminalLine[] = []
|
|
148
152
|
|
|
149
153
|
for (let row = 0; row < terminal.rows; row += 1) {
|
|
150
154
|
lines.push(
|
|
@@ -152,10 +156,27 @@ export function snapshotTerminal(terminal: Terminal, cursorVisible = true): Term
|
|
|
152
156
|
)
|
|
153
157
|
}
|
|
154
158
|
|
|
159
|
+
for (
|
|
160
|
+
let lineIndex = tailStartLine;
|
|
161
|
+
lineIndex <= buffer.baseY + terminal.rows - 1;
|
|
162
|
+
lineIndex += 1
|
|
163
|
+
) {
|
|
164
|
+
const relativeCursorRow = lineIndex - buffer.baseY
|
|
165
|
+
tailLines.push(
|
|
166
|
+
buildLine(
|
|
167
|
+
terminal,
|
|
168
|
+
lineIndex,
|
|
169
|
+
relativeCursorRow === cursorRow ? cursorColumn : null,
|
|
170
|
+
cursorVisible
|
|
171
|
+
)
|
|
172
|
+
)
|
|
173
|
+
}
|
|
174
|
+
|
|
155
175
|
return {
|
|
156
176
|
baseY: buffer.baseY,
|
|
157
177
|
cursorVisible,
|
|
158
178
|
lines,
|
|
179
|
+
tailLines,
|
|
159
180
|
viewportY: buffer.viewportY,
|
|
160
181
|
}
|
|
161
182
|
}
|
|
@@ -195,6 +216,13 @@ export function areTerminalSnapshotsEqual(
|
|
|
195
216
|
return false
|
|
196
217
|
}
|
|
197
218
|
|
|
219
|
+
const leftTailLines = left.tailLines ?? []
|
|
220
|
+
const rightTailLines = right.tailLines ?? []
|
|
221
|
+
|
|
222
|
+
if (leftTailLines.length !== rightTailLines.length) {
|
|
223
|
+
return false
|
|
224
|
+
}
|
|
225
|
+
|
|
198
226
|
if (
|
|
199
227
|
left.viewportY !== right.viewportY ||
|
|
200
228
|
left.baseY !== right.baseY ||
|
|
@@ -203,8 +231,14 @@ export function areTerminalSnapshotsEqual(
|
|
|
203
231
|
return false
|
|
204
232
|
}
|
|
205
233
|
|
|
206
|
-
return
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
234
|
+
return (
|
|
235
|
+
left.lines.every((line, index) => {
|
|
236
|
+
const other = right.lines[index]
|
|
237
|
+
return other ? areLinesEqual(line, other) : false
|
|
238
|
+
}) &&
|
|
239
|
+
leftTailLines.every((line, index) => {
|
|
240
|
+
const other = rightTailLines[index]
|
|
241
|
+
return other ? areLinesEqual(line, other) : false
|
|
242
|
+
})
|
|
243
|
+
)
|
|
210
244
|
}
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
import type { AIUsageToolConfig } from '@brimveyn/aimux-config'
|
|
2
|
+
|
|
3
|
+
import type { UsageSnapshot } from '../types'
|
|
4
|
+
|
|
5
|
+
import { runCli } from '../spawn'
|
|
6
|
+
|
|
7
|
+
interface ClaudeOAuthCreds {
|
|
8
|
+
accessToken: string
|
|
9
|
+
expiresAt?: number
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
interface ClaudeKeychainPayload {
|
|
13
|
+
claudeAiOauth?: {
|
|
14
|
+
accessToken?: string
|
|
15
|
+
expiresAt?: number
|
|
16
|
+
refreshToken?: string
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
interface UsageWindow {
|
|
21
|
+
utilization?: number
|
|
22
|
+
resets_at?: string
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
interface ClaudeUsageResponse {
|
|
26
|
+
five_hour?: UsageWindow
|
|
27
|
+
seven_day?: UsageWindow
|
|
28
|
+
seven_day_sonnet?: UsageWindow
|
|
29
|
+
seven_day_opus?: UsageWindow
|
|
30
|
+
extra_usage?: UsageWindow & { spent_usd?: number; limit_usd?: number }
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
const USAGE_URL = 'https://api.anthropic.com/api/oauth/usage'
|
|
34
|
+
const OAUTH_BETA_HEADER = 'oauth-2025-04-20'
|
|
35
|
+
const FETCH_TIMEOUT_MS = 15_000
|
|
36
|
+
|
|
37
|
+
let cachedCreds: ClaudeOAuthCreds | null = null
|
|
38
|
+
const CREDS_EXPIRY_BUFFER_MS = 60_000
|
|
39
|
+
|
|
40
|
+
async function readClaudeCreds(): Promise<ClaudeOAuthCreds> {
|
|
41
|
+
const now = Date.now()
|
|
42
|
+
if (
|
|
43
|
+
cachedCreds &&
|
|
44
|
+
typeof cachedCreds.expiresAt === 'number' &&
|
|
45
|
+
cachedCreds.expiresAt - CREDS_EXPIRY_BUFFER_MS > now
|
|
46
|
+
) {
|
|
47
|
+
return cachedCreds
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
if (process.platform !== 'darwin') {
|
|
51
|
+
throw new Error('claude usage requires macOS keychain (darwin only)')
|
|
52
|
+
}
|
|
53
|
+
const result = await runCli('security', [
|
|
54
|
+
'find-generic-password',
|
|
55
|
+
'-s',
|
|
56
|
+
'Claude Code-credentials',
|
|
57
|
+
'-w',
|
|
58
|
+
])
|
|
59
|
+
if (!result.ok) {
|
|
60
|
+
throw new Error(`keychain read failed — run \`claude\` to sign in`)
|
|
61
|
+
}
|
|
62
|
+
const parsed = JSON.parse(result.stdout.trim()) as ClaudeKeychainPayload
|
|
63
|
+
const access = parsed.claudeAiOauth?.accessToken
|
|
64
|
+
if (!access) {
|
|
65
|
+
throw new Error('no accessToken in Claude Code keychain')
|
|
66
|
+
}
|
|
67
|
+
cachedCreds = { accessToken: access, expiresAt: parsed.claudeAiOauth?.expiresAt }
|
|
68
|
+
return cachedCreds
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatRemainingFromIso(iso: string | undefined): string | null {
|
|
72
|
+
if (!iso) return null
|
|
73
|
+
const ms = new Date(iso).getTime() - Date.now()
|
|
74
|
+
if (ms <= 0) return null
|
|
75
|
+
const totalMin = Math.round(ms / 60_000)
|
|
76
|
+
const h = Math.floor(totalMin / 60)
|
|
77
|
+
const m = totalMin % 60
|
|
78
|
+
return `${String(h).padStart(2, '0')}:${String(m).padStart(2, '0')}h`
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
export async function fetchClaudeUsage(_config: AIUsageToolConfig): Promise<UsageSnapshot> {
|
|
82
|
+
const now = new Date().toISOString()
|
|
83
|
+
const base: UsageSnapshot = {
|
|
84
|
+
burnRatePerHour: null,
|
|
85
|
+
costUSD: null,
|
|
86
|
+
lastUpdated: now,
|
|
87
|
+
percent: null,
|
|
88
|
+
resetAt: null,
|
|
89
|
+
timeRemaining: null,
|
|
90
|
+
tokens: { cache: 0, input: 0, output: 0, total: 0 },
|
|
91
|
+
tool: 'claude',
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
try {
|
|
95
|
+
const creds = await readClaudeCreds()
|
|
96
|
+
|
|
97
|
+
const controller = new AbortController()
|
|
98
|
+
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS)
|
|
99
|
+
let response: Response
|
|
100
|
+
try {
|
|
101
|
+
response = await fetch(USAGE_URL, {
|
|
102
|
+
headers: {
|
|
103
|
+
'anthropic-beta': OAUTH_BETA_HEADER,
|
|
104
|
+
'Authorization': `Bearer ${creds.accessToken}`,
|
|
105
|
+
'Content-Type': 'application/json',
|
|
106
|
+
},
|
|
107
|
+
signal: controller.signal,
|
|
108
|
+
})
|
|
109
|
+
} finally {
|
|
110
|
+
clearTimeout(timer)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
if (response.status === 401 || response.status === 403) {
|
|
114
|
+
cachedCreds = null
|
|
115
|
+
return { ...base, error: 'claude oauth expired — run `claude` to re-auth' }
|
|
116
|
+
}
|
|
117
|
+
if (!response.ok) {
|
|
118
|
+
return { ...base, error: `claude api ${response.status}` }
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
const parsed = (await response.json()) as ClaudeUsageResponse
|
|
122
|
+
const fiveHour = parsed.five_hour
|
|
123
|
+
const utilization = typeof fiveHour?.utilization === 'number' ? fiveHour.utilization : null
|
|
124
|
+
const percent = utilization === null ? null : Math.max(0, Math.min(100, utilization))
|
|
125
|
+
|
|
126
|
+
return {
|
|
127
|
+
...base,
|
|
128
|
+
percent,
|
|
129
|
+
resetAt: fiveHour?.resets_at ?? null,
|
|
130
|
+
timeRemaining: formatRemainingFromIso(fiveHour?.resets_at),
|
|
131
|
+
tool: 'claude',
|
|
132
|
+
}
|
|
133
|
+
} catch (error) {
|
|
134
|
+
return {
|
|
135
|
+
...base,
|
|
136
|
+
error: error instanceof Error ? error.message : String(error),
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
}
|