@brimveyn/aimux 1.7.4 → 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 (55) 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 +99 -1
  8. package/src/app-runtime/use-auto-commit-driver.ts +133 -0
  9. package/src/app-runtime/use-terminal-resize.ts +20 -12
  10. package/src/app.tsx +59 -22
  11. package/src/auto-commit/default-auto-commit-prompt.md +46 -0
  12. package/src/auto-commit/headless-commands.ts +40 -0
  13. package/src/auto-commit/output-parser.ts +21 -0
  14. package/src/auto-commit/prompt-loader.ts +33 -0
  15. package/src/auto-commit/staging-mode.ts +5 -0
  16. package/src/auto-commit/strip-ansi.ts +13 -0
  17. package/src/auto-commit/suggestion-runner.ts +55 -0
  18. package/src/auto-commit/working-tree-hash.ts +24 -0
  19. package/src/config.ts +24 -0
  20. package/src/daemon/session-registry.ts +1 -0
  21. package/src/index.tsx +1 -1
  22. package/src/input/keymap/help-entries.ts +4 -4
  23. package/src/input/modes/bridge.ts +6 -0
  24. package/src/input/modes/transitions.ts +3 -1
  25. package/src/input/modes/types.ts +4 -0
  26. package/src/ipc/manager-protocol.ts +2 -2
  27. package/src/ipc/protocol.ts +2 -8
  28. package/src/pty/assistant-status-detector.ts +1 -1
  29. package/src/pty/terminal-snapshot.ts +38 -4
  30. package/src/services/ai-usage/adapters/claude.ts +139 -0
  31. package/src/services/ai-usage/adapters/codex.ts +191 -0
  32. package/src/services/ai-usage/provider.ts +84 -0
  33. package/src/services/ai-usage/spawn.ts +49 -0
  34. package/src/services/ai-usage/types.ts +20 -0
  35. package/src/session-backend/local-session-backend.ts +10 -2
  36. package/src/state/ai-usage-store.ts +29 -0
  37. package/src/state/reducers/auto-commit-state.ts +59 -0
  38. package/src/state/reducers/modal-state.ts +106 -2
  39. package/src/state/reducers/session-state.ts +26 -14
  40. package/src/state/session-persistence.ts +14 -5
  41. package/src/state/store.ts +24 -14
  42. package/src/state/types.ts +55 -2
  43. package/src/state/workspace-save.ts +4 -0
  44. package/src/ui/ai-usage/controller.ts +35 -0
  45. package/src/ui/components/ai-usage-indicator.tsx +131 -0
  46. package/src/ui/components/ai-usage-popover.tsx +152 -0
  47. package/src/ui/components/create-session-modal.tsx +2 -2
  48. package/src/ui/components/git-commit-modal.tsx +167 -18
  49. package/src/ui/components/session-bar.tsx +2 -2
  50. package/src/ui/components/session-picker-modal.tsx +4 -4
  51. package/src/ui/components/sidebar.tsx +3 -1
  52. package/src/ui/components/status-bar.tsx +2 -0
  53. package/src/ui/components/terminal-pane.tsx +18 -3
  54. package/src/ui/root.tsx +13 -2
  55. package/src/ui/status-bar-model.ts +1 -1
@@ -12,6 +12,7 @@ import {
12
12
  getSnapshotTrees,
13
13
  toTerminalContentSize,
14
14
  } from '../state/layout-resize'
15
+ import { getSnapshotScrollIntents } from '../state/session-persistence'
15
16
 
16
17
  export class LocalSessionBackend
17
18
  extends EventEmitter<SessionBackendEvents>
@@ -66,15 +67,22 @@ export class LocalSessionBackend
66
67
  })
67
68
  this.currentSessionId = options.sessionId
68
69
  const trees = getSnapshotTrees(options.workspaceSnapshot)
70
+ const intents = getSnapshotScrollIntents(options.workspaceSnapshot)
69
71
  const splitTrees = trees.filter((t) => t.type === 'split')
70
72
  if (splitTrees.length > 0) {
71
73
  const bounds = createTerminalBounds(options.cols, options.rows)
72
74
  forEachSplitPaneRect(splitTrees, bounds, (tabId, rect) => {
73
75
  const size = toTerminalContentSize(rect)
74
- this.sessionManager.resizeTab(options.sessionId, tabId, size.cols, size.rows)
76
+ this.sessionManager.resizeTab(
77
+ options.sessionId,
78
+ tabId,
79
+ size.cols,
80
+ size.rows,
81
+ intents.get(tabId)
82
+ )
75
83
  })
76
84
  } else {
77
- this.sessionManager.resize(options.sessionId, options.cols, options.rows)
85
+ this.sessionManager.resize(options.sessionId, options.cols, options.rows, intents)
78
86
  }
79
87
  const attachResult = this.sessionManager.attachSession(
80
88
  options.sessionId,
@@ -0,0 +1,29 @@
1
+ import type { AIUsageTool } from '@brimveyn/aimux-config'
2
+
3
+ import { useStore } from 'zustand'
4
+ import { createStore } from 'zustand/vanilla'
5
+
6
+ import type { UsageSnapshot } from '../services/ai-usage/types'
7
+
8
+ export interface AIUsageState {
9
+ enabled: boolean
10
+ snapshots: Partial<Record<AIUsageTool, UsageSnapshot>>
11
+ setEnabled: (enabled: boolean) => void
12
+ setSnapshot: (snap: UsageSnapshot) => void
13
+ clear: () => void
14
+ }
15
+
16
+ export const aiUsageStore = createStore<AIUsageState>((set) => ({
17
+ clear: () => set({ snapshots: {} }),
18
+ enabled: false,
19
+ setEnabled: (enabled: boolean) => set({ enabled }),
20
+ setSnapshot: (snap: UsageSnapshot) =>
21
+ set((state) => ({
22
+ snapshots: { ...state.snapshots, [snap.tool]: snap },
23
+ })),
24
+ snapshots: {},
25
+ }))
26
+
27
+ export function useAIUsageStore<T>(selector: (state: AIUsageState) => T): T {
28
+ return useStore(aiUsageStore, selector)
29
+ }
@@ -0,0 +1,59 @@
1
+ import type { AppAction, AppState, AutoCommitState } from '../types'
2
+
3
+ function setBySession(
4
+ state: AutoCommitState,
5
+ sessionId: string,
6
+ next: AutoCommitState['bySession'][string]
7
+ ): AutoCommitState {
8
+ return { bySession: { ...state.bySession, [sessionId]: next } }
9
+ }
10
+
11
+ export function reduceAutoCommitState(
12
+ state: AutoCommitState,
13
+ action: AppAction
14
+ ): AutoCommitState | null {
15
+ switch (action.type) {
16
+ case 'auto-commit-generation-started': {
17
+ return setBySession(state, action.sessionId, {
18
+ abortController: action.abortController,
19
+ kind: 'generating',
20
+ startedAt: action.startedAt,
21
+ tabId: action.tabId,
22
+ workingTreeHash: action.workingTreeHash,
23
+ })
24
+ }
25
+ case 'auto-commit-generation-ready': {
26
+ const current = state.bySession[action.sessionId]
27
+ if (!current || current.kind !== 'generating') return null
28
+ if (current.workingTreeHash !== action.workingTreeHash) return null
29
+ return setBySession(state, action.sessionId, {
30
+ body: action.body,
31
+ generatedAt: action.generatedAt,
32
+ kind: 'ready',
33
+ tabId: current.tabId,
34
+ title: action.title,
35
+ workingTreeHash: current.workingTreeHash,
36
+ })
37
+ }
38
+ case 'auto-commit-clear': {
39
+ const current = state.bySession[action.sessionId]
40
+ if (!current || current.kind === 'idle') return null
41
+ if (current.kind === 'generating') {
42
+ try {
43
+ current.abortController.abort()
44
+ } catch {
45
+ // ignore
46
+ }
47
+ }
48
+ return setBySession(state, action.sessionId, { kind: 'idle' })
49
+ }
50
+ default:
51
+ return null
52
+ }
53
+ }
54
+
55
+ export function reduceAutoCommit(state: AppState, action: AppAction): AppState | null {
56
+ const next = reduceAutoCommitState(state.autoCommit, action)
57
+ if (next === null) return null
58
+ return { ...state, autoCommit: next }
59
+ }
@@ -7,6 +7,7 @@ import { getActiveKeymap } from '../../input/keymap/keymap-ref'
7
7
  import { getAllAssistantOptions } from '../../pty/command-registry'
8
8
  import { filterThemeIds } from '../../ui/filter-themes'
9
9
  import { filterAssistants, filterSessions, filterSnippets } from '../selectors'
10
+ import { reduceAutoCommitState } from './auto-commit-state'
10
11
 
11
12
  function emptyModal() {
12
13
  return {
@@ -199,7 +200,8 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
199
200
  type: 'update-available',
200
201
  },
201
202
  }
202
- case 'open-git-commit-modal':
203
+ case 'open-git-commit-modal': {
204
+ const sessionId = action.sessionId
203
205
  return {
204
206
  ...state,
205
207
  focusMode: 'command-edit',
@@ -209,10 +211,112 @@ export function reduceModalState(state: AppState, action: AppAction): AppState |
209
211
  cursorPos: 0,
210
212
  editBuffer: '',
211
213
  selectedIndex: 0,
212
- sessionTargetId: null,
214
+ sessionTargetId: sessionId ?? null,
215
+ stage: 'edit',
213
216
  type: 'git-commit',
214
217
  },
215
218
  }
219
+ }
220
+ case 'git-commit-enter-confirm': {
221
+ if (state.modal.type !== 'git-commit') return state
222
+ return {
223
+ ...state,
224
+ focusMode: 'command-edit',
225
+ modal: { ...state.modal, stage: 'confirm' },
226
+ }
227
+ }
228
+ case 'git-commit-leave-confirm': {
229
+ if (state.modal.type !== 'git-commit') return state
230
+ return {
231
+ ...state,
232
+ focusMode: 'command-edit',
233
+ modal: { ...state.modal, stage: 'edit' },
234
+ }
235
+ }
236
+ case 'git-commit-enter-generating': {
237
+ if (state.modal.type !== 'git-commit') return state
238
+ return {
239
+ ...state,
240
+ focusMode: 'modal',
241
+ modal: { ...state.modal, sessionTargetId: action.sessionId, stage: 'generating' },
242
+ }
243
+ }
244
+ case 'git-commit-leave-generating': {
245
+ if (state.modal.type !== 'git-commit') return state
246
+ return {
247
+ ...state,
248
+ focusMode: 'command-edit',
249
+ modal: { ...state.modal, stage: 'edit' },
250
+ }
251
+ }
252
+ case 'auto-commit-generation-ready': {
253
+ if (
254
+ state.modal.type !== 'git-commit' ||
255
+ state.modal.stage !== 'generating' ||
256
+ state.modal.sessionTargetId !== action.sessionId
257
+ ) {
258
+ return null
259
+ }
260
+ const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
261
+ if (!nextAutoCommit) {
262
+ // Stale result (hash mismatch or slice was cleared mid-flight): don't
263
+ // strand the modal in `generating` — flip back to edit so the user
264
+ // isn't stuck staring at a spinner that will never resolve.
265
+ return {
266
+ ...state,
267
+ focusMode: 'command-edit',
268
+ modal: { ...state.modal, stage: 'edit' },
269
+ }
270
+ }
271
+ return {
272
+ ...state,
273
+ autoCommit: nextAutoCommit,
274
+ focusMode: 'command-edit',
275
+ modal: {
276
+ ...state.modal,
277
+ activeField: 'title',
278
+ contentBuffer: action.body,
279
+ cursorPos: action.title.length,
280
+ editBuffer: action.title,
281
+ stage: 'confirm',
282
+ },
283
+ }
284
+ }
285
+ case 'git-commit-use-background-suggestion': {
286
+ if (state.modal.type !== 'git-commit' || state.modal.sessionTargetId !== action.sessionId) {
287
+ return null
288
+ }
289
+ const suggestion = state.autoCommit.bySession[action.sessionId]
290
+ if (!suggestion || suggestion.kind !== 'ready') return null
291
+ return {
292
+ ...state,
293
+ focusMode: 'command-edit',
294
+ modal: {
295
+ ...state.modal,
296
+ activeField: 'title',
297
+ contentBuffer: suggestion.body,
298
+ cursorPos: suggestion.title.length,
299
+ editBuffer: suggestion.title,
300
+ stage: 'confirm',
301
+ },
302
+ }
303
+ }
304
+ case 'auto-commit-clear': {
305
+ if (
306
+ state.modal.type !== 'git-commit' ||
307
+ state.modal.stage !== 'generating' ||
308
+ state.modal.sessionTargetId !== action.sessionId
309
+ ) {
310
+ return null
311
+ }
312
+ const nextAutoCommit = reduceAutoCommitState(state.autoCommit, action)
313
+ return {
314
+ ...state,
315
+ autoCommit: nextAutoCommit ?? state.autoCommit,
316
+ focusMode: 'command-edit',
317
+ modal: { ...state.modal, stage: 'edit' },
318
+ }
319
+ }
216
320
  case 'set-help-entry-count': {
217
321
  if (state.modal.type !== 'help') return state
218
322
  if (state.modal.entryCount === action.count) {
@@ -53,27 +53,39 @@ export function reduceSessionState(state: AppState, action: AppAction): AppState
53
53
  ),
54
54
  }
55
55
  case 'delete-session-record': {
56
+ const deletingCurrent = action.sessionId === state.currentSessionId
56
57
  const newSessions = state.sessions.filter((session) => session.id !== action.sessionId)
57
- const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
58
- const maxIndex = filteredNew.length
59
- const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
60
58
  const nextStatuses = { ...state.sessionStatuses }
61
59
  delete nextStatuses[action.sessionId]
60
+ if (action.openSessionPicker) {
61
+ const filteredNew = filterSessions(newSessions, state.modal.editBuffer)
62
+ const maxIndex = filteredNew.length
63
+ const clampedIndex = Math.min(state.modal.selectedIndex, maxIndex)
64
+ return {
65
+ ...state,
66
+ activeTabId: deletingCurrent ? null : state.activeTabId,
67
+ currentSessionId: deletingCurrent ? null : state.currentSessionId,
68
+ focusMode: 'modal',
69
+ modal: {
70
+ editBuffer: null,
71
+ selectedIndex: clampedIndex,
72
+ sessionTargetId: null,
73
+ type: 'session-picker',
74
+ },
75
+ sessions: newSessions,
76
+ sessionStatuses: nextStatuses,
77
+ tabs: deletingCurrent ? [] : state.tabs,
78
+ }
79
+ }
62
80
  return {
63
81
  ...state,
64
- activeTabId: action.sessionId === state.currentSessionId ? null : state.activeTabId,
65
- currentSessionId:
66
- action.sessionId === state.currentSessionId ? null : state.currentSessionId,
67
- focusMode: 'modal',
68
- modal: {
69
- editBuffer: null,
70
- selectedIndex: clampedIndex,
71
- sessionTargetId: null,
72
- type: 'session-picker',
73
- },
82
+ activeTabId: deletingCurrent ? null : state.activeTabId,
83
+ currentSessionId: deletingCurrent ? null : state.currentSessionId,
84
+ focusMode: deletingCurrent ? 'navigation' : state.focusMode,
85
+ modal: deletingCurrent ? CLOSED_MODAL : state.modal,
74
86
  sessions: newSessions,
75
87
  sessionStatuses: nextStatuses,
76
- tabs: action.sessionId === state.currentSessionId ? [] : state.tabs,
88
+ tabs: deletingCurrent ? [] : state.tabs,
77
89
  }
78
90
  }
79
91
  case 'reorder-sessions': {
@@ -2,6 +2,7 @@ import { allLeafIds, createGroupId, type LayoutNode, pruneLayoutTree } from './l
2
2
  import {
3
3
  type AppState,
4
4
  DEFAULT_SCROLL_INTENT,
5
+ type ScrollIntent,
5
6
  type TabSession,
6
7
  type TabStatus,
7
8
  type WorkspaceSnapshotV1,
@@ -82,6 +83,18 @@ export function restoreTabsFromWorkspace(snapshot: WorkspaceSnapshotV1 | undefin
82
83
  }))
83
84
  }
84
85
 
86
+ export function getSnapshotScrollIntents(
87
+ snapshot: WorkspaceSnapshotV1 | undefined
88
+ ): Map<string, ScrollIntent> {
89
+ if (!snapshot || snapshot.version !== 1) {
90
+ return new Map()
91
+ }
92
+
93
+ return new Map(
94
+ snapshot.tabs.map((tab) => [tab.id, tab.scrollIntent ?? DEFAULT_SCROLL_INTENT] as const)
95
+ )
96
+ }
97
+
85
98
  export function restoreLayoutTrees(
86
99
  snapshot: WorkspaceSnapshotV1 | undefined,
87
100
  tabs: TabSession[]
@@ -192,11 +205,7 @@ export function restoreWorkspaceState(
192
205
  activeTabId,
193
206
  focusMode: 'navigation',
194
207
  layoutTrees,
195
- sidebar: {
196
- ...state.sidebar,
197
- visible: workspaceSnapshot?.sidebar.visible ?? state.sidebar.visible,
198
- width: workspaceSnapshot?.sidebar.width ?? state.sidebar.width,
199
- },
208
+ sidebar: state.sidebar,
200
209
  tabGroupMap,
201
210
  tabs: orderedTabs,
202
211
  }
@@ -1,15 +1,4 @@
1
- import type {
2
- AppAction,
3
- AppState,
4
- GitModeState,
5
- GitPaneMode,
6
- GitPanePosition,
7
- GitPaneState,
8
- SessionBarPosition,
9
- SessionRecord,
10
- SnippetRecord,
11
- } from './types'
12
-
1
+ import { reduceAutoCommit } from './reducers/auto-commit-state'
13
2
  import { emptyGitMode, reduceGitModeState } from './reducers/git-mode-state'
14
3
  import { emptyGitPanel, reduceGitPanelState } from './reducers/git-panel-state'
15
4
  import { emptyModal, reduceModalState } from './reducers/modal-state'
@@ -17,6 +6,18 @@ import { reduceSessionState } from './reducers/session-state'
17
6
  import { reduceTabState } from './reducers/tab-state'
18
7
  import { reduceUIState } from './reducers/ui-state'
19
8
  import { filterSnippets } from './selectors'
9
+ import {
10
+ type AppAction,
11
+ type AppState,
12
+ EMPTY_AUTO_COMMIT_STATE,
13
+ type GitModeState,
14
+ type GitPaneMode,
15
+ type GitPanePosition,
16
+ type GitPaneState,
17
+ type SessionBarPosition,
18
+ type SessionRecord,
19
+ type SnippetRecord,
20
+ } from './types'
20
21
 
21
22
  const DEFAULT_SIDEBAR_WIDTH = 28
22
23
  const DEFAULT_SIDEBAR_MIN_WIDTH = 18
@@ -27,6 +28,7 @@ const DEFAULT_TERMINAL_ROWS = 24
27
28
  export interface InitialStateOverrides {
28
29
  gitMode?: Partial<GitModeState>
29
30
  gitPane?: Partial<GitPaneState>
31
+ sidebar?: Pick<AppState['sidebar'], 'visible' | 'width'>
30
32
  sessionBarVisible?: boolean
31
33
  sessionBarPosition?: SessionBarPosition
32
34
  }
@@ -52,6 +54,10 @@ function resolveGitPanePosition(mode: GitPaneMode, position: GitPanePosition): G
52
54
  return position === 'left' || position === 'right' ? position : 'left'
53
55
  }
54
56
 
57
+ function clampSidebarWidth(width: number): number {
58
+ return Math.min(DEFAULT_SIDEBAR_MAX_WIDTH, Math.max(DEFAULT_SIDEBAR_MIN_WIDTH, width))
59
+ }
60
+
55
61
  export function createInitialState(
56
62
  customCommands: Record<string, string> = {},
57
63
  sessions: SessionRecord[] = [],
@@ -66,6 +72,7 @@ export function createInitialState(
66
72
  )
67
73
  return {
68
74
  activeTabId: null,
75
+ autoCommit: EMPTY_AUTO_COMMIT_STATE,
69
76
  currentSessionId: null,
70
77
  customCommands,
71
78
  focusMode: showSessionPicker ? 'command-edit' : 'navigation',
@@ -101,8 +108,8 @@ export function createInitialState(
101
108
  sidebar: {
102
109
  maxWidth: DEFAULT_SIDEBAR_MAX_WIDTH,
103
110
  minWidth: DEFAULT_SIDEBAR_MIN_WIDTH,
104
- visible: true,
105
- width: DEFAULT_SIDEBAR_WIDTH,
111
+ visible: overrides.sidebar?.visible ?? true,
112
+ width: clampSidebarWidth(overrides.sidebar?.width ?? DEFAULT_SIDEBAR_WIDTH),
106
113
  },
107
114
  snippets,
108
115
  tabGroupMap: {},
@@ -129,6 +136,9 @@ export function appReducer(state: AppState, action: AppAction): AppState {
129
136
  const gitModeState = reduceGitModeState(state, action)
130
137
  if (gitModeState) return gitModeState
131
138
 
139
+ const autoCommitState = reduceAutoCommit(state, action)
140
+ if (autoCommitState) return autoCommitState
141
+
132
142
  switch (action.type) {
133
143
  case 'set-snippets':
134
144
  return { ...state, snippets: action.snippets }
@@ -62,6 +62,7 @@ export interface TerminalLine {
62
62
 
63
63
  export interface TerminalSnapshot {
64
64
  lines: TerminalLine[]
65
+ tailLines?: TerminalLine[]
65
66
  viewportY: number
66
67
  baseY: number
67
68
  cursorVisible: boolean
@@ -323,6 +324,7 @@ export interface ModalGitCommit extends ModalBase {
323
324
  type: 'git-commit'
324
325
  activeField: 'title' | 'body'
325
326
  contentBuffer: string
327
+ stage: 'edit' | 'generating' | 'confirm'
326
328
  }
327
329
 
328
330
  export interface ModalCreateSession extends ModalBase {
@@ -397,6 +399,7 @@ export interface AppState {
397
399
  customCommands: Record<AssistantId, string>
398
400
  gitPanel: GitPanelState
399
401
  gitMode: GitModeState
402
+ autoCommit: AutoCommitState
400
403
  /** Chord prefix the sequence resolver is currently waiting on, or null when idle. */
401
404
  pendingChords: string[] | null
402
405
  }
@@ -438,7 +441,7 @@ export type SessionAction =
438
441
  | { type: 'set-sessions'; sessions: SessionRecord[] }
439
442
  | { type: 'create-session-record'; session: SessionRecord }
440
443
  | { type: 'rename-session-record'; sessionId: string; name: string }
441
- | { type: 'delete-session-record'; sessionId: string }
444
+ | { type: 'delete-session-record'; sessionId: string; openSessionPicker?: boolean }
442
445
  | { type: 'reorder-sessions'; orderedIds: string[] }
443
446
  | { type: 'set-session-status'; sessionId: string; status: SessionStatus }
444
447
 
@@ -527,6 +530,50 @@ export type GitPanelAction =
527
530
  | { type: 'git-refresh-error'; kind: GitPanelError }
528
531
  | { type: 'git-panel-reset' }
529
532
 
533
+ // -- Auto-commit state & actions --
534
+ export type AutoCommitSuggestion =
535
+ | { kind: 'idle' }
536
+ | {
537
+ kind: 'generating'
538
+ tabId: string
539
+ workingTreeHash: string
540
+ abortController: AbortController
541
+ startedAt: number
542
+ }
543
+ | {
544
+ kind: 'ready'
545
+ tabId: string
546
+ workingTreeHash: string
547
+ title: string
548
+ body: string
549
+ generatedAt: number
550
+ }
551
+
552
+ export interface AutoCommitState {
553
+ bySession: Record<string, AutoCommitSuggestion>
554
+ }
555
+
556
+ export const EMPTY_AUTO_COMMIT_STATE: AutoCommitState = { bySession: {} }
557
+
558
+ export type AutoCommitAction =
559
+ | {
560
+ type: 'auto-commit-generation-started'
561
+ sessionId: string
562
+ tabId: string
563
+ workingTreeHash: string
564
+ abortController: AbortController
565
+ startedAt: number
566
+ }
567
+ | {
568
+ type: 'auto-commit-generation-ready'
569
+ sessionId: string
570
+ workingTreeHash: string
571
+ title: string
572
+ body: string
573
+ generatedAt: number
574
+ }
575
+ | { type: 'auto-commit-clear'; sessionId: string }
576
+
530
577
  export type GitModeAction =
531
578
  | { type: 'enter-git-mode' }
532
579
  | { type: 'exit-git-mode' }
@@ -576,7 +623,12 @@ export type GitModeAction =
576
623
  fromSection: GitFileSection
577
624
  toSection: GitFileSection | null
578
625
  }
579
- | { type: 'open-git-commit-modal' }
626
+ | { type: 'open-git-commit-modal'; sessionId?: string }
627
+ | { type: 'git-commit-enter-confirm' }
628
+ | { type: 'git-commit-leave-confirm' }
629
+ | { type: 'git-commit-enter-generating'; sessionId: string }
630
+ | { type: 'git-commit-leave-generating' }
631
+ | { type: 'git-commit-use-background-suggestion'; sessionId: string }
580
632
 
581
633
  // -- Data actions --
582
634
  export type DataAction =
@@ -593,3 +645,4 @@ export type AppAction =
593
645
  | DataAction
594
646
  | GitPanelAction
595
647
  | GitModeAction
648
+ | AutoCommitAction
@@ -35,6 +35,10 @@ export function saveCurrentWorkspace(state: AppState): void {
35
35
  },
36
36
  sessionBarPosition: state.sessionBar.position,
37
37
  sessionBarVisible: state.sessionBar.visible,
38
+ sidebar: {
39
+ visible: state.sidebar.visible,
40
+ width: state.sidebar.width,
41
+ },
38
42
  })
39
43
  saveSessionCatalog(
40
44
  buildSessionsWithCurrentSnapshot(state.sessions, state.currentSessionId, state)
@@ -0,0 +1,35 @@
1
+ export interface AIUsagePopoverState {
2
+ anchorX: number
3
+ anchorY: number
4
+ }
5
+
6
+ type Listener = (state: AIUsagePopoverState | null) => void
7
+
8
+ let current: AIUsagePopoverState | null = null
9
+ const listeners = new Set<Listener>()
10
+
11
+ export function openAIUsagePopover(anchorX: number, anchorY: number): void {
12
+ current = { anchorX, anchorY }
13
+ for (const l of listeners) l(current)
14
+ }
15
+
16
+ export function closeAIUsagePopover(): void {
17
+ if (current === null) return
18
+ current = null
19
+ for (const l of listeners) l(null)
20
+ }
21
+
22
+ export function toggleAIUsagePopover(anchorX: number, anchorY: number): void {
23
+ if (current) {
24
+ closeAIUsagePopover()
25
+ } else {
26
+ openAIUsagePopover(anchorX, anchorY)
27
+ }
28
+ }
29
+
30
+ export function subscribeAIUsagePopover(listener: Listener): () => void {
31
+ listeners.add(listener)
32
+ return () => {
33
+ listeners.delete(listener)
34
+ }
35
+ }