@brimveyn/aimux 1.4.0 → 1.5.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 (53) hide show
  1. package/README.md +142 -176
  2. package/package.json +7 -3
  3. package/src/app-runtime/use-terminal-resize.ts +112 -32
  4. package/src/app.tsx +20 -3
  5. package/src/config.ts +53 -17
  6. package/src/daemon/session-manager.ts +11 -4
  7. package/src/daemon/session-registry.ts +15 -4
  8. package/src/index.tsx +6 -1
  9. package/src/input/keymap/build-handlers.ts +1 -0
  10. package/src/input/keymap/help-entries.ts +44 -0
  11. package/src/input/keymap/keymap-ref.ts +11 -0
  12. package/src/input/keymap/sequence-resolver.ts +35 -2
  13. package/src/input/keymap/trie.ts +1 -0
  14. package/src/input/modes/bridge.ts +1 -1
  15. package/src/input/modes/handlers/shared.ts +0 -16
  16. package/src/input/modes/transitions.ts +4 -4
  17. package/src/input/modes/types.ts +1 -1
  18. package/src/platform/daemon-control.ts +0 -8
  19. package/src/pty/pty-manager.ts +127 -11
  20. package/src/restart-terminal-manager.ts +44 -0
  21. package/src/session-backend/local-session-backend.ts +15 -4
  22. package/src/session-backend/remote-session-backend.ts +13 -2
  23. package/src/session-backend/types.ts +13 -2
  24. package/src/state/reducers/git-panel-state.ts +35 -8
  25. package/src/state/reducers/modal-state.ts +48 -3
  26. package/src/state/selectors.ts +1 -5
  27. package/src/state/session-persistence.ts +1 -20
  28. package/src/state/store.ts +38 -5
  29. package/src/state/types.ts +27 -13
  30. package/src/state/validation.ts +0 -2
  31. package/src/state/workspace-save.ts +6 -2
  32. package/src/ui/components/create-session-modal.tsx +5 -3
  33. package/src/ui/components/git-commit-modal.tsx +1 -3
  34. package/src/ui/components/git-pane-widget.tsx +46 -0
  35. package/src/ui/components/git-panel.tsx +72 -25
  36. package/src/ui/components/help-modal.tsx +156 -42
  37. package/src/ui/components/modal-keybinds-overlay.tsx +39 -0
  38. package/src/ui/components/modal-shell.tsx +15 -3
  39. package/src/ui/components/new-tab-modal.tsx +9 -10
  40. package/src/ui/components/pending-chord-overlay.tsx +2 -1
  41. package/src/ui/components/session-name-modal.tsx +1 -3
  42. package/src/ui/components/session-picker-modal.tsx +1 -3
  43. package/src/ui/components/sidebar.tsx +24 -50
  44. package/src/ui/components/snippet-editor-modal.tsx +1 -3
  45. package/src/ui/components/snippet-picker-modal.tsx +1 -3
  46. package/src/ui/components/status-bar.tsx +0 -4
  47. package/src/ui/components/terminal-pane.tsx +17 -12
  48. package/src/ui/components/theme-picker-modal.tsx +6 -3
  49. package/src/ui/components/update-available-modal.tsx +7 -4
  50. package/src/ui/keymap-context.ts +1 -6
  51. package/src/ui/root.tsx +29 -1
  52. package/src/ui/status-bar-model.ts +0 -5
  53. package/src/ui/directory-search.ts +0 -1
package/README.md CHANGED
@@ -1,6 +1,8 @@
1
1
  # aimux
2
2
 
3
- A terminal multiplexer for AI CLIs. Manage multiple AI assistant sessions (Claude, Codex, OpenCode) side by side in a single terminal with tabbed navigation, split panes, persistent sessions, and fully configurable keybindings.
3
+ A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode, and normal
4
+ shell tabs side by side in one TUI with persistent sessions, split panes,
5
+ snippets, themes, and fully configurable keymaps.
4
6
 
5
7
  ![Built with Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1)
6
8
  ![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6)
@@ -10,39 +12,16 @@ A terminal multiplexer for AI CLIs. Manage multiple AI assistant sessions (Claud
10
12
 
11
13
  ## Features
12
14
 
13
- - **Multi-tab sessions** Run Claude, Codex, and OpenCode in parallel with instant tab switching
14
- - **Session bar** — Numbered session chips at the top (or bottom) of the screen. Click to switch, drag to reorder, busy spinner for non-focused sessions with live PTY output. Toggle with `<leader>b`; jump with `<leader>1..9`. Position persists via `aimux.config.ts` or `aimux.json`.
15
- - **Split panes** Split vertically (`|`) or horizontally (`-`) to view multiple assistants at once
16
- - **Draggable separators** Resize split panes by dragging with the mouse
17
- - **Click-to-focus** Click any pane or sidebar tab to focus it instantly
18
- - **Full terminal emulation** Powered by xterm.js with mouse tracking, alternate buffer, and scrollback
19
- - **Configurable nvim-style keymaps** — Define keybinds in a typed `aimux.config.ts` with leader keys, multi-key sequences, and a prefix-trie resolver
20
- - **Text selection** Double-click for a word, triple-click for a line, drag for a region. Selections copy to system clipboard automatically
21
- - **Project-scoped sessions** Associate a git repository with each session; all tabs spawn in that directory
22
- - **Directory picker** Fuzzy-search git repos and worktrees from `$HOME` using `fzf`
23
- - **Session persistence** — Workspace state (tabs, titles, layout) saved and restored on restart
24
- - **Git status panel** — Branch + diff summary in the sidebar
25
- - **Seamless daemon updates** — A restartable IPC daemon now reconnects to a long-lived terminal manager, so updates and IPC changes do not kill live tabs
26
- - **Snippets** — Save and reuse prompt snippets across sessions
27
- - **Theme picker** — Switch between 11 built-in themes on the fly
28
- - **Pending-chord indicator** — Bottom-right overlay shows mid-sequence key state (like nvim's `which-key`)
29
- - **Built-in help** — Press `?` to see all keybindings
30
-
31
- ### Session Management
32
-
33
- ![Session management](assets/sessions.gif)
34
-
35
- ### Multi-Tab Workflow
36
-
37
- ![Multi-tab workflow](assets/tabs.gif)
38
-
39
- ### Themes
40
-
41
- ![Themes](assets/themes.gif)
42
-
43
- ### Split Panes
44
-
45
- ![Split panes](assets/splits.gif)
15
+ - multi-session workflow with a dedicated session picker
16
+ - tabs for `claude`, `codex`, `opencode`, and `terminal`
17
+ - split panes with pane focus and resize shortcuts
18
+ - persistent sessions with saved layout and tab state
19
+ - profile-isolated config, catalogs, daemon sockets, and runtime state
20
+ - typed keymap customization through `@brimveyn/aimux-config`
21
+ - snippets catalog and snippet picker
22
+ - configurable git pane (embedded in the sidebar or as a standalone pane) and git mode
23
+ - built-in help generated from the resolved keymap
24
+ - theme picker with 11 built-in themes
46
25
 
47
26
  ## Install
48
27
 
@@ -52,178 +31,165 @@ bun install -g @brimveyn/aimux
52
31
 
53
32
  Requires [Bun](https://bun.sh).
54
33
 
55
- ## Usage
56
-
57
- ```bash
58
- aimux # start the TUI
59
- aimux version # print version
60
- aimux doctor # check setup
61
- aimux update # self-update
62
- aimux restart-daemon # restart IPC daemon only
63
- ```
34
+ ## Quick Start
64
35
 
65
- `aimux update` and `aimux restart-daemon` restart only the IPC daemon. Live PTYs and headless terminal state stay in the long-lived terminal-manager process, so active tabs can be reattached instead of being restarted.
66
-
67
- ## Configuration
68
-
69
- aimux reads `~/.config/aimux/<profile>/aimux.config.ts` at startup. The default installed profile is `default`, while the repository dev scripts use `dev`. Set up the default profile with:
36
+ Create the default profile and install the typed config package into it:
70
37
 
71
38
  ```bash
72
- mkdir -p ~/.config/aimux/default && cd ~/.config/aimux/default
39
+ mkdir -p ~/.config/aimux/default
40
+ cd ~/.config/aimux/default
73
41
  bun init -y
74
42
  bun add -d @brimveyn/aimux-config
75
43
  ```
76
44
 
77
- Then create `~/.config/aimux/default/aimux.config.ts`:
45
+ Create `~/.config/aimux/default/aimux.config.ts`:
78
46
 
79
47
  ```ts
80
- import { defineConfig, actions, themes } from '@brimveyn/aimux-config'
48
+ import { defineConfig, actions } from '@brimveyn/aimux-config'
81
49
 
82
50
  export default defineConfig({
83
- theme: themes.extend('tokyo-night', { accent: '#ff9e64' }),
51
+ sessionBar: {
52
+ position: 'top',
53
+ visible: true,
54
+ },
84
55
 
85
56
  keymaps: (k) =>
86
- k
87
- .leader('<Space>')
88
- .timeout(300)
89
- .mode('navigation', (m) =>
90
- m
91
- .map('j', actions.nextTab)
92
- .map('k', actions.prevTab)
93
- .map('<leader>g', actions.sessionPicker)
94
- .group('<leader>t', 'tabs', (g) =>
95
- g.map('n', actions.newTab).map('r', actions.renameTab).map('x', actions.closeTab)
96
- )
97
- )
98
- .mode('layout', (m) => m.map('|', actions.splitVertical).map('-', actions.splitHorizontal)),
57
+ k.mode('navigation', (m) => m.map('<C-p>', actions.sessionPicker, 'Session picker')),
99
58
  })
100
59
  ```
101
60
 
102
- User bindings override defaults. Use `.unmap(keys)` to remove a default. See [`@brimveyn/aimux-config`](packages/aimux-config/README.md) for the full builder API.
103
-
104
- ### Key notation
105
-
106
- | Notation | Meaning |
107
- | -------------- | -------------------------- |
108
- | `j` | Bare character |
109
- | `J` | Shift+J (uppercase letter) |
110
- | `<C-n>` | Ctrl+N |
111
- | `<M-x>` | Meta/Alt+X |
112
- | `<CR>` `<Esc>` | Return / Escape |
113
- | `<leader>` | Configured leader chord |
114
- | `dd` | Multi-key sequence |
115
- | `<leader>tn` | Leader, then t, then n |
116
-
117
- ## Default Keymaps
118
-
119
- Press `?` in navigation mode for the full, live keybinding list (reflects your config).
120
-
121
- ### Navigation Mode
122
-
123
- | Key | Action |
124
- | --------------------- | -------------------- |
125
- | `j` / `k` | Next / previous tab |
126
- | `Shift+J` / `Shift+K` | Reorder tabs |
127
- | `i` | Enter terminal input |
128
- | `r` | Rename active tab |
129
- | `dd` | Close active tab |
130
- | `Ctrl+N` | New tab |
131
- | `Ctrl+R` | Restart tab |
132
- | `Ctrl+G` | Session picker |
133
- | `Ctrl+B` | Toggle sidebar |
134
- | `Ctrl+H` / `Ctrl+L` | Resize sidebar |
135
- | `Ctrl+S` | Snippet picker |
136
- | `Ctrl+T` | Theme picker |
137
- | `G` | Toggle git panel |
138
- | `<leader>b` | Toggle session bar |
139
- | `<leader>1..9` | Switch to session N |
140
- | `?` | Show help |
141
- | `Ctrl+C` | Quit |
142
-
143
- ### Terminal Input Mode
144
-
145
- Keystrokes pass through to the active tab's PTY. Configured shortcuts:
146
-
147
- | Key | Action |
148
- | ---------- | ------------------- |
149
- | `Ctrl+Z` | Leave to navigation |
150
- | `<leader>` | Enter layout mode |
151
- | `Ctrl+B` | Toggle sidebar |
152
-
153
- ### Layout Mode
154
-
155
- | Key | Action |
156
- | --------------------- | ---------------- |
157
- | `\|` | Split vertical |
158
- | `-` | Split horizontal |
159
- | `h` / `j` / `k` / `l` | Focus pane |
160
- | `Shift+H/J/K/L` | Resize pane |
161
- | `q` | Close pane |
162
- | `Esc` | Back to input |
163
-
164
- ## Architecture
165
-
166
- Runtime split:
167
-
168
- - `aimux` UI connects to the IPC daemon over the app protocol.
169
- - The IPC daemon owns the app-facing socket, protocol negotiation, and reconnect behavior.
170
- - The terminal manager owns PTYs, xterm headless emulators, and live session state.
171
-
172
- This split lets the app or IPC daemon change protocols without dropping live shells.
61
+ Then start the app:
173
62
 
63
+ ```bash
64
+ aimux
174
65
  ```
175
- aimux/
176
- ├── packages/
177
- │ └── aimux-config/ # published as @brimveyn/aimux-config
178
- │ └── src/ # types, builder, actions, themes, defaults
179
- └── src/ # the CLI (published as @brimveyn/aimux)
180
- ├── index.tsx # entry point + CLI dispatcher
181
- ├── app.tsx # main React app + state wiring
182
- ├── config/
183
- │ └── loader.ts # loads aimux.config.ts
184
- ├── ui/ # OpenTUI React components
185
- ├── state/ # reducers + app store
186
- ├── pty/ # PTY and terminal emulation
187
- ├── session-backend/ # local and remote backends
188
- ├── daemon/ # IPC daemon / broker
189
- ├── terminal-manager/ # long-lived PTY/session owner
190
- ├── ipc/ # app and manager protocols
191
- └── input/
192
- ├── modes/ # mode registry + transitions
193
- ├── keymap/ # prefix trie + sequence resolver
194
- └── raw-input-handler.ts
66
+
67
+ On first launch, use the session picker flow to create your first session.
68
+
69
+ For the full setup path, see [`docs/getting-started.md`](docs/getting-started.md).
70
+
71
+ ## Core Concepts
72
+
73
+ ### Profiles
74
+
75
+ Profile-managed config and catalogs live under:
76
+
77
+ ```text
78
+ ~/.config/aimux/<profile>/
195
79
  ```
196
80
 
197
- ## Tech Stack
81
+ The active profile is chosen from:
198
82
 
199
- - [Bun](https://bun.sh) — Runtime and toolchain
200
- - [React](https://react.dev) + [OpenTUI](https://github.com/sst/opentui) — Terminal UI framework
201
- - [xterm.js](https://xtermjs.org) (headless) — Terminal emulation
202
- - [bun-pty](https://github.com/nicolo-ribaudo/bun-pty) — Native PTY spawning
203
- - [Zustand](https://zustand-demo.pmnd.rs/) — State store
83
+ 1. `AIMUX_PROFILE`
84
+ 2. `AIMUX_RUNTIME_PROFILE`
85
+ 3. `default`
204
86
 
205
- ## Development
87
+ Runtime sockets live in a separate runtime directory that depends on the active
88
+ profile. See [`docs/concepts/profiles.md`](docs/concepts/profiles.md).
206
89
 
207
- ```bash
208
- git clone https://github.com/BrimVeyn/aimux && cd aimux
209
- bun install
90
+ ### Config vs Runtime State
91
+
92
+ `aimux` uses multiple files per profile:
93
+
94
+ - `aimux.config.ts` or `aimux.config.js` - typed user config
95
+ - `aimux.json` - app-managed preferences and runtime state
96
+ - `aimux-sessions.json` - session catalog and workspace snapshots
97
+ - `aimux-snippets.json` - snippet catalog
98
+
99
+ See [`docs/concepts/config-and-state.md`](docs/concepts/config-and-state.md).
210
100
 
211
- bun run dev # auto-reload dev mode
212
- bun run start # run from source
213
- bun test # run test suite
214
- bun run check # typecheck
215
- bun run lint # oxlint
101
+ ### Sessions
102
+
103
+ Sessions are named workspaces. A session can have:
104
+
105
+ - a name
106
+ - an optional project directory
107
+ - a persisted workspace snapshot
108
+ - an order in the session bar and session picker
109
+
110
+ See [`docs/guide/sessions.md`](docs/guide/sessions.md).
111
+
112
+ ### Keymaps
113
+
114
+ Keymaps are defined through `@brimveyn/aimux-config` and merged with shipped
115
+ defaults at startup.
116
+
117
+ Important runtime fact:
118
+
119
+ - the shipped leader key is `Ctrl+W`
120
+
121
+ See [`docs/guide/keymaps.md`](docs/guide/keymaps.md).
122
+
123
+ ## Default Everyday Shortcuts
124
+
125
+ - `?` - open help
126
+ - `i` - focus terminal
127
+ - `Ctrl+Z` - leave terminal-input mode
128
+ - `Ctrl+N` - open new-tab modal
129
+ - `Ctrl+G` - open session picker
130
+ - `Ctrl+S` - open snippet picker
131
+ - `Ctrl+T` - open theme picker
132
+ - `Ctrl+B` - toggle sidebar
133
+ - `Ctrl+W b` - toggle session bar
134
+ - `Ctrl+W 1` through `Ctrl+W 9` - switch sessions by index
135
+
136
+ The help modal reflects the resolved keymap, so it includes your overrides.
137
+
138
+ ## CLI
139
+
140
+ ```bash
141
+ aimux
142
+ aimux version
143
+ aimux doctor
144
+ aimux update
145
+ aimux restart-daemon
146
+ aimux restart-terminal-manager
216
147
  ```
217
148
 
218
- By default the app talks to the background IPC daemon. For explicit single-process debugging only, set `AIMUX_LOCAL_BACKEND=1` before starting aimux.
149
+ See [`docs/reference/cli.md`](docs/reference/cli.md) for behavior details.
150
+
151
+ ## Runtime Model
152
+
153
+ `aimux` is split into:
154
+
155
+ - the UI app
156
+ - an IPC daemon
157
+ - a long-lived terminal manager
219
158
 
220
- Profiles live under `~/.config/aimux/<profile>/`. Each profile gets its own config, session catalog, snippet catalog, and matching runtime namespace.
159
+ This split is what allows daemon restarts and some update paths without dropping
160
+ every live PTY immediately.
221
161
 
222
- The repository `bun run dev`, `bun run start`, and `bun run restart-daemon` scripts set `AIMUX_PROFILE=dev`, so source builds use `~/.config/aimux/dev/` and their own IPC daemon / terminal-manager sockets instead of interfering with a globally installed `aimux` instance.
162
+ See [`docs/developer/architecture.md`](docs/developer/architecture.md).
223
163
 
224
- You can override the active profile manually with `AIMUX_PROFILE=<name>` when you need multiple isolated environments on the same machine. `AIMUX_RUNTIME_PROFILE` is still accepted as a fallback alias for runtime compatibility.
164
+ ## Documentation Map
165
+
166
+ - [`docs/getting-started.md`](docs/getting-started.md)
167
+ - [`docs/concepts/config-and-state.md`](docs/concepts/config-and-state.md)
168
+ - [`docs/concepts/profiles.md`](docs/concepts/profiles.md)
169
+ - [`docs/guide/sessions.md`](docs/guide/sessions.md)
170
+ - [`docs/guide/keymaps.md`](docs/guide/keymaps.md)
171
+ - [`docs/guide/themes.md`](docs/guide/themes.md)
172
+ - [`docs/reference/cli.md`](docs/reference/cli.md)
173
+ - [`docs/reference/config-reference.md`](docs/reference/config-reference.md)
174
+ - [`docs/reference/runtime-paths.md`](docs/reference/runtime-paths.md)
175
+ - [`docs/developer/architecture.md`](docs/developer/architecture.md)
176
+ - [`docs/developer/aimux-config-internals.md`](docs/developer/aimux-config-internals.md)
177
+
178
+ ## Development
179
+
180
+ ```bash
181
+ git clone https://github.com/BrimVeyn/aimux && cd aimux
182
+ bun install
183
+
184
+ bun run dev
185
+ bun run start
186
+ bun test
187
+ bun run check
188
+ bun run lint
189
+ ```
225
190
 
226
- This profile move is intentionally breaking: aimux no longer reads legacy flat config or catalog files once profile directories are enabled.
191
+ The repository dev scripts use `AIMUX_PROFILE=dev`, so local development does
192
+ not collide with a globally installed `aimux` instance.
227
193
 
228
194
  ## License
229
195
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@brimveyn/aimux",
3
- "version": "1.4.0",
3
+ "version": "1.5.0",
4
4
  "description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
5
5
  "keywords": [
6
6
  "ai",
@@ -52,12 +52,15 @@
52
52
  "demo:splits": "vhs assets/splits.tape",
53
53
  "demo:themes": "vhs assets/themes.tape",
54
54
  "restart-daemon": "AIMUX_PROFILE=dev bun run src/index.tsx restart-daemon",
55
+ "restart-terminal-manager": "AIMUX_PROFILE=dev bun run src/index.tsx restart-terminal-manager",
55
56
  "lint": "oxlint .",
56
57
  "format": "oxfmt --write .",
57
- "format:check": "oxfmt --check ."
58
+ "format:check": "oxfmt --check .",
59
+ "knip": "knip-bun",
60
+ "bump": "bun run scripts/bump.ts"
58
61
  },
59
62
  "dependencies": {
60
- "@brimveyn/aimux-config": "0.3.0",
63
+ "@brimveyn/aimux-config": "0.3.1",
61
64
  "@opentui/core": "^0.1.90",
62
65
  "@opentui/react": "^0.1.90",
63
66
  "@xterm/headless": "^6.0.0",
@@ -70,6 +73,7 @@
70
73
  "@types/node": "^25.5.0",
71
74
  "@types/react": "^19.2.14",
72
75
  "eslint-plugin-perfectionist": "^5.8.0",
76
+ "knip": "^6.4.1",
73
77
  "lefthook": "^2.1.4",
74
78
  "oxfmt": "^0.42.0",
75
79
  "oxlint": "^1.57.0",
@@ -1,4 +1,4 @@
1
- import { type MutableRefObject, useEffect, useMemo, useRef } from 'react'
1
+ import { type MutableRefObject, useEffect, useLayoutEffect, useMemo, useRef } from 'react'
2
2
 
3
3
  import type { TerminalContentOrigin } from '../input/raw-input-handler'
4
4
  import type { SessionBackend } from '../session-backend/types'
@@ -28,20 +28,21 @@ function resizeSplitTabs(
28
28
  tabIds: string[],
29
29
  cols: number,
30
30
  rows: number,
31
- intents: Map<string, ScrollIntent>
31
+ intents: Map<string, ScrollIntent>,
32
+ options?: { sync?: boolean }
32
33
  ): void {
33
34
  const bounds = getTerminalBounds(cols, rows)
34
35
  const resizedTabIds = new Set<string>()
35
36
 
36
37
  forEachSplitPaneRect(Object.values(layoutTrees), bounds, (tabId, rect) => {
37
38
  const size = toTerminalContentSize(rect)
38
- backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId))
39
+ backend.resizeTab(tabId, size.cols, size.rows, intents.get(tabId), options)
39
40
  resizedTabIds.add(tabId)
40
41
  })
41
42
 
42
43
  for (const id of tabIds) {
43
44
  if (!resizedTabIds.has(id)) {
44
- backend.resizeTab(id, cols, rows, intents.get(id))
45
+ backend.resizeTab(id, cols, rows, intents.get(id), options)
45
46
  }
46
47
  }
47
48
  }
@@ -55,6 +56,50 @@ interface UseTerminalResizeOptions {
55
56
  resizingRef: MutableRefObject<boolean>
56
57
  }
57
58
 
59
+ interface RunResizeCascadeArgs {
60
+ backend: SessionBackend
61
+ dispatch: (action: AppAction) => void
62
+ resizingRef: MutableRefObject<boolean>
63
+ resizingTimerRef: MutableRefObject<ReturnType<typeof setTimeout> | null>
64
+ cols: number
65
+ rows: number
66
+ layoutTrees: AppState['layoutTrees']
67
+ stableTabIds: string[]
68
+ intents: Map<string, ScrollIntent>
69
+ sync: boolean
70
+ }
71
+
72
+ function runResizeCascade({
73
+ backend,
74
+ cols,
75
+ dispatch,
76
+ intents,
77
+ layoutTrees,
78
+ resizingRef,
79
+ resizingTimerRef,
80
+ rows,
81
+ stableTabIds,
82
+ sync,
83
+ }: RunResizeCascadeArgs): void {
84
+ dispatch({ cols, rows, type: 'set-terminal-size' })
85
+ resizingRef.current = true
86
+ if (resizingTimerRef.current) {
87
+ clearTimeout(resizingTimerRef.current)
88
+ }
89
+ const trees = Object.values(layoutTrees)
90
+ const hasSplits = trees.some((t) => t.type === 'split')
91
+ const options = sync ? { sync: true } : undefined
92
+ if (hasSplits) {
93
+ resizeSplitTabs(backend, layoutTrees, stableTabIds, cols, rows, intents, options)
94
+ } else {
95
+ backend.resizeAll(cols, rows, intents, options)
96
+ }
97
+ resizingTimerRef.current = setTimeout(() => {
98
+ resizingRef.current = false
99
+ resizingTimerRef.current = null
100
+ }, RESIZE_ACTIVITY_SETTLE_MS)
101
+ }
102
+
58
103
  export function useTerminalResize({
59
104
  backend,
60
105
  contentOriginRef,
@@ -66,6 +111,8 @@ export function useTerminalResize({
66
111
  const resizingTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
67
112
  const tabIdsRef = useRef<string[]>([])
68
113
  const intentsRef = useRef<Map<string, ScrollIntent>>(new Map())
114
+ const handledBySyncRef = useRef(false)
115
+ const sidebarMountedRef = useRef(false)
69
116
 
70
117
  const currentTabIds = state.tabs.map((t) => t.id)
71
118
  const tabIdsChanged =
@@ -82,21 +129,31 @@ export function useTerminalResize({
82
129
  .map((t) => [t.id, t.scrollIntent])
83
130
  )
84
131
 
132
+ const gitPaneInPaneMode = state.gitPane.mode === 'pane' && state.gitPane.visible
85
133
  const terminalSize = useMemo(() => {
86
134
  const sidebarWidth = state.sidebar.visible ? state.sidebar.width + 1 : 0
135
+ const sessionBarRows = state.sessionBar.visible ? 1 : 0
136
+ const sessionBarTopOffset =
137
+ state.sessionBar.visible && state.sessionBar.position === 'top' ? 1 : 0
87
138
  const reservedRows =
88
- MAIN_AREA_VERTICAL_PADDING + STATUS_BAR_HEIGHT + TERMINAL_PANE_VERTICAL_CHROME
139
+ MAIN_AREA_VERTICAL_PADDING +
140
+ STATUS_BAR_HEIGHT +
141
+ TERMINAL_PANE_VERTICAL_CHROME +
142
+ sessionBarRows
143
+ const gitPaneRaw = gitPaneInPaneMode ? Math.round(state.gitPane.ratio * 80) : 0
144
+ const gitPaneWidth = gitPaneInPaneMode ? Math.max(20, Math.min(80, gitPaneRaw)) : 0
145
+ const gitOnLeft = gitPaneInPaneMode && state.gitPane.position === 'left'
89
146
  const cols = Math.max(
90
147
  MIN_TERMINAL_COLS,
91
- Math.floor(dimensions.width - sidebarWidth - MAIN_AREA_HORIZONTAL_CHROME)
148
+ Math.floor(dimensions.width - sidebarWidth - gitPaneWidth - MAIN_AREA_HORIZONTAL_CHROME)
92
149
  )
93
150
  const rows = Math.max(MIN_TERMINAL_ROWS, Math.floor(dimensions.height - reservedRows))
94
151
 
95
152
  contentOriginRef.current = {
96
153
  cols,
97
154
  rows,
98
- x: sidebarWidth + 1,
99
- y: 1,
155
+ x: sidebarWidth + (gitOnLeft ? gitPaneWidth : 0) + 1,
156
+ y: 1 + sessionBarTopOffset,
100
157
  }
101
158
 
102
159
  return { cols, rows }
@@ -106,36 +163,59 @@ export function useTerminalResize({
106
163
  dimensions.width,
107
164
  state.sidebar.visible,
108
165
  state.sidebar.width,
166
+ state.sessionBar.visible,
167
+ state.sessionBar.position,
168
+ gitPaneInPaneMode,
169
+ state.gitPane.position,
170
+ state.gitPane.ratio,
109
171
  ])
110
172
 
111
- useEffect(() => {
112
- dispatch({
173
+ useLayoutEffect(() => {
174
+ if (!sidebarMountedRef.current) {
175
+ sidebarMountedRef.current = true
176
+ return
177
+ }
178
+ runResizeCascade({
179
+ backend,
113
180
  cols: terminalSize.cols,
181
+ dispatch,
182
+ intents: intentsRef.current,
183
+ layoutTrees: state.layoutTrees,
184
+ resizingRef,
185
+ resizingTimerRef,
114
186
  rows: terminalSize.rows,
115
- type: 'set-terminal-size',
187
+ stableTabIds,
188
+ sync: true,
116
189
  })
117
- resizingRef.current = true
118
- if (resizingTimerRef.current) {
119
- clearTimeout(resizingTimerRef.current)
120
- }
121
- const trees = Object.values(state.layoutTrees)
122
- const hasSplits = trees.some((t) => t.type === 'split')
123
- if (hasSplits) {
124
- resizeSplitTabs(
125
- backend,
126
- state.layoutTrees,
127
- stableTabIds,
128
- terminalSize.cols,
129
- terminalSize.rows,
130
- intentsRef.current
131
- )
132
- } else {
133
- backend.resizeAll(terminalSize.cols, terminalSize.rows, intentsRef.current)
190
+ handledBySyncRef.current = true
191
+ // eslint-disable-next-line react-hooks/exhaustive-deps
192
+ }, [
193
+ state.sidebar.visible,
194
+ state.sidebar.width,
195
+ state.sessionBar.visible,
196
+ state.sessionBar.position,
197
+ gitPaneInPaneMode,
198
+ state.gitPane.position,
199
+ state.gitPane.ratio,
200
+ ])
201
+
202
+ useEffect(() => {
203
+ if (handledBySyncRef.current) {
204
+ handledBySyncRef.current = false
205
+ return
134
206
  }
135
- resizingTimerRef.current = setTimeout(() => {
136
- resizingRef.current = false
137
- resizingTimerRef.current = null
138
- }, RESIZE_ACTIVITY_SETTLE_MS)
207
+ runResizeCascade({
208
+ backend,
209
+ cols: terminalSize.cols,
210
+ dispatch,
211
+ intents: intentsRef.current,
212
+ layoutTrees: state.layoutTrees,
213
+ resizingRef,
214
+ resizingTimerRef,
215
+ rows: terminalSize.rows,
216
+ stableTabIds,
217
+ sync: false,
218
+ })
139
219
  }, [
140
220
  backend,
141
221
  dispatch,
package/src/app.tsx CHANGED
@@ -25,6 +25,7 @@ import { useRendererBindings } from './app-runtime/use-renderer-bindings'
25
25
  import { useTerminalResize } from './app-runtime/use-terminal-resize'
26
26
  import { useWorkspaceAutosave } from './app-runtime/use-workspace-autosave'
27
27
  import { loadConfig } from './config'
28
+ import { setActiveKeymap } from './input/keymap/keymap-ref'
28
29
  import { deriveModeId } from './input/modes/bridge'
29
30
  import { registerAllModes } from './input/modes/handlers'
30
31
  import { getHandler, transitionTo } from './input/modes/registry'
@@ -54,7 +55,10 @@ export function App({
54
55
  resolvedConfig: ResolvedConfig
55
56
  }) {
56
57
  const keymapHandlers = useMemo(
57
- () => registerAllModes(resolvedConfig.keymaps),
58
+ () => {
59
+ setActiveKeymap(resolvedConfig.keymaps)
60
+ return registerAllModes(resolvedConfig.keymaps)
61
+ },
58
62
  // Registration has side effects in a global mode registry — run once per app instance.
59
63
  // eslint-disable-next-line react-hooks/exhaustive-deps
60
64
  []
@@ -73,14 +77,27 @@ export function App({
73
77
  const sessionBarVisible = resolvedConfig.sessionBar?.visible ?? json.sessionBarVisible ?? true
74
78
  const sessionBarPosition =
75
79
  resolvedConfig.sessionBar?.position ?? json.sessionBarPosition ?? 'top'
80
+
81
+ // Merge config-file gitPane (persisted prefs) with user's resolved gitPane
82
+ // (programmatic config). User config wins; file provides persisted prior state.
83
+ const userGitPane = resolvedConfig.gitPane
84
+ const gitPaneOverrides = {
85
+ ...json.gitPane,
86
+ ...(userGitPane?.visible !== undefined ? { visible: userGitPane.visible } : {}),
87
+ ...(userGitPane?.mode !== undefined ? { mode: userGitPane.mode } : {}),
88
+ ...(userGitPane?.position !== undefined ? { position: userGitPane.position } : {}),
89
+ ...(userGitPane?.ratio !== undefined ? { ratio: userGitPane.ratio } : {}),
90
+ ...(userGitPane?.path !== undefined ? { path: userGitPane.path } : {}),
91
+ ...(userGitPane?.diffCount !== undefined ? { diffCount: userGitPane.diffCount } : {}),
92
+ }
93
+
76
94
  return createInitialState(
77
95
  json.customCommands,
78
96
  loadSessionCatalog(),
79
97
  loadSnippetCatalog(),
80
98
  true,
81
99
  {
82
- gitPanelRatio: json.gitPanelRatio,
83
- gitPanelVisible: json.gitPanelVisible,
100
+ gitPane: gitPaneOverrides,
84
101
  sessionBarPosition,
85
102
  sessionBarVisible,
86
103
  }