@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.
Files changed (62) hide show
  1. package/README.md +27 -15
  2. package/package.json +2 -2
  3. package/src/app-runtime/auto-commit-driver.ts +286 -0
  4. package/src/app-runtime/auto-commit-ref.ts +20 -0
  5. package/src/app-runtime/backend-attach-runtime.ts +3 -1
  6. package/src/app-runtime/session-actions.ts +7 -2
  7. package/src/app-runtime/side-effects.ts +111 -4
  8. package/src/app-runtime/split-drag-controller.ts +31 -1
  9. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  10. package/src/app-runtime/use-mouse-handlers.ts +93 -5
  11. package/src/app-runtime/use-terminal-resize.ts +24 -16
  12. package/src/app.tsx +71 -19
  13. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  14. package/src/auto-commit/headless-commands.ts +40 -0
  15. package/src/auto-commit/output-parser.ts +21 -0
  16. package/src/auto-commit/prompt-loader.ts +33 -0
  17. package/src/auto-commit/staging-mode.ts +5 -0
  18. package/src/auto-commit/strip-ansi.ts +13 -0
  19. package/src/auto-commit/suggestion-runner.ts +55 -0
  20. package/src/auto-commit/working-tree-hash.ts +24 -0
  21. package/src/config.ts +45 -2
  22. package/src/daemon/session-registry.ts +1 -0
  23. package/src/index.tsx +1 -1
  24. package/src/input/keymap/help-entries.ts +4 -4
  25. package/src/input/modes/bridge.ts +6 -0
  26. package/src/input/modes/transitions.ts +3 -1
  27. package/src/input/modes/types.ts +4 -0
  28. package/src/ipc/manager-protocol.ts +2 -2
  29. package/src/ipc/protocol.ts +2 -8
  30. package/src/pty/assistant-status-detector.ts +1 -1
  31. package/src/pty/terminal-snapshot.ts +38 -4
  32. package/src/services/ai-usage/adapters/claude.ts +139 -0
  33. package/src/services/ai-usage/adapters/codex.ts +191 -0
  34. package/src/services/ai-usage/provider.ts +84 -0
  35. package/src/services/ai-usage/spawn.ts +49 -0
  36. package/src/services/ai-usage/types.ts +20 -0
  37. package/src/session-backend/local-session-backend.ts +10 -2
  38. package/src/state/ai-usage-store.ts +29 -0
  39. package/src/state/git-pane-sizing.ts +15 -0
  40. package/src/state/reducers/auto-commit-state.ts +59 -0
  41. package/src/state/reducers/git-panel-state.ts +12 -7
  42. package/src/state/reducers/modal-state.ts +106 -2
  43. package/src/state/reducers/session-state.ts +26 -14
  44. package/src/state/reducers/ui-state.ts +6 -0
  45. package/src/state/session-persistence.ts +14 -5
  46. package/src/state/store.ts +26 -15
  47. package/src/state/types.ts +59 -3
  48. package/src/state/workspace-save.ts +6 -1
  49. package/src/ui/ai-usage/controller.ts +35 -0
  50. package/src/ui/components/ai-usage-indicator.tsx +131 -0
  51. package/src/ui/components/ai-usage-popover.tsx +152 -0
  52. package/src/ui/components/context-menu-overlay.tsx +4 -2
  53. package/src/ui/components/create-session-modal.tsx +2 -2
  54. package/src/ui/components/git-commit-modal.tsx +167 -18
  55. package/src/ui/components/git-pane-context-menu.ts +26 -0
  56. package/src/ui/components/session-bar.tsx +2 -2
  57. package/src/ui/components/session-picker-modal.tsx +4 -4
  58. package/src/ui/components/sidebar.tsx +117 -31
  59. package/src/ui/components/status-bar.tsx +2 -0
  60. package/src/ui/components/terminal-pane.tsx +18 -3
  61. package/src/ui/root.tsx +114 -13
  62. 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,5 @@
1
+ import type { GitRefreshPayload } from '../state/types'
2
+
3
+ export function hasStagedFiles(git: GitRefreshPayload): boolean {
4
+ return git.files.some((f) => f.section === 'staged')
5
+ }
@@ -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
- ratio: number
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
- typeof v.ratio === 'number' && Number.isFinite(v.ratio) && v.ratio > 0 && v.ratio < 1
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 sessions)\n\n'
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: 'Session picker', modeId: 'modal.session-picker.filtering' },
18
- { label: 'Session picker — filter', modeId: 'modal.session-picker.filtering' },
19
- { label: 'Session name', modeId: 'modal.session-name' },
20
- { label: 'Create session', modeId: 'modal.create-session' },
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'],
@@ -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 = 2
18
- export const MANAGER_PROTOCOL_VERSION = 2
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
@@ -10,14 +10,8 @@ import type {
10
10
 
11
11
  import { isWorkspaceSnapshotV1 } from '../state/validation'
12
12
 
13
- // v4 widens sessionStatus from a single status enum to independent
14
- // {working, waiting} flags so a chip can show both at once.
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 left.lines.every((line, index) => {
207
- const other = right.lines[index]
208
- return other ? areLinesEqual(line, other) : false
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
+ }