@brimveyn/aimux 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +206 -0
- package/package.json +79 -0
- package/src/app-runtime/backend-attach-runtime.ts +135 -0
- package/src/app-runtime/backend-runtime-events.ts +78 -0
- package/src/app-runtime/click-selection-resolver.ts +130 -0
- package/src/app-runtime/pty-write.ts +32 -0
- package/src/app-runtime/render-invalidation.ts +20 -0
- package/src/app-runtime/selection-clipboard.ts +69 -0
- package/src/app-runtime/selection-scroll.ts +143 -0
- package/src/app-runtime/session-actions.ts +170 -0
- package/src/app-runtime/side-effects.ts +434 -0
- package/src/app-runtime/snippet-actions.ts +90 -0
- package/src/app-runtime/split-drag-controller.ts +15 -0
- package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
- package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
- package/src/app-runtime/use-backend-runtime.ts +99 -0
- package/src/app-runtime/use-directory-search.ts +52 -0
- package/src/app-runtime/use-mouse-handlers.ts +183 -0
- package/src/app-runtime/use-renderer-bindings.ts +163 -0
- package/src/app-runtime/use-terminal-resize.ts +141 -0
- package/src/app-runtime/use-workspace-autosave.ts +39 -0
- package/src/app.tsx +247 -0
- package/src/config/loader.ts +36 -0
- package/src/config.ts +146 -0
- package/src/daemon/daemon.ts +236 -0
- package/src/daemon/runtime-paths.ts +69 -0
- package/src/daemon/session-manager.ts +109 -0
- package/src/daemon/session-registry.ts +207 -0
- package/src/debug/input-log.ts +29 -0
- package/src/doctor.ts +106 -0
- package/src/git/git-poller.ts +49 -0
- package/src/git/git-status.ts +183 -0
- package/src/index.tsx +56 -0
- package/src/input/keymap/build-handlers.ts +34 -0
- package/src/input/keymap/key-chord.ts +201 -0
- package/src/input/keymap/keymap-mode-handler.ts +88 -0
- package/src/input/keymap/sequence-resolver.ts +117 -0
- package/src/input/keymap/trie.ts +103 -0
- package/src/input/modes/bridge.ts +58 -0
- package/src/input/modes/handlers/index.ts +18 -0
- package/src/input/modes/handlers/shared.ts +70 -0
- package/src/input/modes/registry.ts +34 -0
- package/src/input/modes/transitions.ts +37 -0
- package/src/input/modes/types.ts +63 -0
- package/src/input/mouse-forwarding.ts +98 -0
- package/src/input/multi-click-detector.ts +55 -0
- package/src/input/paste.ts +12 -0
- package/src/input/raw-input-handler.ts +191 -0
- package/src/input/terminal-text-extraction.ts +78 -0
- package/src/ipc/protocol.ts +341 -0
- package/src/platform/clipboard.ts +13 -0
- package/src/platform/daemon-control.ts +62 -0
- package/src/platform/id.ts +7 -0
- package/src/platform/project-search.ts +75 -0
- package/src/pty/command-registry.ts +69 -0
- package/src/pty/pty-manager.ts +268 -0
- package/src/pty/terminal-snapshot.ts +210 -0
- package/src/restart-daemon.ts +28 -0
- package/src/session-backend/bootstrap.ts +107 -0
- package/src/session-backend/local-session-backend.ts +164 -0
- package/src/session-backend/remote-session-backend.ts +373 -0
- package/src/session-backend/types.ts +47 -0
- package/src/state/app-store.ts +19 -0
- package/src/state/layout-resize.ts +56 -0
- package/src/state/layout-tree.ts +323 -0
- package/src/state/reducers/git-panel-state.ts +102 -0
- package/src/state/reducers/modal-state.ts +358 -0
- package/src/state/reducers/session-state.ts +86 -0
- package/src/state/reducers/tab-state.ts +531 -0
- package/src/state/reducers/ui-state.ts +29 -0
- package/src/state/selectors.ts +30 -0
- package/src/state/session-catalog.ts +96 -0
- package/src/state/session-persistence.ts +210 -0
- package/src/state/snippet-catalog.ts +90 -0
- package/src/state/store.ts +93 -0
- package/src/state/terminal-modes.ts +11 -0
- package/src/state/types.ts +367 -0
- package/src/state/validation.ts +154 -0
- package/src/state/workspace-save.ts +33 -0
- package/src/ui/components/create-session-modal.tsx +100 -0
- package/src/ui/components/git-panel.tsx +200 -0
- package/src/ui/components/help-modal.tsx +79 -0
- package/src/ui/components/input-field.tsx +18 -0
- package/src/ui/components/list-item.tsx +30 -0
- package/src/ui/components/modal-filter-bar.tsx +18 -0
- package/src/ui/components/modal-shell.tsx +47 -0
- package/src/ui/components/new-tab-modal.tsx +52 -0
- package/src/ui/components/pending-chord-indicator.tsx +41 -0
- package/src/ui/components/session-name-modal.tsx +15 -0
- package/src/ui/components/session-picker-modal.tsx +93 -0
- package/src/ui/components/sidebar-group-metadata.ts +44 -0
- package/src/ui/components/sidebar-scroll.ts +33 -0
- package/src/ui/components/sidebar.tsx +225 -0
- package/src/ui/components/snippet-editor-modal.tsx +39 -0
- package/src/ui/components/snippet-picker-modal.tsx +56 -0
- package/src/ui/components/split-layout.tsx +201 -0
- package/src/ui/components/status-bar.tsx +60 -0
- package/src/ui/components/surface.tsx +63 -0
- package/src/ui/components/tab-item.tsx +118 -0
- package/src/ui/components/terminal-pane.tsx +215 -0
- package/src/ui/components/theme-picker-modal.tsx +35 -0
- package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
- package/src/ui/components/use-sidebar-branch.ts +36 -0
- package/src/ui/directory-search.ts +1 -0
- package/src/ui/git-branch.ts +11 -0
- package/src/ui/path-format.ts +6 -0
- package/src/ui/root.tsx +261 -0
- package/src/ui/status-bar-model.ts +87 -0
- package/src/ui/theme.ts +9 -0
- package/src/ui/themes.ts +255 -0
- package/src/ui/ui-tokens.ts +11 -0
- package/src/update.ts +62 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 BrimVeyn
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
# aimux
|
|
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.
|
|
4
|
+
|
|
5
|
+

|
|
6
|
+

|
|
7
|
+

|
|
8
|
+
|
|
9
|
+

|
|
10
|
+
|
|
11
|
+
## Features
|
|
12
|
+
|
|
13
|
+
- **Multi-tab sessions** — Run Claude, Codex, and OpenCode in parallel with instant tab switching
|
|
14
|
+
- **Split panes** — Split vertically (`|`) or horizontally (`-`) to view multiple assistants at once
|
|
15
|
+
- **Draggable separators** — Resize split panes by dragging with the mouse
|
|
16
|
+
- **Click-to-focus** — Click any pane or sidebar tab to focus it instantly
|
|
17
|
+
- **Full terminal emulation** — Powered by xterm.js with mouse tracking, alternate buffer, and scrollback
|
|
18
|
+
- **Configurable nvim-style keymaps** — Define keybinds in a typed `aimux.config.ts` with leader keys, multi-key sequences, and a prefix-trie resolver
|
|
19
|
+
- **Text selection** — Double-click for a word, triple-click for a line, drag for a region. Selections copy to system clipboard automatically
|
|
20
|
+
- **Project-scoped sessions** — Associate a git repository with each session; all tabs spawn in that directory
|
|
21
|
+
- **Directory picker** — Fuzzy-search git repos and worktrees from `$HOME` using `fzf`
|
|
22
|
+
- **Session persistence** — Workspace state (tabs, titles, layout) saved and restored on restart
|
|
23
|
+
- **Git status panel** — Branch + diff summary in the sidebar
|
|
24
|
+
- **Daemon mode** — Background daemon keeps sessions alive across terminal restarts
|
|
25
|
+
- **Snippets** — Save and reuse prompt snippets across sessions
|
|
26
|
+
- **Theme picker** — Switch between 11 built-in themes on the fly
|
|
27
|
+
- **Pending-chord indicator** — Bottom-right overlay shows mid-sequence key state (like nvim's `which-key`)
|
|
28
|
+
- **Built-in help** — Press `?` to see all keybindings
|
|
29
|
+
|
|
30
|
+
### Session Management
|
|
31
|
+
|
|
32
|
+

|
|
33
|
+
|
|
34
|
+
### Multi-Tab Workflow
|
|
35
|
+
|
|
36
|
+

|
|
37
|
+
|
|
38
|
+
### Themes
|
|
39
|
+
|
|
40
|
+

|
|
41
|
+
|
|
42
|
+
### Split Panes
|
|
43
|
+
|
|
44
|
+

|
|
45
|
+
|
|
46
|
+
## Install
|
|
47
|
+
|
|
48
|
+
```bash
|
|
49
|
+
bun install -g @brimveyn/aimux
|
|
50
|
+
```
|
|
51
|
+
|
|
52
|
+
Requires [Bun](https://bun.sh).
|
|
53
|
+
|
|
54
|
+
## Usage
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
aimux # start the TUI
|
|
58
|
+
aimux version # print version
|
|
59
|
+
aimux doctor # check setup
|
|
60
|
+
aimux update # self-update
|
|
61
|
+
aimux restart-daemon # restart background daemon
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configuration
|
|
65
|
+
|
|
66
|
+
aimux reads `~/.config/aimux/aimux.config.ts` at startup. Set it up with:
|
|
67
|
+
|
|
68
|
+
```bash
|
|
69
|
+
mkdir -p ~/.config/aimux && cd ~/.config/aimux
|
|
70
|
+
bun init -y
|
|
71
|
+
bun add -d @brimveyn/aimux-config
|
|
72
|
+
```
|
|
73
|
+
|
|
74
|
+
Then create `~/.config/aimux/aimux.config.ts`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
import { defineConfig, actions, themes } from '@brimveyn/aimux-config'
|
|
78
|
+
|
|
79
|
+
export default defineConfig({
|
|
80
|
+
theme: themes.extend('tokyo-night', { accent: '#ff9e64' }),
|
|
81
|
+
|
|
82
|
+
keymaps: (k) =>
|
|
83
|
+
k
|
|
84
|
+
.leader('<Space>')
|
|
85
|
+
.timeout(300)
|
|
86
|
+
.mode('navigation', (m) =>
|
|
87
|
+
m
|
|
88
|
+
.map('j', actions.nextTab)
|
|
89
|
+
.map('k', actions.prevTab)
|
|
90
|
+
.map('<leader>g', actions.sessionPicker)
|
|
91
|
+
.group('<leader>t', 'tabs', (g) =>
|
|
92
|
+
g.map('n', actions.newTab).map('r', actions.renameTab).map('x', actions.closeTab)
|
|
93
|
+
)
|
|
94
|
+
)
|
|
95
|
+
.mode('layout', (m) => m.map('|', actions.splitVertical).map('-', actions.splitHorizontal)),
|
|
96
|
+
})
|
|
97
|
+
```
|
|
98
|
+
|
|
99
|
+
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.
|
|
100
|
+
|
|
101
|
+
### Key notation
|
|
102
|
+
|
|
103
|
+
| Notation | Meaning |
|
|
104
|
+
| -------------- | -------------------------- |
|
|
105
|
+
| `j` | Bare character |
|
|
106
|
+
| `J` | Shift+J (uppercase letter) |
|
|
107
|
+
| `<C-n>` | Ctrl+N |
|
|
108
|
+
| `<M-x>` | Meta/Alt+X |
|
|
109
|
+
| `<CR>` `<Esc>` | Return / Escape |
|
|
110
|
+
| `<leader>` | Configured leader chord |
|
|
111
|
+
| `dd` | Multi-key sequence |
|
|
112
|
+
| `<leader>tn` | Leader, then t, then n |
|
|
113
|
+
|
|
114
|
+
## Default Keymaps
|
|
115
|
+
|
|
116
|
+
Press `?` in navigation mode for the full, live keybinding list (reflects your config).
|
|
117
|
+
|
|
118
|
+
### Navigation Mode
|
|
119
|
+
|
|
120
|
+
| Key | Action |
|
|
121
|
+
| --------------------- | -------------------- |
|
|
122
|
+
| `j` / `k` | Next / previous tab |
|
|
123
|
+
| `Shift+J` / `Shift+K` | Reorder tabs |
|
|
124
|
+
| `i` | Enter terminal input |
|
|
125
|
+
| `r` | Rename active tab |
|
|
126
|
+
| `dd` | Close active tab |
|
|
127
|
+
| `Ctrl+N` | New tab |
|
|
128
|
+
| `Ctrl+R` | Restart tab |
|
|
129
|
+
| `Ctrl+G` | Session picker |
|
|
130
|
+
| `Ctrl+B` | Toggle sidebar |
|
|
131
|
+
| `Ctrl+H` / `Ctrl+L` | Resize sidebar |
|
|
132
|
+
| `Ctrl+S` | Snippet picker |
|
|
133
|
+
| `Ctrl+T` | Theme picker |
|
|
134
|
+
| `G` | Toggle git panel |
|
|
135
|
+
| `?` | Show help |
|
|
136
|
+
| `Ctrl+C` | Quit |
|
|
137
|
+
|
|
138
|
+
### Terminal Input Mode
|
|
139
|
+
|
|
140
|
+
Keystrokes pass through to the active tab's PTY. Configured shortcuts:
|
|
141
|
+
|
|
142
|
+
| Key | Action |
|
|
143
|
+
| ---------- | ------------------- |
|
|
144
|
+
| `Ctrl+Z` | Leave to navigation |
|
|
145
|
+
| `<leader>` | Enter layout mode |
|
|
146
|
+
| `Ctrl+B` | Toggle sidebar |
|
|
147
|
+
|
|
148
|
+
### Layout Mode
|
|
149
|
+
|
|
150
|
+
| Key | Action |
|
|
151
|
+
| --------------------- | ---------------- |
|
|
152
|
+
| `\|` | Split vertical |
|
|
153
|
+
| `-` | Split horizontal |
|
|
154
|
+
| `h` / `j` / `k` / `l` | Focus pane |
|
|
155
|
+
| `Shift+H/J/K/L` | Resize pane |
|
|
156
|
+
| `q` | Close pane |
|
|
157
|
+
| `Esc` | Back to input |
|
|
158
|
+
|
|
159
|
+
## Architecture
|
|
160
|
+
|
|
161
|
+
```
|
|
162
|
+
aimux/
|
|
163
|
+
├── packages/
|
|
164
|
+
│ └── aimux-config/ # published as @brimveyn/aimux-config
|
|
165
|
+
│ └── src/ # types, builder, actions, themes, defaults
|
|
166
|
+
└── src/ # the CLI (published as @brimveyn/aimux)
|
|
167
|
+
├── index.tsx # entry point + CLI dispatcher
|
|
168
|
+
├── app.tsx # main React app + state wiring
|
|
169
|
+
├── config/
|
|
170
|
+
│ └── loader.ts # loads aimux.config.ts
|
|
171
|
+
├── ui/ # OpenTUI React components
|
|
172
|
+
├── state/ # reducers + app store
|
|
173
|
+
├── pty/ # PTY and terminal emulation
|
|
174
|
+
├── session-backend/ # local and daemon backends
|
|
175
|
+
├── daemon/ # background session daemon
|
|
176
|
+
├── ipc/ # daemon protocol
|
|
177
|
+
└── input/
|
|
178
|
+
├── modes/ # mode registry + transitions
|
|
179
|
+
├── keymap/ # prefix trie + sequence resolver
|
|
180
|
+
└── raw-input-handler.ts
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
## Tech Stack
|
|
184
|
+
|
|
185
|
+
- [Bun](https://bun.sh) — Runtime and toolchain
|
|
186
|
+
- [React](https://react.dev) + [OpenTUI](https://github.com/sst/opentui) — Terminal UI framework
|
|
187
|
+
- [xterm.js](https://xtermjs.org) (headless) — Terminal emulation
|
|
188
|
+
- [bun-pty](https://github.com/nicolo-ribaudo/bun-pty) — Native PTY spawning
|
|
189
|
+
- [Zustand](https://zustand-demo.pmnd.rs/) — State store
|
|
190
|
+
|
|
191
|
+
## Development
|
|
192
|
+
|
|
193
|
+
```bash
|
|
194
|
+
git clone https://github.com/BrimVeyn/aimux && cd aimux
|
|
195
|
+
bun install
|
|
196
|
+
|
|
197
|
+
bun run dev # auto-reload dev mode
|
|
198
|
+
bun run start # run from source
|
|
199
|
+
bun test # run test suite
|
|
200
|
+
bun run check # typecheck
|
|
201
|
+
bun run lint # oxlint
|
|
202
|
+
```
|
|
203
|
+
|
|
204
|
+
## License
|
|
205
|
+
|
|
206
|
+
MIT © BrimVeyn
|
package/package.json
ADDED
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@brimveyn/aimux",
|
|
3
|
+
"version": "1.1.0",
|
|
4
|
+
"description": "A terminal multiplexer for AI CLIs. Run Claude, Codex, OpenCode side-by-side with tabbed navigation, split panes, and persistent sessions.",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"ai",
|
|
7
|
+
"aimux",
|
|
8
|
+
"bun",
|
|
9
|
+
"claude",
|
|
10
|
+
"cli",
|
|
11
|
+
"codex",
|
|
12
|
+
"multiplexer",
|
|
13
|
+
"opencode",
|
|
14
|
+
"pty",
|
|
15
|
+
"terminal",
|
|
16
|
+
"tui"
|
|
17
|
+
],
|
|
18
|
+
"homepage": "https://github.com/BrimVeyn/aimux#readme",
|
|
19
|
+
"bugs": {
|
|
20
|
+
"url": "https://github.com/BrimVeyn/aimux/issues"
|
|
21
|
+
},
|
|
22
|
+
"license": "MIT",
|
|
23
|
+
"author": "BrimVeyn",
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/BrimVeyn/aimux.git"
|
|
27
|
+
},
|
|
28
|
+
"bin": {
|
|
29
|
+
"aimux": "src/index.tsx"
|
|
30
|
+
},
|
|
31
|
+
"workspaces": [
|
|
32
|
+
"packages/*"
|
|
33
|
+
],
|
|
34
|
+
"files": [
|
|
35
|
+
"src",
|
|
36
|
+
"README.md",
|
|
37
|
+
"LICENSE"
|
|
38
|
+
],
|
|
39
|
+
"type": "module",
|
|
40
|
+
"module": "src/index.tsx",
|
|
41
|
+
"publishConfig": {
|
|
42
|
+
"access": "public"
|
|
43
|
+
},
|
|
44
|
+
"scripts": {
|
|
45
|
+
"dev": "bun --watch src/index.tsx",
|
|
46
|
+
"start": "bun run src/index.tsx",
|
|
47
|
+
"test": "bun test",
|
|
48
|
+
"check": "tsc --noEmit",
|
|
49
|
+
"demo": "vhs assets/demo.tape",
|
|
50
|
+
"demo:sessions": "vhs assets/sessions.tape",
|
|
51
|
+
"demo:tabs": "vhs assets/tabs.tape",
|
|
52
|
+
"demo:splits": "vhs assets/splits.tape",
|
|
53
|
+
"demo:themes": "vhs assets/themes.tape",
|
|
54
|
+
"restart-daemon": "bun run src/index.tsx restart-daemon",
|
|
55
|
+
"lint": "oxlint .",
|
|
56
|
+
"format": "oxfmt --write .",
|
|
57
|
+
"format:check": "oxfmt --check .",
|
|
58
|
+
"prepare": "lefthook install"
|
|
59
|
+
},
|
|
60
|
+
"dependencies": {
|
|
61
|
+
"@brimveyn/aimux-config": "^0.1.0",
|
|
62
|
+
"@opentui/core": "^0.1.90",
|
|
63
|
+
"@opentui/react": "^0.1.90",
|
|
64
|
+
"@xterm/headless": "^6.0.0",
|
|
65
|
+
"bun-pty": "^0.4.8",
|
|
66
|
+
"react": "^19.2.4",
|
|
67
|
+
"zustand": "^5.0.12"
|
|
68
|
+
},
|
|
69
|
+
"devDependencies": {
|
|
70
|
+
"@types/bun": "latest",
|
|
71
|
+
"@types/node": "^25.5.0",
|
|
72
|
+
"@types/react": "^19.2.14",
|
|
73
|
+
"eslint-plugin-perfectionist": "^5.8.0",
|
|
74
|
+
"lefthook": "^2.1.4",
|
|
75
|
+
"oxfmt": "^0.42.0",
|
|
76
|
+
"oxlint": "^1.57.0",
|
|
77
|
+
"typescript": "^6.0.2"
|
|
78
|
+
}
|
|
79
|
+
}
|
|
@@ -0,0 +1,135 @@
|
|
|
1
|
+
import type { MutableRefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
4
|
+
import type { AppAction, LayoutState, WorkspaceSnapshotV1 } from '../state/types'
|
|
5
|
+
|
|
6
|
+
import { logInputDebug } from '../debug/input-log'
|
|
7
|
+
import {
|
|
8
|
+
createTerminalBounds,
|
|
9
|
+
forEachSplitPaneRect,
|
|
10
|
+
getSnapshotTrees,
|
|
11
|
+
toTerminalContentSize,
|
|
12
|
+
} from '../state/layout-resize'
|
|
13
|
+
|
|
14
|
+
export function resizeSnapshotPanes(
|
|
15
|
+
snapshot: WorkspaceSnapshotV1 | undefined,
|
|
16
|
+
layoutRef: MutableRefObject<LayoutState>,
|
|
17
|
+
backend: SessionBackend
|
|
18
|
+
): void {
|
|
19
|
+
if (!snapshot) {
|
|
20
|
+
return
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const trees = getSnapshotTrees(snapshot)
|
|
24
|
+
const bounds = createTerminalBounds(
|
|
25
|
+
layoutRef.current.terminalCols,
|
|
26
|
+
layoutRef.current.terminalRows
|
|
27
|
+
)
|
|
28
|
+
forEachSplitPaneRect(trees, bounds, (tabId, rect) => {
|
|
29
|
+
const size = toTerminalContentSize(rect)
|
|
30
|
+
backend.resizeTab(tabId, size.cols, size.rows)
|
|
31
|
+
})
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
function hydrateAttachedSession(
|
|
35
|
+
dispatch: (action: AppAction) => void,
|
|
36
|
+
sessionId: string,
|
|
37
|
+
workspaceSnapshot: WorkspaceSnapshotV1 | undefined,
|
|
38
|
+
result: Awaited<ReturnType<SessionBackend['attach']>>,
|
|
39
|
+
layoutRef: MutableRefObject<LayoutState>,
|
|
40
|
+
backend: SessionBackend
|
|
41
|
+
): void {
|
|
42
|
+
if (result) {
|
|
43
|
+
dispatch({
|
|
44
|
+
activeTabId: result.activeTabId,
|
|
45
|
+
layoutTree: workspaceSnapshot?.layoutTree,
|
|
46
|
+
layoutTrees: workspaceSnapshot?.layoutTrees,
|
|
47
|
+
tabGroupMap: workspaceSnapshot?.tabGroupMap,
|
|
48
|
+
tabs: result.tabs,
|
|
49
|
+
type: 'hydrate-workspace',
|
|
50
|
+
})
|
|
51
|
+
resizeSnapshotPanes(workspaceSnapshot, layoutRef, backend)
|
|
52
|
+
return
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!workspaceSnapshot) {
|
|
56
|
+
return
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
dispatch({
|
|
60
|
+
sessionId,
|
|
61
|
+
type: 'load-session',
|
|
62
|
+
workspaceSnapshot,
|
|
63
|
+
})
|
|
64
|
+
resizeSnapshotPanes(workspaceSnapshot, layoutRef, backend)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
interface AttachCurrentSessionOptions {
|
|
68
|
+
backend: SessionBackend
|
|
69
|
+
dispatch: (action: AppAction) => void
|
|
70
|
+
currentSessionId: string
|
|
71
|
+
currentSessionWorkspaceSnapshot: Parameters<SessionBackend['attach']>[0]['workspaceSnapshot']
|
|
72
|
+
layoutRef: MutableRefObject<LayoutState>
|
|
73
|
+
attachRequestIdRef: MutableRefObject<number>
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
export function attachCurrentSession({
|
|
77
|
+
attachRequestIdRef,
|
|
78
|
+
backend,
|
|
79
|
+
currentSessionId,
|
|
80
|
+
currentSessionWorkspaceSnapshot,
|
|
81
|
+
dispatch,
|
|
82
|
+
layoutRef,
|
|
83
|
+
}: AttachCurrentSessionOptions): () => void {
|
|
84
|
+
const attachRequestId = attachRequestIdRef.current + 1
|
|
85
|
+
attachRequestIdRef.current = attachRequestId
|
|
86
|
+
let cancelled = false
|
|
87
|
+
|
|
88
|
+
void backend
|
|
89
|
+
.attach({
|
|
90
|
+
cols: layoutRef.current.terminalCols,
|
|
91
|
+
rows: layoutRef.current.terminalRows,
|
|
92
|
+
sessionId: currentSessionId,
|
|
93
|
+
workspaceSnapshot: currentSessionWorkspaceSnapshot,
|
|
94
|
+
})
|
|
95
|
+
.then((result) => {
|
|
96
|
+
if (cancelled || attachRequestIdRef.current !== attachRequestId) {
|
|
97
|
+
return
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
logInputDebug('app.backend.attachResult', {
|
|
101
|
+
activeTabId: result?.activeTabId ?? null,
|
|
102
|
+
hasResult: !!result,
|
|
103
|
+
tabs: result?.tabs.length ?? 0,
|
|
104
|
+
})
|
|
105
|
+
hydrateAttachedSession(
|
|
106
|
+
dispatch,
|
|
107
|
+
currentSessionId,
|
|
108
|
+
currentSessionWorkspaceSnapshot,
|
|
109
|
+
result,
|
|
110
|
+
layoutRef,
|
|
111
|
+
backend
|
|
112
|
+
)
|
|
113
|
+
})
|
|
114
|
+
.catch((error) => {
|
|
115
|
+
if (cancelled || attachRequestIdRef.current !== attachRequestId) {
|
|
116
|
+
return
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
logInputDebug('app.backend.attachError', {
|
|
120
|
+
error: error instanceof Error ? error.message : String(error),
|
|
121
|
+
})
|
|
122
|
+
hydrateAttachedSession(
|
|
123
|
+
dispatch,
|
|
124
|
+
currentSessionId,
|
|
125
|
+
currentSessionWorkspaceSnapshot,
|
|
126
|
+
null,
|
|
127
|
+
layoutRef,
|
|
128
|
+
backend
|
|
129
|
+
)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
return () => {
|
|
133
|
+
cancelled = true
|
|
134
|
+
}
|
|
135
|
+
}
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
import type { MutableRefObject } from 'react'
|
|
2
|
+
|
|
3
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
4
|
+
import type { AppAction, TabSession, TerminalModeState } from '../state/types'
|
|
5
|
+
|
|
6
|
+
import { type TabRuntimeTimeouts } from './tab-runtime-timeouts'
|
|
7
|
+
|
|
8
|
+
const IDLE_ACTIVITY_TIMEOUT_MS = 2_000
|
|
9
|
+
|
|
10
|
+
interface BindBackendRuntimeEventsOptions {
|
|
11
|
+
backend: SessionBackend
|
|
12
|
+
dispatch: (action: AppAction) => void
|
|
13
|
+
resizingRef: MutableRefObject<boolean>
|
|
14
|
+
timeouts: Pick<
|
|
15
|
+
TabRuntimeTimeouts,
|
|
16
|
+
| 'clearIdleTimer'
|
|
17
|
+
| 'clearStartupGrace'
|
|
18
|
+
| 'isStartupGraceActive'
|
|
19
|
+
| 'scheduleIdle'
|
|
20
|
+
| 'clearAllTimers'
|
|
21
|
+
>
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function clearTabRuntimeState(
|
|
25
|
+
timeouts: Pick<TabRuntimeTimeouts, 'clearIdleTimer' | 'clearStartupGrace'>,
|
|
26
|
+
tabId: string
|
|
27
|
+
): void {
|
|
28
|
+
timeouts.clearIdleTimer(tabId)
|
|
29
|
+
timeouts.clearStartupGrace(tabId)
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function bindBackendRuntimeEvents({
|
|
33
|
+
backend,
|
|
34
|
+
dispatch,
|
|
35
|
+
resizingRef,
|
|
36
|
+
timeouts,
|
|
37
|
+
}: BindBackendRuntimeEventsOptions): () => void {
|
|
38
|
+
const handleRender = (
|
|
39
|
+
tabId: string,
|
|
40
|
+
viewport: TabSession['viewport'],
|
|
41
|
+
terminalModes: TerminalModeState
|
|
42
|
+
) => {
|
|
43
|
+
if (!viewport) {
|
|
44
|
+
return
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
dispatch({ tabId, terminalModes, type: 'replace-tab-viewport', viewport })
|
|
48
|
+
if (timeouts.isStartupGraceActive(tabId) || resizingRef.current) {
|
|
49
|
+
return
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
dispatch({ activity: 'busy', tabId, type: 'set-tab-activity' })
|
|
53
|
+
timeouts.scheduleIdle(tabId, IDLE_ACTIVITY_TIMEOUT_MS)
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const handleExit = (tabId: string, exitCode: number) => {
|
|
57
|
+
clearTabRuntimeState(timeouts, tabId)
|
|
58
|
+
dispatch({ exitCode, status: 'exited', tabId, type: 'set-tab-status' })
|
|
59
|
+
dispatch({ activity: undefined, tabId, type: 'set-tab-activity' })
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
const handleError = (tabId: string, message: string) => {
|
|
63
|
+
clearTabRuntimeState(timeouts, tabId)
|
|
64
|
+
dispatch({ message, tabId, type: 'set-tab-error' })
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
backend.on('render', handleRender)
|
|
68
|
+
backend.on('exit', handleExit)
|
|
69
|
+
backend.on('error', handleError)
|
|
70
|
+
|
|
71
|
+
return () => {
|
|
72
|
+
timeouts.clearAllTimers()
|
|
73
|
+
backend.off('render', handleRender)
|
|
74
|
+
backend.off('exit', handleExit)
|
|
75
|
+
backend.off('error', handleError)
|
|
76
|
+
void backend.destroy(true)
|
|
77
|
+
}
|
|
78
|
+
}
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
import type { MouseEvent as OtuiMouseEvent } from '@opentui/core'
|
|
2
|
+
|
|
3
|
+
import type { TabSession } from '../state/types'
|
|
4
|
+
|
|
5
|
+
import { logInputDebug } from '../debug/input-log'
|
|
6
|
+
import { getLineText, getWordAtColumn } from '../input/terminal-text-extraction'
|
|
7
|
+
|
|
8
|
+
interface PositionedNode {
|
|
9
|
+
id?: string
|
|
10
|
+
parent?: unknown
|
|
11
|
+
selectable?: boolean
|
|
12
|
+
x: number
|
|
13
|
+
y: number
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface ClickSelectionResult {
|
|
17
|
+
selectedText: string
|
|
18
|
+
startCol: number
|
|
19
|
+
endCol: number
|
|
20
|
+
baseX: number
|
|
21
|
+
eventY: number
|
|
22
|
+
target: unknown
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function isPositionedNode(value: unknown): value is PositionedNode {
|
|
26
|
+
return (
|
|
27
|
+
typeof value === 'object' &&
|
|
28
|
+
value !== null &&
|
|
29
|
+
typeof Reflect.get(value, 'x') === 'number' &&
|
|
30
|
+
typeof Reflect.get(value, 'y') === 'number'
|
|
31
|
+
)
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export function resolveClickSelection(
|
|
35
|
+
event: OtuiMouseEvent,
|
|
36
|
+
targetTabId: string,
|
|
37
|
+
tab: TabSession | undefined,
|
|
38
|
+
clickCount: number
|
|
39
|
+
): ClickSelectionResult | null {
|
|
40
|
+
if (!event.target) {
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
const viewportText = event.target
|
|
45
|
+
if (!isPositionedNode(viewportText)) {
|
|
46
|
+
return null
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
const col = event.x - viewportText.x
|
|
50
|
+
const row = event.y - viewportText.y
|
|
51
|
+
const baseX = viewportText.x
|
|
52
|
+
|
|
53
|
+
logInputDebug('click.detect', {
|
|
54
|
+
clickCount,
|
|
55
|
+
col,
|
|
56
|
+
eventX: event.x,
|
|
57
|
+
eventY: event.y,
|
|
58
|
+
row,
|
|
59
|
+
targetId: event.target.id,
|
|
60
|
+
viewportX: viewportText.x,
|
|
61
|
+
viewportY: viewportText.y,
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
if (!tab?.viewport?.lines[row]) {
|
|
65
|
+
logInputDebug('click.noViewportLine', {
|
|
66
|
+
hasViewport: !!tab?.viewport,
|
|
67
|
+
lineCount: tab?.viewport?.lines.length ?? 0,
|
|
68
|
+
row,
|
|
69
|
+
tabFound: !!tab,
|
|
70
|
+
targetTabId,
|
|
71
|
+
})
|
|
72
|
+
return null
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
const line = tab.viewport.lines[row]
|
|
76
|
+
const lineText = getLineText(line)
|
|
77
|
+
|
|
78
|
+
let selectedText: string
|
|
79
|
+
let startCol: number
|
|
80
|
+
let endCol: number
|
|
81
|
+
|
|
82
|
+
if (clickCount === 2) {
|
|
83
|
+
const word = getWordAtColumn(lineText, col)
|
|
84
|
+
if (word.text.length === 0) {
|
|
85
|
+
logInputDebug('click.emptyWord', {
|
|
86
|
+
charAtCol: lineText[col] ?? 'OOB',
|
|
87
|
+
col,
|
|
88
|
+
lineText,
|
|
89
|
+
row,
|
|
90
|
+
})
|
|
91
|
+
return null
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
selectedText = word.text
|
|
95
|
+
startCol = word.startCol
|
|
96
|
+
endCol = word.endCol
|
|
97
|
+
} else {
|
|
98
|
+
selectedText = lineText
|
|
99
|
+
startCol = 0
|
|
100
|
+
endCol = lineText.length
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
logInputDebug('click.selection', {
|
|
104
|
+
baseX,
|
|
105
|
+
clickCount,
|
|
106
|
+
endCol,
|
|
107
|
+
endX: baseX + endCol,
|
|
108
|
+
lineText,
|
|
109
|
+
selectedText,
|
|
110
|
+
spanCount: line.spans.length,
|
|
111
|
+
spanStyles: line.spans.map((span) => ({
|
|
112
|
+
bold: span.bold,
|
|
113
|
+
italic: span.italic,
|
|
114
|
+
underline: span.underline,
|
|
115
|
+
})),
|
|
116
|
+
spanTexts: line.spans.map((span) => span.text),
|
|
117
|
+
startCol,
|
|
118
|
+
startX: baseX + startCol,
|
|
119
|
+
y: event.y,
|
|
120
|
+
})
|
|
121
|
+
|
|
122
|
+
return {
|
|
123
|
+
baseX,
|
|
124
|
+
endCol,
|
|
125
|
+
eventY: event.y,
|
|
126
|
+
selectedText,
|
|
127
|
+
startCol,
|
|
128
|
+
target: event.target,
|
|
129
|
+
}
|
|
130
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { SessionBackend } from '../session-backend/types'
|
|
2
|
+
import type { TabSession } from '../state/types'
|
|
3
|
+
|
|
4
|
+
import { buildPtyPastePayload } from '../input/paste'
|
|
5
|
+
|
|
6
|
+
function shouldScrollViewportToBottom(tab: TabSession): boolean {
|
|
7
|
+
const viewport = tab.viewport
|
|
8
|
+
return viewport !== undefined && viewport.viewportY < viewport.baseY
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function writeToTab(
|
|
12
|
+
backend: SessionBackend,
|
|
13
|
+
tabId: string,
|
|
14
|
+
tab: TabSession | undefined,
|
|
15
|
+
input: string
|
|
16
|
+
): void {
|
|
17
|
+
if (tab && shouldScrollViewportToBottom(tab)) {
|
|
18
|
+
backend.scrollViewportToBottom(tabId)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
backend.write(tabId, input)
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function writePasteToTab(
|
|
25
|
+
backend: SessionBackend,
|
|
26
|
+
tabId: string,
|
|
27
|
+
tab: TabSession | undefined,
|
|
28
|
+
text: string
|
|
29
|
+
): void {
|
|
30
|
+
const payload = buildPtyPastePayload(text, tab?.terminalModes.bracketedPasteMode ?? false)
|
|
31
|
+
writeToTab(backend, tabId, tab, payload)
|
|
32
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export interface RenderableNode {
|
|
2
|
+
parent?: unknown
|
|
3
|
+
requestRender(): void
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function isRenderableNode(value: unknown): value is RenderableNode {
|
|
7
|
+
return (
|
|
8
|
+
typeof value === 'object' &&
|
|
9
|
+
value !== null &&
|
|
10
|
+
typeof Reflect.get(value, 'requestRender') === 'function'
|
|
11
|
+
)
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export function requestRenderUpTree(node: unknown): void {
|
|
15
|
+
let currentNode = node
|
|
16
|
+
while (isRenderableNode(currentNode)) {
|
|
17
|
+
currentNode.requestRender()
|
|
18
|
+
currentNode = currentNode.parent
|
|
19
|
+
}
|
|
20
|
+
}
|