@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.
Files changed (113) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +206 -0
  3. package/package.json +79 -0
  4. package/src/app-runtime/backend-attach-runtime.ts +135 -0
  5. package/src/app-runtime/backend-runtime-events.ts +78 -0
  6. package/src/app-runtime/click-selection-resolver.ts +130 -0
  7. package/src/app-runtime/pty-write.ts +32 -0
  8. package/src/app-runtime/render-invalidation.ts +20 -0
  9. package/src/app-runtime/selection-clipboard.ts +69 -0
  10. package/src/app-runtime/selection-scroll.ts +143 -0
  11. package/src/app-runtime/session-actions.ts +170 -0
  12. package/src/app-runtime/side-effects.ts +434 -0
  13. package/src/app-runtime/snippet-actions.ts +90 -0
  14. package/src/app-runtime/split-drag-controller.ts +15 -0
  15. package/src/app-runtime/tab-runtime-timeouts.ts +86 -0
  16. package/src/app-runtime/terminal-mouse-adapter.ts +31 -0
  17. package/src/app-runtime/use-backend-runtime.ts +99 -0
  18. package/src/app-runtime/use-directory-search.ts +52 -0
  19. package/src/app-runtime/use-mouse-handlers.ts +183 -0
  20. package/src/app-runtime/use-renderer-bindings.ts +163 -0
  21. package/src/app-runtime/use-terminal-resize.ts +141 -0
  22. package/src/app-runtime/use-workspace-autosave.ts +39 -0
  23. package/src/app.tsx +247 -0
  24. package/src/config/loader.ts +36 -0
  25. package/src/config.ts +146 -0
  26. package/src/daemon/daemon.ts +236 -0
  27. package/src/daemon/runtime-paths.ts +69 -0
  28. package/src/daemon/session-manager.ts +109 -0
  29. package/src/daemon/session-registry.ts +207 -0
  30. package/src/debug/input-log.ts +29 -0
  31. package/src/doctor.ts +106 -0
  32. package/src/git/git-poller.ts +49 -0
  33. package/src/git/git-status.ts +183 -0
  34. package/src/index.tsx +56 -0
  35. package/src/input/keymap/build-handlers.ts +34 -0
  36. package/src/input/keymap/key-chord.ts +201 -0
  37. package/src/input/keymap/keymap-mode-handler.ts +88 -0
  38. package/src/input/keymap/sequence-resolver.ts +117 -0
  39. package/src/input/keymap/trie.ts +103 -0
  40. package/src/input/modes/bridge.ts +58 -0
  41. package/src/input/modes/handlers/index.ts +18 -0
  42. package/src/input/modes/handlers/shared.ts +70 -0
  43. package/src/input/modes/registry.ts +34 -0
  44. package/src/input/modes/transitions.ts +37 -0
  45. package/src/input/modes/types.ts +63 -0
  46. package/src/input/mouse-forwarding.ts +98 -0
  47. package/src/input/multi-click-detector.ts +55 -0
  48. package/src/input/paste.ts +12 -0
  49. package/src/input/raw-input-handler.ts +191 -0
  50. package/src/input/terminal-text-extraction.ts +78 -0
  51. package/src/ipc/protocol.ts +341 -0
  52. package/src/platform/clipboard.ts +13 -0
  53. package/src/platform/daemon-control.ts +62 -0
  54. package/src/platform/id.ts +7 -0
  55. package/src/platform/project-search.ts +75 -0
  56. package/src/pty/command-registry.ts +69 -0
  57. package/src/pty/pty-manager.ts +268 -0
  58. package/src/pty/terminal-snapshot.ts +210 -0
  59. package/src/restart-daemon.ts +28 -0
  60. package/src/session-backend/bootstrap.ts +107 -0
  61. package/src/session-backend/local-session-backend.ts +164 -0
  62. package/src/session-backend/remote-session-backend.ts +373 -0
  63. package/src/session-backend/types.ts +47 -0
  64. package/src/state/app-store.ts +19 -0
  65. package/src/state/layout-resize.ts +56 -0
  66. package/src/state/layout-tree.ts +323 -0
  67. package/src/state/reducers/git-panel-state.ts +102 -0
  68. package/src/state/reducers/modal-state.ts +358 -0
  69. package/src/state/reducers/session-state.ts +86 -0
  70. package/src/state/reducers/tab-state.ts +531 -0
  71. package/src/state/reducers/ui-state.ts +29 -0
  72. package/src/state/selectors.ts +30 -0
  73. package/src/state/session-catalog.ts +96 -0
  74. package/src/state/session-persistence.ts +210 -0
  75. package/src/state/snippet-catalog.ts +90 -0
  76. package/src/state/store.ts +93 -0
  77. package/src/state/terminal-modes.ts +11 -0
  78. package/src/state/types.ts +367 -0
  79. package/src/state/validation.ts +154 -0
  80. package/src/state/workspace-save.ts +33 -0
  81. package/src/ui/components/create-session-modal.tsx +100 -0
  82. package/src/ui/components/git-panel.tsx +200 -0
  83. package/src/ui/components/help-modal.tsx +79 -0
  84. package/src/ui/components/input-field.tsx +18 -0
  85. package/src/ui/components/list-item.tsx +30 -0
  86. package/src/ui/components/modal-filter-bar.tsx +18 -0
  87. package/src/ui/components/modal-shell.tsx +47 -0
  88. package/src/ui/components/new-tab-modal.tsx +52 -0
  89. package/src/ui/components/pending-chord-indicator.tsx +41 -0
  90. package/src/ui/components/session-name-modal.tsx +15 -0
  91. package/src/ui/components/session-picker-modal.tsx +93 -0
  92. package/src/ui/components/sidebar-group-metadata.ts +44 -0
  93. package/src/ui/components/sidebar-scroll.ts +33 -0
  94. package/src/ui/components/sidebar.tsx +225 -0
  95. package/src/ui/components/snippet-editor-modal.tsx +39 -0
  96. package/src/ui/components/snippet-picker-modal.tsx +56 -0
  97. package/src/ui/components/split-layout.tsx +201 -0
  98. package/src/ui/components/status-bar.tsx +60 -0
  99. package/src/ui/components/surface.tsx +63 -0
  100. package/src/ui/components/tab-item.tsx +118 -0
  101. package/src/ui/components/terminal-pane.tsx +215 -0
  102. package/src/ui/components/theme-picker-modal.tsx +35 -0
  103. package/src/ui/components/use-sidebar-auto-scroll.ts +63 -0
  104. package/src/ui/components/use-sidebar-branch.ts +36 -0
  105. package/src/ui/directory-search.ts +1 -0
  106. package/src/ui/git-branch.ts +11 -0
  107. package/src/ui/path-format.ts +6 -0
  108. package/src/ui/root.tsx +261 -0
  109. package/src/ui/status-bar-model.ts +87 -0
  110. package/src/ui/theme.ts +9 -0
  111. package/src/ui/themes.ts +255 -0
  112. package/src/ui/ui-tokens.ts +11 -0
  113. 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
+ ![Built with Bun](https://img.shields.io/badge/runtime-Bun-f9f1e1)
6
+ ![TypeScript](https://img.shields.io/badge/lang-TypeScript-3178c6)
7
+ ![MIT License](https://img.shields.io/badge/license-MIT-blue)
8
+
9
+ ![aimux demo](assets/demo.gif)
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
+ ![Session management](assets/sessions.gif)
33
+
34
+ ### Multi-Tab Workflow
35
+
36
+ ![Multi-tab workflow](assets/tabs.gif)
37
+
38
+ ### Themes
39
+
40
+ ![Themes](assets/themes.gif)
41
+
42
+ ### Split Panes
43
+
44
+ ![Split panes](assets/splits.gif)
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
+ }