@brimveyn/aimux 1.20.4 → 1.21.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 (34) hide show
  1. package/package.json +2 -2
  2. package/src/app-runtime/side-effects.ts +3 -48
  3. package/src/app-runtime/split-drag-controller.ts +4 -8
  4. package/src/app-runtime/use-mouse-handlers.ts +27 -42
  5. package/src/app-runtime/use-terminal-resize.ts +11 -20
  6. package/src/app.tsx +9 -37
  7. package/src/config.ts +98 -8
  8. package/src/git/pr-merge.ts +64 -0
  9. package/src/git/pr-status-poller.ts +57 -0
  10. package/src/git/pr-status.ts +227 -0
  11. package/src/index.tsx +7 -2
  12. package/src/platform/open-url.ts +39 -0
  13. package/src/services/ai-usage/spawn.ts +3 -1
  14. package/src/state/bars.ts +75 -0
  15. package/src/state/git-pane-sizing.ts +0 -9
  16. package/src/state/pr-status-store.ts +39 -0
  17. package/src/state/reducers/git-panel-state.ts +0 -47
  18. package/src/state/reducers/ui-state.ts +83 -13
  19. package/src/state/session-persistence.ts +5 -7
  20. package/src/state/store.ts +28 -35
  21. package/src/state/types.ts +24 -19
  22. package/src/state/workspace-save.ts +5 -7
  23. package/src/ui/components/git/diff-renderer/pierre-diff.tsx +2 -1
  24. package/src/ui/components/git/pane/git-pane-header.tsx +105 -39
  25. package/src/ui/components/git/pane/git-pane-widget.tsx +30 -16
  26. package/src/ui/components/git/pane/pr-checks-panel.tsx +197 -0
  27. package/src/ui/components/git/pane/pr-state-row.tsx +103 -0
  28. package/src/ui/components/layout/bar.tsx +191 -0
  29. package/src/ui/components/layout/top-tab-bar.tsx +3 -2
  30. package/src/ui/root.tsx +25 -109
  31. package/src/ui/widgets/registry.tsx +18 -0
  32. package/src/ui/widgets/widget-context-menu.ts +69 -0
  33. package/src/ui/components/git/pane/git-pane-context-menu.ts +0 -26
  34. package/src/ui/components/layout/sidebar/sidebar.tsx +0 -163
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.20.4",
3
+ "version": "1.21.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, Kimi side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -66,7 +66,7 @@
66
66
  "bump:terminal_manager": "bun run scripts/bump-protocol.ts terminal-manager"
67
67
  },
68
68
  "dependencies": {
69
- "@brimveyn/aimux-config": "0.8.5",
69
+ "@brimveyn/aimux-config": "0.8.6",
70
70
  "@opentui/core": "^0.1.90",
71
71
  "@opentui/react": "^0.1.90",
72
72
  "@resvg/resvg-wasm": "^2.6.2",
@@ -913,62 +913,17 @@ export function executeSideEffect(effect: SideEffect, ctx: SideEffectContext): v
913
913
  }
914
914
  case 'persist-git-diff-mode-ratio': {
915
915
  const config = loadConfig()
916
- const persistedGitPane = config.gitPane
917
- const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
918
- const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
919
- saveConfig({
920
- ...config,
921
- gitPane: {
922
- diffModeRatio: effect.ratio,
923
- embeddedRatio,
924
- fileListMode: persistedGitPane?.fileListMode,
925
- mode: persistedGitPane?.mode ?? 'embedded',
926
- paneRatio,
927
- position: persistedGitPane?.position ?? 'bottom',
928
- treeCompaction: persistedGitPane?.treeCompaction,
929
- visible: persistedGitPane?.visible ?? true,
930
- },
931
- })
916
+ saveConfig({ ...config, gitPane: { ...config.gitPane, diffModeRatio: effect.ratio } })
932
917
  return
933
918
  }
934
919
  case 'persist-git-file-list-mode': {
935
920
  const config = loadConfig()
936
- const persistedGitPane = config.gitPane
937
- const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
938
- const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
939
- saveConfig({
940
- ...config,
941
- gitPane: {
942
- diffModeRatio: persistedGitPane?.diffModeRatio,
943
- embeddedRatio,
944
- fileListMode: effect.mode,
945
- mode: persistedGitPane?.mode ?? 'embedded',
946
- paneRatio,
947
- position: persistedGitPane?.position ?? 'bottom',
948
- treeCompaction: persistedGitPane?.treeCompaction,
949
- visible: persistedGitPane?.visible ?? true,
950
- },
951
- })
921
+ saveConfig({ ...config, gitPane: { ...config.gitPane, fileListMode: effect.mode } })
952
922
  return
953
923
  }
954
924
  case 'persist-git-tree-compaction': {
955
925
  const config = loadConfig()
956
- const persistedGitPane = config.gitPane
957
- const paneRatio = persistedGitPane?.paneRatio ?? persistedGitPane?.ratio ?? 0.5
958
- const embeddedRatio = persistedGitPane?.embeddedRatio ?? persistedGitPane?.ratio ?? 0.5
959
- saveConfig({
960
- ...config,
961
- gitPane: {
962
- diffModeRatio: persistedGitPane?.diffModeRatio,
963
- embeddedRatio,
964
- fileListMode: persistedGitPane?.fileListMode,
965
- mode: persistedGitPane?.mode ?? 'embedded',
966
- paneRatio,
967
- position: persistedGitPane?.position ?? 'bottom',
968
- treeCompaction: effect.enabled,
969
- visible: persistedGitPane?.visible ?? true,
970
- },
971
- })
926
+ saveConfig({ ...config, gitPane: { ...config.gitPane, treeCompaction: effect.enabled } })
972
927
  return
973
928
  }
974
929
  case 'git-stage': {
@@ -16,8 +16,7 @@ export interface AxisDragState {
16
16
  screenStart: number
17
17
  }
18
18
 
19
- export interface AnchoredRatioDragState {
20
- anchor: 'start' | 'end'
19
+ export interface RatioDragState {
21
20
  axis: ScreenAxis
22
21
  screenStart: number
23
22
  totalSize: number
@@ -38,10 +37,7 @@ export function getAxisDeltaFromDrag(event: OtuiMouseEvent, drag: AxisDragState)
38
37
  return getScreenPosition(event, drag.axis) - drag.screenStart
39
38
  }
40
39
 
41
- export function getAnchoredRatioFromDrag(
42
- event: OtuiMouseEvent,
43
- drag: AnchoredRatioDragState
44
- ): number {
45
- const offset = getScreenPosition(event, drag.axis) - drag.screenStart
46
- return drag.anchor === 'start' ? offset / drag.totalSize : 1 - offset / drag.totalSize
40
+ /** Cursor position as a 0..1 fraction of the dragged container. */
41
+ export function getRatioFromDrag(event: OtuiMouseEvent, drag: RatioDragState): number {
42
+ return (getScreenPosition(event, drag.axis) - drag.screenStart) / drag.totalSize
47
43
  }
@@ -5,7 +5,7 @@ import { useEffect, useRef } from 'react'
5
5
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
6
6
  import type { SessionBackend } from '../session-backend/types'
7
7
  import type { SplitDirection } from '../state/layout-tree'
8
- import type { AppAction, AppState, TabSession, TerminalLine } from '../state/types'
8
+ import type { AppAction, AppState, BarSide, TabSession, TerminalLine } from '../state/types'
9
9
 
10
10
  import { logInputDebug } from '../debug/input-log'
11
11
  import { MultiClickDetector } from '../input/multi-click-detector'
@@ -23,20 +23,19 @@ import { recordMultiClickClipboardWrite } from './multi-click-clipboard-guard'
23
23
  import { requestRenderUpTree } from './render-invalidation'
24
24
  import { shouldWriteSelectionToClipboard } from './selection-clipboard-dedup'
25
25
  import {
26
- type AnchoredRatioDragState,
27
26
  type AxisDragState,
28
- getAnchoredRatioFromDrag,
29
27
  getAxisDeltaFromDrag,
28
+ getRatioFromDrag,
30
29
  getSplitRatioFromDrag,
30
+ type RatioDragState,
31
31
  type SplitDragState,
32
32
  } from './split-drag-controller'
33
33
  import { getForwardedMouseSequence, getScrollViewportDelta } from './terminal-mouse-adapter'
34
34
 
35
35
  type ResizeDragState =
36
36
  | ({ kind: 'split' } & SplitDragState)
37
- | ({ initialWidth: number; kind: 'sidebar' } & AxisDragState)
38
- | ({ initialWidth: number; kind: 'git-pane'; side: 'left' | 'right' } & AxisDragState)
39
- | ({ kind: 'embedded-git'; position: 'top' | 'bottom' } & AnchoredRatioDragState)
37
+ | ({ initialWidth: number; kind: 'bar'; side: BarSide } & AxisDragState)
38
+ | ({ index: number; kind: 'bar-boundary'; side: BarSide } & RatioDragState)
40
39
 
41
40
  interface MultiClickDragState {
42
41
  mode: MultiClickMode
@@ -322,40 +321,32 @@ export function useMouseHandlers({
322
321
  separatorDragRef.current = { kind: 'split', ...info }
323
322
  }
324
323
 
325
- const handleSidebarResizeStart = (info: { initialWidth: number; screenStart: number }) => {
326
- separatorDragRef.current = {
327
- axis: 'x',
328
- initialWidth: info.initialWidth,
329
- kind: 'sidebar',
330
- screenStart: info.screenStart,
331
- }
332
- }
333
-
334
- const handleGitPaneResizeStart = (info: {
324
+ const handleBarResizeStart = (info: {
335
325
  initialWidth: number
336
326
  screenStart: number
337
- side: 'left' | 'right'
327
+ side: BarSide
338
328
  }) => {
339
329
  separatorDragRef.current = {
340
330
  axis: 'x',
341
331
  initialWidth: info.initialWidth,
342
- kind: 'git-pane',
332
+ kind: 'bar',
343
333
  screenStart: info.screenStart,
344
334
  side: info.side,
345
335
  }
346
336
  }
347
337
 
348
- const handleEmbeddedGitResizeStart = (info: {
338
+ const handleBarBoundaryResizeStart = (info: {
349
339
  containerStart: number
350
- position: 'top' | 'bottom'
340
+ index: number
341
+ side: BarSide
351
342
  totalSize: number
352
343
  }) => {
353
344
  separatorDragRef.current = {
354
- anchor: info.position === 'top' ? 'start' : 'end',
355
345
  axis: 'y',
356
- kind: 'embedded-git',
357
- position: info.position,
346
+ index: info.index,
347
+ kind: 'bar-boundary',
358
348
  screenStart: info.containerStart,
349
+ side: info.side,
359
350
  totalSize: info.totalSize,
360
351
  }
361
352
  }
@@ -377,27 +368,22 @@ export function useMouseHandlers({
377
368
  })
378
369
  break
379
370
  }
380
- case 'sidebar': {
381
- const nextWidth = Math.round(drag.initialWidth + getAxisDeltaFromDrag(event, drag))
382
- dispatch({ type: 'set-sidebar-width', width: nextWidth })
383
- break
384
- }
385
- case 'git-pane': {
386
- const delta = getAxisDeltaFromDrag(event, drag)
387
- const direction = drag.side === 'left' ? 1 : -1
388
- const nextWidth = drag.initialWidth + delta * direction
371
+ case 'bar': {
372
+ // The right bar grows as the cursor moves left, hence the flipped sign.
373
+ const delta = getAxisDeltaFromDrag(event, drag) * (drag.side === 'left' ? 1 : -1)
389
374
  dispatch({
390
- ratio: nextWidth / 80,
391
- target: 'pane',
392
- type: 'set-git-pane-ratio',
375
+ side: drag.side,
376
+ type: 'set-bar-width',
377
+ width: Math.round(drag.initialWidth + delta),
393
378
  })
394
379
  break
395
380
  }
396
- case 'embedded-git': {
381
+ case 'bar-boundary': {
397
382
  dispatch({
398
- ratio: getAnchoredRatioFromDrag(event, drag),
399
- target: 'embedded',
400
- type: 'set-git-pane-ratio',
383
+ index: drag.index,
384
+ ratio: getRatioFromDrag(event, drag),
385
+ side: drag.side,
386
+ type: 'set-bar-boundary',
401
387
  })
402
388
  break
403
389
  }
@@ -586,13 +572,12 @@ export function useMouseHandlers({
586
572
  }
587
573
 
588
574
  return {
589
- handleEmbeddedGitResizeStart,
590
- handleGitPaneResizeStart,
575
+ handleBarBoundaryResizeStart,
576
+ handleBarResizeStart,
591
577
  handlePaneActivate,
592
578
  handleSeparatorDrag,
593
579
  handleSeparatorDragEnd,
594
580
  handleSeparatorDragStart,
595
- handleSidebarResizeStart,
596
581
  handleSplitResize,
597
582
  handleTerminalClick,
598
583
  handleTerminalDrag,
@@ -13,7 +13,7 @@ import type { SessionBackend } from '../session-backend/types'
13
13
  import type { AppAction, AppState } from '../state/types'
14
14
  import type { MeasuredPaneRect } from './use-pane-size-report'
15
15
 
16
- import { getGitPaneWidthFromRatio } from '../state/git-pane-sizing'
16
+ import { getBarWidth } from '../state/bars'
17
17
  import {
18
18
  createTerminalBounds,
19
19
  forEachSplitPaneRect,
@@ -223,9 +223,12 @@ export function useTerminalResize({
223
223
  [backend, contentOriginRef]
224
224
  )
225
225
 
226
- const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
226
+ // `getBarWidth` is the single authority on bar width — the Bar component
227
+ // renders from the same function. A mismatch silently corrupts PTY columns
228
+ // and mouse hit-testing.
229
+ const leftBarWidth = getBarWidth(state.bars.left)
230
+ const rightBarWidth = getBarWidth(state.bars.right)
227
231
  const terminalSize = useMemo(() => {
228
- const sidebarWidth = state.sidebar.visible ? state.sidebar.width : 0
229
232
  const sessionBarRows = state.sessionBar.visible ? 1 : 0
230
233
  const sessionBarTopOffset = sessionBarRows
231
234
  const reservedRows =
@@ -233,18 +236,16 @@ export function useTerminalResize({
233
236
  STATUS_BAR_HEIGHT +
234
237
  TERMINAL_PANE_VERTICAL_CHROME +
235
238
  sessionBarRows
236
- const gitPaneWidth = gitPaneInPaneMode ? getGitPaneWidthFromRatio(state.gitPane.paneRatio) : 0
237
- const gitOnLeft = gitPaneInPaneMode && state.gitPane.position === 'left'
238
239
  const cols = Math.max(
239
240
  MIN_TERMINAL_COLS,
240
- Math.floor(dimensions.width - sidebarWidth - gitPaneWidth - MAIN_AREA_HORIZONTAL_CHROME)
241
+ Math.floor(dimensions.width - leftBarWidth - rightBarWidth - MAIN_AREA_HORIZONTAL_CHROME)
241
242
  )
242
243
  const rows = Math.max(MIN_TERMINAL_ROWS, Math.floor(dimensions.height - reservedRows))
243
244
 
244
245
  contentOriginRef.current = {
245
246
  cols,
246
247
  rows,
247
- x: sidebarWidth + (gitOnLeft ? gitPaneWidth : 0) + 1,
248
+ x: leftBarWidth + 1,
248
249
  y: 1 + sessionBarTopOffset,
249
250
  }
250
251
 
@@ -253,12 +254,9 @@ export function useTerminalResize({
253
254
  contentOriginRef,
254
255
  dimensions.height,
255
256
  dimensions.width,
256
- state.sidebar.visible,
257
- state.sidebar.width,
257
+ leftBarWidth,
258
+ rightBarWidth,
258
259
  state.sessionBar.visible,
259
- gitPaneInPaneMode,
260
- state.gitPane.position,
261
- state.gitPane.paneRatio,
262
260
  ])
263
261
 
264
262
  useLayoutEffect(() => {
@@ -280,14 +278,7 @@ export function useTerminalResize({
280
278
  })
281
279
  handledBySyncRef.current = true
282
280
  // eslint-disable-next-line react-hooks/exhaustive-deps
283
- }, [
284
- state.sidebar.visible,
285
- state.sidebar.width,
286
- state.sessionBar.visible,
287
- gitPaneInPaneMode,
288
- state.gitPane.position,
289
- state.gitPane.paneRatio,
290
- ])
281
+ }, [leftBarWidth, rightBarWidth, state.sessionBar.visible])
291
282
 
292
283
  useEffect(() => {
293
284
  if (handledBySyncRef.current) {
package/src/app.tsx CHANGED
@@ -106,40 +106,14 @@ export function App({
106
106
  const json = loadConfig()
107
107
  const sessionBarVisible =
108
108
  resolvedConfig.sessionBar?.initialVisible ?? json.sessionBarVisible ?? true
109
- const sidebarOverrides = json.sidebar
110
-
111
109
  // Merge config-file gitPane (persisted prefs) with user's resolved gitPane
112
110
  // (programmatic config). User config wins; file provides persisted prior state.
113
111
  const userGitPane = resolvedConfig.gitPane
114
- const fileListMode = userGitPane?.initialFileListMode ?? json.gitPane?.fileListMode ?? 'tree'
115
- const diffModeRatio = userGitPane?.initialDiffModeRatio ?? json.gitPane?.diffModeRatio ?? 0.35
116
- const treeCompaction =
117
- userGitPane?.initialTreeCompaction ?? json.gitPane?.treeCompaction ?? true
118
- const prefetchRadius = userGitPane?.prefetchRadius ?? json.gitPane?.prefetchRadius ?? 5
119
- const persistedPaneRatio = json.gitPane?.paneRatio ?? json.gitPane?.ratio ?? 0.5
120
- const persistedEmbeddedRatio = json.gitPane?.embeddedRatio ?? json.gitPane?.ratio ?? 0.5
121
112
  const gitPaneOverrides = {
122
- ...json.gitPane,
123
- diffModeRatio,
124
- embeddedRatio:
125
- userGitPane?.initialMode === 'embedded' && userGitPane?.initialRatio !== undefined
126
- ? userGitPane.initialRatio
127
- : persistedEmbeddedRatio,
128
- fileListMode,
129
- paneRatio:
130
- userGitPane?.initialMode === 'pane' && userGitPane?.initialRatio !== undefined
131
- ? userGitPane.initialRatio
132
- : persistedPaneRatio,
133
- prefetchRadius,
134
- treeCompaction,
135
- ...(userGitPane?.initialVisible !== undefined ? { visible: userGitPane.initialVisible } : {}),
136
- ...(userGitPane?.initialMode !== undefined ? { mode: userGitPane.initialMode } : {}),
137
- ...(userGitPane?.initialPosition !== undefined
138
- ? { position: userGitPane.initialPosition }
139
- : {}),
140
- ...(userGitPane?.initialDiffModeRatio !== undefined
141
- ? { diffModeRatio: userGitPane.initialDiffModeRatio }
142
- : {}),
113
+ diffModeRatio: userGitPane?.initialDiffModeRatio ?? json.gitPane?.diffModeRatio ?? 0.35,
114
+ fileListMode: userGitPane?.initialFileListMode ?? json.gitPane?.fileListMode ?? 'tree',
115
+ prefetchRadius: userGitPane?.prefetchRadius ?? json.gitPane?.prefetchRadius ?? 5,
116
+ treeCompaction: userGitPane?.initialTreeCompaction ?? json.gitPane?.treeCompaction ?? true,
143
117
  ...(userGitPane?.path !== undefined ? { path: userGitPane.path } : {}),
144
118
  ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
145
119
  }
@@ -152,9 +126,9 @@ export function App({
152
126
  mergedSnippets,
153
127
  sessionCatalog.length === 0,
154
128
  {
129
+ bars: json.bars,
155
130
  gitPane: gitPaneOverrides,
156
131
  sessionBarVisible,
157
- sidebar: sidebarOverrides,
158
132
  worktreeTemplates:
159
133
  resolvedConfig.worktreeTemplates.length > 0
160
134
  ? resolvedConfig.worktreeTemplates
@@ -382,13 +356,12 @@ export function App({
382
356
  })
383
357
 
384
358
  const {
385
- handleEmbeddedGitResizeStart,
386
- handleGitPaneResizeStart,
359
+ handleBarBoundaryResizeStart,
360
+ handleBarResizeStart,
387
361
  handlePaneActivate,
388
362
  handleSeparatorDrag,
389
363
  handleSeparatorDragEnd,
390
364
  handleSeparatorDragStart,
391
- handleSidebarResizeStart,
392
365
  handleSplitResize,
393
366
  handleTerminalClick,
394
367
  handleTerminalDrag,
@@ -563,12 +536,11 @@ export function App({
563
536
  onTerminalMouseUp={handleTerminalMouseUp}
564
537
  onPaneActivate={handlePaneActivate}
565
538
  onSplitResize={handleSplitResize}
566
- onEmbeddedGitResizeStart={handleEmbeddedGitResizeStart}
567
- onGitPaneResizeStart={handleGitPaneResizeStart}
539
+ onBarBoundaryResizeStart={handleBarBoundaryResizeStart}
540
+ onBarResizeStart={handleBarResizeStart}
568
541
  onSeparatorDragStart={handleSeparatorDragStart}
569
542
  onSeparatorDrag={handleSeparatorDrag}
570
543
  onSeparatorDragEnd={handleSeparatorDragEnd}
571
- onSidebarResizeStart={handleSidebarResizeStart}
572
544
  onMeasure={terminalSize.onMeasure}
573
545
  terminalCols={terminalSize.cols}
574
546
  terminalRows={terminalSize.rows}
package/src/config.ts CHANGED
@@ -22,24 +22,48 @@ export function getConfigPath(): string {
22
22
  return `${getProfileConfigDir()}/aimux.json`
23
23
  }
24
24
 
25
+ /**
26
+ * `visible` / `mode` / `position` / `*Ratio` are legacy placement fields, read
27
+ * once by `deriveBarsFromLegacy` and no longer written. They must stay
28
+ * optional: rejecting the whole block would silently drop the git *content*
29
+ * preferences (`fileListMode`, `diffModeRatio`, `treeCompaction`).
30
+ */
25
31
  export interface PersistedGitPane {
26
32
  diffModeRatio?: number
27
33
  fileListMode?: GitFileListMode
28
34
  treeCompaction?: boolean
29
35
  prefetchRadius?: number
30
- visible: boolean
31
- mode: 'embedded' | 'pane'
32
- position: 'top' | 'bottom' | 'left' | 'right'
36
+ visible?: boolean
37
+ mode?: 'embedded' | 'pane'
38
+ position?: 'top' | 'bottom' | 'left' | 'right'
33
39
  paneRatio?: number
34
40
  embeddedRatio?: number
35
41
  ratio?: number
36
42
  }
37
43
 
44
+ /** Legacy; still written for downgrade safety, read only as a bars fallback. */
38
45
  export interface PersistedSidebar {
39
46
  visible: boolean
40
47
  width: number
41
48
  }
42
49
 
50
+ export interface PersistedBarWidget {
51
+ id: string
52
+ grow: number
53
+ visible: boolean
54
+ }
55
+
56
+ export interface PersistedBar {
57
+ visible: boolean
58
+ width: number
59
+ widgets: PersistedBarWidget[]
60
+ }
61
+
62
+ export interface PersistedBars {
63
+ left: PersistedBar
64
+ right: PersistedBar
65
+ }
66
+
43
67
  export interface WorktreeTemplatePane {
44
68
  id: string
45
69
  assistant: string
@@ -67,6 +91,7 @@ export interface AimuxConfig {
67
91
  themeTransparent?: boolean
68
92
  themeMode?: ThemeMode
69
93
  gitPane?: PersistedGitPane
94
+ bars?: PersistedBars
70
95
  sidebar?: PersistedSidebar
71
96
  sessionBarVisible?: boolean
72
97
  workspaceSnapshot?: WorkspaceSnapshotV1
@@ -79,8 +104,9 @@ export interface AimuxConfig {
79
104
  function isPersistedGitPane(value: unknown): value is PersistedGitPane {
80
105
  if (typeof value !== 'object' || value === null) return false
81
106
  const v = value as Record<string, unknown>
82
- const modeOk = v.mode === 'embedded' || v.mode === 'pane'
107
+ const modeOk = v.mode === undefined || v.mode === 'embedded' || v.mode === 'pane'
83
108
  const positionOk =
109
+ v.position === undefined ||
84
110
  v.position === 'top' ||
85
111
  v.position === 'bottom' ||
86
112
  v.position === 'left' ||
@@ -106,7 +132,7 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
106
132
  Number.isFinite(v.diffModeRatio) &&
107
133
  v.diffModeRatio > 0 &&
108
134
  v.diffModeRatio < 1)
109
- const visibleOk = typeof v.visible === 'boolean'
135
+ const visibleOk = v.visible === undefined || typeof v.visible === 'boolean'
110
136
  const fileListModeOk =
111
137
  v.fileListMode === undefined || v.fileListMode === 'tree' || v.fileListMode === 'flat'
112
138
  const treeCompactionOk = v.treeCompaction === undefined || typeof v.treeCompaction === 'boolean'
@@ -130,12 +156,69 @@ function isPersistedGitPane(value: unknown): value is PersistedGitPane {
130
156
  ) {
131
157
  return false
132
158
  }
133
- // cross-field coherence: embedded => top|bottom; pane => left|right
134
- if (v.mode === 'embedded' && v.position !== 'top' && v.position !== 'bottom') return false
135
- if (v.mode === 'pane' && v.position !== 'left' && v.position !== 'right') return false
136
159
  return true
137
160
  }
138
161
 
162
+ function isPersistedBar(value: unknown): value is PersistedBar {
163
+ if (typeof value !== 'object' || value === null) return false
164
+ const v = value as Record<string, unknown>
165
+ if (typeof v.visible !== 'boolean') return false
166
+ if (typeof v.width !== 'number' || !Number.isFinite(v.width) || v.width <= 0) return false
167
+ if (!Array.isArray(v.widgets)) return false
168
+ return v.widgets.every((raw) => {
169
+ if (typeof raw !== 'object' || raw === null) return false
170
+ const widget = raw as Record<string, unknown>
171
+ return (
172
+ typeof widget.id === 'string' &&
173
+ widget.id.length > 0 &&
174
+ typeof widget.grow === 'number' &&
175
+ Number.isFinite(widget.grow) &&
176
+ widget.grow > 0 &&
177
+ typeof widget.visible === 'boolean'
178
+ )
179
+ })
180
+ }
181
+
182
+ export function isPersistedBars(value: unknown): value is PersistedBars {
183
+ if (typeof value !== 'object' || value === null) return false
184
+ const v = value as Record<string, unknown>
185
+ return isPersistedBar(v.left) && isPersistedBar(v.right)
186
+ }
187
+
188
+ /**
189
+ * One-time upgrade from the pre-bars layout (a left sidebar plus a git pane
190
+ * with its own mode/position). Runs whenever `bars` is absent so an existing
191
+ * install keeps the layout it had.
192
+ */
193
+ export function deriveBarsFromLegacy(
194
+ sidebar: PersistedSidebar | undefined,
195
+ gitPane: PersistedGitPane | undefined
196
+ ): PersistedBars {
197
+ const gitVisible = gitPane?.visible ?? true
198
+ const embeddedRatio = gitPane?.embeddedRatio ?? gitPane?.ratio ?? 0.5
199
+ const gitGrow = Math.max(1, Math.round(embeddedRatio * 100))
200
+ const git = { grow: gitGrow, id: 'git', visible: gitVisible }
201
+ const workspaces = { grow: Math.max(1, 100 - gitGrow), id: 'workspaces', visible: true }
202
+
203
+ const left: PersistedBar = {
204
+ visible: sidebar?.visible ?? true,
205
+ widgets: gitPane?.position === 'top' ? [git, workspaces] : [workspaces, git],
206
+ width: sidebar?.width ?? 28,
207
+ }
208
+ const right: PersistedBar = { visible: false, widgets: [], width: 40 }
209
+
210
+ // A legacy `pane` git panel on the right was already its own right-hand
211
+ // column — it maps straight onto the right bar. On the left it shared the
212
+ // edge with the sidebar, which the single-bar model expresses as a stack.
213
+ if (gitPane?.mode === 'pane' && gitPane.position === 'right') {
214
+ left.widgets = [{ ...workspaces, grow: 100 }]
215
+ right.visible = true
216
+ right.widgets = [{ ...git, grow: 100 }]
217
+ right.width = Math.round((gitPane.paneRatio ?? gitPane.ratio ?? 0.5) * 80)
218
+ }
219
+ return { left, right }
220
+ }
221
+
139
222
  function isRatioValid(value: unknown): boolean {
140
223
  return typeof value === 'number' && Number.isFinite(value) && value > 0.15 && value < 0.85
141
224
  }
@@ -259,6 +342,7 @@ export function loadConfigResult(): ConfigLoadResult {
259
342
  themeTransparent?: unknown
260
343
  themeMode?: unknown
261
344
  gitPane?: unknown
345
+ bars?: unknown
262
346
  sidebar?: unknown
263
347
  gitPanelVisible?: unknown
264
348
  gitPanelRatio?: unknown
@@ -305,6 +389,11 @@ export function loadConfigResult(): ConfigLoadResult {
305
389
  issues.push('ignored invalid sidebar')
306
390
  }
307
391
 
392
+ const validBars = isPersistedBars(parsed.bars) ? parsed.bars : undefined
393
+ if (parsed.bars !== undefined && validBars === undefined) {
394
+ issues.push('ignored invalid bars')
395
+ }
396
+
308
397
  // Legacy migration: previous schema stored gitPanelVisible/gitPanelRatio at
309
398
  // top level. If the new `gitPane` field is absent, synthesize it from legacy
310
399
  // keys so users don't lose their toggle/ratio on upgrade.
@@ -359,6 +448,7 @@ export function loadConfigResult(): ConfigLoadResult {
359
448
 
360
449
  return {
361
450
  config: {
451
+ bars: validBars ?? deriveBarsFromLegacy(validSidebar, validGitPane),
362
452
  customCommands: isCustomCommandsRecord(parsed.customCommands) ? parsed.customCommands : {},
363
453
  gitPane: validGitPane,
364
454
  prunedOrphanAimuxBranches: parsed.prunedOrphanAimuxBranches === true ? true : undefined,
@@ -0,0 +1,64 @@
1
+ import { logDebug } from '../debug/input-log'
2
+ import { runCli } from '../services/ai-usage/spawn'
3
+
4
+ export interface RepoMergeMethods {
5
+ mergeCommitAllowed: boolean
6
+ squashMergeAllowed: boolean
7
+ rebaseMergeAllowed: boolean
8
+ }
9
+
10
+ /**
11
+ * Mirrors the method GitHub pre-selects on the merge button: a merge commit
12
+ * when the repo allows one, then squash, then rebase. `gh pr merge` refuses to
13
+ * guess when several are enabled, so we have to name one explicitly.
14
+ */
15
+ export function pickMergeFlag(methods: RepoMergeMethods): string | null {
16
+ if (methods.mergeCommitAllowed) return '--merge'
17
+ if (methods.squashMergeAllowed) return '--squash'
18
+ if (methods.rebaseMergeAllowed) return '--rebase'
19
+ return null
20
+ }
21
+
22
+ export type MergeResult = { ok: true } | { ok: false; message: string }
23
+
24
+ async function repoMergeMethods(gh: string, cwd: string): Promise<RepoMergeMethods | null> {
25
+ const result = await runCli(
26
+ gh,
27
+ ['repo', 'view', '--json', 'mergeCommitAllowed,squashMergeAllowed,rebaseMergeAllowed'],
28
+ 15_000,
29
+ cwd
30
+ )
31
+ if (!result.ok) return null
32
+ try {
33
+ const raw = JSON.parse(result.stdout) as Record<string, unknown>
34
+ return {
35
+ mergeCommitAllowed: raw.mergeCommitAllowed === true,
36
+ rebaseMergeAllowed: raw.rebaseMergeAllowed === true,
37
+ squashMergeAllowed: raw.squashMergeAllowed === true,
38
+ }
39
+ } catch {
40
+ return null
41
+ }
42
+ }
43
+
44
+ export async function approveAndMergePr(cwd: string): Promise<MergeResult> {
45
+ const gh = Bun.which('gh')
46
+ if (gh === null) return { message: 'gh CLI not found', ok: false }
47
+
48
+ const methods = await repoMergeMethods(gh, cwd)
49
+ if (methods === null) return { message: 'could not read repo merge settings', ok: false }
50
+ const flag = pickMergeFlag(methods)
51
+ if (flag === null) return { message: 'repo allows no merge method', ok: false }
52
+
53
+ // GitHub rejects approving your own PR, and a missing approval is not a
54
+ // reason to skip the merge — best effort, log it, move on.
55
+ const approve = await runCli(gh, ['pr', 'review', '--approve'], 15_000, cwd)
56
+ if (!approve.ok) logDebug('git.pr.approveSkipped', { error: approve.error })
57
+
58
+ const merge = await runCli(gh, ['pr', 'merge', flag], 60_000, cwd)
59
+ if (!merge.ok) {
60
+ const detail = merge.stderr.trim().split('\n')[0] ?? 'merge failed'
61
+ return { message: detail.slice(0, 160), ok: false }
62
+ }
63
+ return { ok: true }
64
+ }