@jx0/jmux 0.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 ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Jarred Kenny
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,219 @@
1
+ # jmux
2
+
3
+ A persistent session sidebar for tmux. See every project at a glance, switch instantly, never lose context.
4
+
5
+ ![jmux sidebar with grouped sessions alongside vim and server logs](docs/screenshots/hero.png)
6
+
7
+ ---
8
+
9
+ ## The Problem
10
+
11
+ You have 30+ tmux sessions. Each one is a project, a context, a train of thought. But tmux gives you a flat list and a status bar that shows one session name. To switch, you `prefix-s`, scan a wall of text, find the one you want, and hope you remember which window you were in.
12
+
13
+ You lose context constantly. You forget what's running where. You can't see at a glance which sessions have new output or which ones need attention.
14
+
15
+ ## The Solution
16
+
17
+ jmux wraps tmux with a persistent sidebar that shows all your sessions, all the time. It doesn't replace tmux — it sits alongside it. Your keybindings, your panes, your workflow. Everything works exactly like before, plus a sidebar.
18
+
19
+ **What you get:**
20
+ - Every session visible at all times with git branch and window count
21
+ - Sessions grouped by project directory — related work stays together
22
+ - Instant switching with `Ctrl-Shift-Up/Down` — no prefix, no menu, no delay
23
+ - Activity indicators (green dot) and attention flags (orange `!`) for agentic workflows
24
+ - Mouse click to switch sessions
25
+ - New session modal with fuzzy directory picker
26
+ - A self-contained tmux distribution — ships its own config, doesn't touch `~/.tmux.conf`
27
+
28
+ ![jmux sidebar alongside vim with split panes and a dev server](docs/screenshots/blog.png)
29
+
30
+ ## How It Works
31
+
32
+ jmux owns the terminal. It spawns tmux in a PTY, feeds the output through a headless terminal emulator ([xterm.js](https://xtermjs.org/)), and composites a 24-column sidebar alongside the tmux rendering. A separate tmux control mode connection provides real-time session metadata via push notifications.
33
+
34
+ Your tmux is unmodified. Sessions, windows, panes, keybindings — all unchanged. jmux just adds a persistent view of what's happening across your projects.
35
+
36
+ ```
37
+ ┌─ jmux sidebar ──┬─ your normal tmux ──────────────────────┐
38
+ │ │ │
39
+ │ jmux │ $ vim src/server.ts │
40
+ │ ──────────────── │ ... │
41
+ │ Code/work │ │
42
+ │ ▎ api-server 3w │ │
43
+ │ main│ │
44
+ │ │ │
45
+ │ dashboard 1w │ │
46
+ │ feat/x│ │
47
+ │ │ │
48
+ │ Code/personal │ │
49
+ │ ● blog 1w │ │
50
+ │ │ │
51
+ │ dotfiles 2w │ │
52
+ │ ├─────────────────────────────────────────┤
53
+ │ │ 1:vim 2:zsh 3:bun │
54
+ └──────────────────┴─────────────────────────────────────────┘
55
+ ```
56
+
57
+ ### Sidebar Features
58
+
59
+ **Session grouping** — Sessions that share a parent directory are automatically grouped under a header. `~/Code/work/api` and `~/Code/work/web` group under `Code/work`. Solo sessions render ungrouped.
60
+
61
+ **Two-line entries** — Each session shows its name and window count on the first line, git branch on the second. Grouped sessions inherit directory context from the group header. Ungrouped sessions show their directory path.
62
+
63
+ **Visual indicators:**
64
+ - Green `▎` left marker — active session
65
+ - Green `●` dot — new output since you last viewed that session
66
+ - Orange `!` flag — attention needed (set programmatically)
67
+ - Blue highlight — sidebar navigation mode (prefix + j)
68
+
69
+ ## Installation
70
+
71
+ ### Requirements
72
+
73
+ - [Bun](https://bun.sh) 1.2+
74
+ - [tmux](https://github.com/tmux/tmux) 3.2+
75
+ - [fzf](https://github.com/junegunn/fzf) (for new session modal and window picker)
76
+ - [git](https://git-scm.com/) (optional, for branch display)
77
+
78
+ ### Install
79
+
80
+ ```bash
81
+ git clone https://github.com/jarredkenny/jmux.git
82
+ cd jmux
83
+ bun install
84
+ ```
85
+
86
+ ### Run
87
+
88
+ ```bash
89
+ bun run bin/jmux
90
+ ```
91
+
92
+ Or with a named session:
93
+
94
+ ```bash
95
+ bun run bin/jmux my-project
96
+ ```
97
+
98
+ ### Isolated Server
99
+
100
+ To run jmux on a separate tmux server (keeps your existing tmux sessions untouched):
101
+
102
+ ```bash
103
+ bun run bin/jmux -L jmux
104
+ ```
105
+
106
+ ## New Session Modal
107
+
108
+ Press `Ctrl-a n` to create a new session. The modal walks you through two steps:
109
+
110
+ 1. **Pick a directory** — fuzzy search over all git repos found under `~/Code`, `~/Projects`, `~/src`, `~/work`, and `~/dev`. Start typing to narrow down instantly.
111
+
112
+ 2. **Name the session** — pre-filled with the directory basename. Edit or accept with Enter.
113
+
114
+ The session is created in the selected directory and the sidebar updates immediately. The new session is auto-selected.
115
+
116
+ ## Keybindings
117
+
118
+ ### Session Navigation (always active)
119
+
120
+ | Key | Action |
121
+ |-----|--------|
122
+ | `Ctrl-Shift-Up` | Switch to previous session |
123
+ | `Ctrl-Shift-Down` | Switch to next session |
124
+ | `Ctrl-a n` | New session (directory picker + name) |
125
+ | Click sidebar | Switch to that session |
126
+
127
+ ### Sidebar Mode (`Ctrl-a j` to enter)
128
+
129
+ | Key | Action |
130
+ |-----|--------|
131
+ | `j` / `k` / arrows | Move highlight |
132
+ | `Enter` | Switch to highlighted session |
133
+ | `Escape` | Exit sidebar mode |
134
+
135
+ ### Windows
136
+
137
+ | Key | Action |
138
+ |-----|--------|
139
+ | `Ctrl-a c` | New window (opens in `~`) |
140
+ | `Ctrl-Right` / `Ctrl-Left` | Next / previous window |
141
+ | `Ctrl-Shift-Right` / `Ctrl-Shift-Left` | Reorder windows |
142
+
143
+ ### Panes
144
+
145
+ | Key | Action |
146
+ |-----|--------|
147
+ | `Ctrl-a \|` | Split horizontal |
148
+ | `Ctrl-a -` | Split vertical |
149
+ | `Shift-arrows` | Navigate panes (vim-aware via smart-splits) |
150
+ | `Ctrl-a arrows` | Resize panes |
151
+ | `Ctrl-a P` | Toggle pane border titles |
152
+
153
+ ### Utilities
154
+
155
+ | Key | Action |
156
+ |-----|--------|
157
+ | `Ctrl-a k` | Clear pane screen and scrollback |
158
+ | `Ctrl-a y` | Copy entire pane to clipboard |
159
+ | `Ctrl-a Space` | Toggle scratchpad popup |
160
+
161
+ ## Claude Code Integration
162
+
163
+ jmux is built for agentic workflows. When you have Claude Code running in multiple sessions, you need to know which ones need your attention.
164
+
165
+ ### One-Command Setup
166
+
167
+ ```bash
168
+ bun run bin/jmux --install-agent-hooks
169
+ ```
170
+
171
+ This adds a hook to `~/.claude/settings.json` that sets the attention flag whenever Claude Code finishes a response. The orange `!` appears in your sidebar so you know which session to check.
172
+
173
+ ### Manual Setup
174
+
175
+ Set an attention flag on any session:
176
+
177
+ ```bash
178
+ tmux set-option -t my-session @jmux-attention 1
179
+ ```
180
+
181
+ jmux shows an orange `!` indicator. When you switch to that session, the flag clears automatically.
182
+
183
+ ### Workflow
184
+
185
+ 1. Start jmux
186
+ 2. Create sessions for each project (`Ctrl-a n`)
187
+ 3. Run Claude Code in each session on different tasks
188
+ 4. Work in one session while others run in the background
189
+ 5. Orange `!` flags appear when Claude finishes — switch instantly with `Ctrl-Shift-Down`
190
+
191
+ ## Self-Contained Config
192
+
193
+ jmux ships its own `config/tmux.conf`. It never reads `~/.tmux.conf`. This means:
194
+
195
+ - Your existing tmux setup is untouched
196
+ - Every jmux user gets the same keybindings and behavior
197
+ - No plugin manager needed — everything is built in
198
+ - The status bar shows only window tabs (session info is in the sidebar)
199
+ - Windows auto-rename to the running command (`vim`, `zsh`, `bun`, etc.)
200
+
201
+ The config is fully customizable — see [docs/configuration.md](docs/configuration.md) for details.
202
+
203
+ ## Architecture
204
+
205
+ ```
206
+ Terminal (Ghostty, iTerm, etc.)
207
+ └── jmux (owns the terminal surface)
208
+ ├── Sidebar (24 cols) ── session groups, indicators, navigation
209
+ ├── Border (1 col) ──── vertical separator
210
+ └── tmux PTY (remaining cols)
211
+ ├── PTY client ──── spawns tmux, feeds output through @xterm/headless
212
+ └── Control client ─ tmux -C for real-time session metadata
213
+ ```
214
+
215
+ jmux is ~1500 lines of TypeScript. It has no opinions about what you run inside tmux.
216
+
217
+ ## License
218
+
219
+ MIT
package/bin/jmux ADDED
@@ -0,0 +1,2 @@
1
+ #!/usr/bin/env bun
2
+ import "../src/main.ts";
@@ -0,0 +1,69 @@
1
+ #!/bin/bash
2
+ # jmux new session — name + directory picker
3
+ # Called via: display-popup -E "new-session.sh"
4
+
5
+ FZF_COLORS="border:#4f565d,header:#b5bcc9,prompt:#9fe8c3,label:#9fe8c3,pointer:#9fe8c3,fg:#6b7280,fg+:#b5bcc9,hl:#fbd4b8,hl+:#fbd4b8"
6
+
7
+ # ─── Step 1: Pick a directory ─────────────────────────────────────────
8
+
9
+ # Build project list: find directories with .git (real projects)
10
+ # Search common code directories, limit depth for speed
11
+ PROJECT_DIRS=$(find \
12
+ "$HOME/Code" \
13
+ "$HOME/Projects" \
14
+ "$HOME/src" \
15
+ "$HOME/work" \
16
+ "$HOME/dev" \
17
+ 2>/dev/null \
18
+ -maxdepth 3 -name ".git" -type d 2>/dev/null \
19
+ | sed 's|/\.git$||' \
20
+ | sort -u)
21
+
22
+ # Add home directory as fallback
23
+ PROJECT_DIRS=$(printf "%s\n%s" "$HOME" "$PROJECT_DIRS" | grep -v '^$')
24
+
25
+ # Replace $HOME with ~ for display, but keep original paths
26
+ DISPLAY_DIRS=$(echo "$PROJECT_DIRS" | sed "s|^$HOME|~|")
27
+
28
+ SELECTED_DIR=$(echo "$DISPLAY_DIRS" | fzf \
29
+ --height=100% \
30
+ --layout=reverse \
31
+ --border=rounded \
32
+ --border-label=" New Session — Pick Directory " \
33
+ --header="Search for a project directory" \
34
+ --header-first \
35
+ --prompt="Dir: " \
36
+ --pointer="▸" \
37
+ --color="$FZF_COLORS")
38
+
39
+ [ -z "$SELECTED_DIR" ] && exit 0
40
+
41
+ # Expand ~ back to $HOME
42
+ WORK_DIR="${SELECTED_DIR/#\~/$HOME}"
43
+
44
+ # Default session name to directory basename
45
+ DEFAULT_NAME=$(basename "$WORK_DIR")
46
+
47
+ # ─── Step 2: Session name ─────────────────────────────────────────────
48
+
49
+ SESSION_NAME=$(echo "" | fzf --print-query \
50
+ --height=100% \
51
+ --layout=reverse \
52
+ --border=rounded \
53
+ --border-label=" New Session — Name " \
54
+ --header="Directory: $SELECTED_DIR" \
55
+ --header-first \
56
+ --prompt="Name: " \
57
+ --query="$DEFAULT_NAME" \
58
+ --pointer="" \
59
+ --no-info \
60
+ --color="$FZF_COLORS" \
61
+ | head -1)
62
+
63
+ [ -z "$SESSION_NAME" ] && exit 0
64
+
65
+ # ─── Create session ───────────────────────────────────────────────────
66
+
67
+ PARENT_CLIENT=$(tmux display-message -p '#{client_name}' 2>/dev/null)
68
+ tmux new-session -d -s "$SESSION_NAME" -c "$WORK_DIR"
69
+ tmux switch-client -c "$PARENT_CLIENT" -t "$SESSION_NAME"
@@ -0,0 +1,115 @@
1
+ # jmux tmux configuration
2
+ # Self-contained — does not read ~/.tmux.conf
3
+
4
+ # --- Prefix ---
5
+ set -g prefix C-a
6
+ unbind C-b
7
+ bind-key C-a send-prefix
8
+
9
+ # jmux-managed bindings
10
+ bind-key n display-popup -E -w 60% -h 70% -b heavy -S 'fg=#4f565d' "$JMUX_DIR/config/new-session.sh"
11
+ unbind p # free up for future use
12
+
13
+ # --- Windows ---
14
+ set -g base-index 1
15
+ set -g pane-base-index 1
16
+ set -g renumber-windows on
17
+ set -g automatic-rename on
18
+ set -g automatic-rename-format "#{pane_current_command}"
19
+ bind c new-window -c ~
20
+ bind -n C-Right next-window
21
+ bind -n C-Left previous-window
22
+
23
+ # Reorder windows with Ctrl-Shift-Left/Right
24
+ bind-key -n C-S-Left run-shell 'W=#{window_index}; T=$((W-1)); [ $T -ge 1 ] && tmux swap-window -s ":$W" -t ":$T" && tmux select-window -t ":$T"'
25
+ bind-key -n C-S-Right run-shell 'W=#{window_index}; T=$((W+1)); MAX=$(tmux list-windows | wc -l | tr -d " "); [ $T -le $MAX ] && tmux swap-window -s ":$W" -t ":$T" && tmux select-window -t ":$T"'
26
+
27
+ # --- Panes ---
28
+ bind | split-window -h -c "#{pane_current_path}"
29
+ bind - split-window -v -c "#{pane_current_path}"
30
+ unbind '"'
31
+ unbind %
32
+ bind -r Left resize-pane -L 5
33
+ bind -r Down resize-pane -D 5
34
+ bind -r Up resize-pane -U 5
35
+ bind -r Right resize-pane -R 5
36
+
37
+ # Smart-splits.nvim integration
38
+ is_vim="ps -o state= -o comm= -t '#{pane_tty}' | grep -iqE '^[^TXZ ]+ +(\\S+\\/)?g?(view|l?n?vim?x?|fzf)(diff)?$'"
39
+ bind -n S-Left if-shell "$is_vim" "send-keys S-Left" "select-pane -L"
40
+ bind -n S-Right if-shell "$is_vim" "send-keys S-Right" "select-pane -R"
41
+ bind -n S-Up if-shell "$is_vim" "send-keys S-Up" "select-pane -U"
42
+ bind -n S-Down if-shell "$is_vim" "send-keys S-Down" "select-pane -D"
43
+
44
+ # --- Pane borders ---
45
+ set -g pane-border-lines heavy
46
+ set -g pane-border-status off
47
+ set -g pane-border-style 'fg=#3a4450'
48
+ set -g pane-active-border-style 'fg=#4f565d'
49
+ set -g pane-border-format "#{?pane_active, #[fg=#9fe8c3 bold]#{pane_title} #[fg=#b5bcc9 nobold]#{b:pane_current_path} #[fg=#4f565d]| #{pane_current_command} , #[fg=#4f565d]#{pane_title} #[fg=#3a4450]#{b:pane_current_path} | #{pane_current_command} }"
50
+
51
+ # Auto-show pane borders only when window has multiple panes
52
+ set-hook -g window-layout-changed 'if -F "#{==:#{window_panes},1}" "set -w pane-border-status off" "set -w pane-border-status top"'
53
+ set-hook -g after-select-window 'if -F "#{==:#{window_panes},1}" "set -w pane-border-status off" "set -w pane-border-status top"'
54
+ bind-key P set -w pane-border-status
55
+
56
+ # --- Window styles ---
57
+ set -g window-style 'fg=#6b7280'
58
+ set -g window-active-style 'fg=#b5bcc9'
59
+
60
+ # --- Utilities ---
61
+ bind k send-keys -R \; clear-history \; display-message "Pane cleared"
62
+ bind y run-shell "tmux capture-pane -pS - -E - | grep . | pbcopy" \; display-message "Copied pane to clipboard"
63
+
64
+ # Window switcher popup (C-a j) — jmux overrides this for sidebar mode,
65
+ # but it's still available if sidebar mode changes
66
+ bind-key j display-popup -E -x 0 -y 0 -w 30% -h 100% -b heavy -S 'fg=#4f565d' \
67
+ "tmux list-windows -F '#I: #W#{?window_active, *, }' | \
68
+ fzf --reverse --no-info --prompt=' Window> ' --pointer='▸' \
69
+ --color='bg:#0c1117,fg:#6b7280,hl:#fbd4b8,fg+:#b5bcc9,hl+:#fbd4b8,pointer:#9fe8c3,prompt:#9fe8c3' | \
70
+ cut -d: -f1 | \
71
+ xargs -I{} tmux select-window -t :{}"
72
+
73
+ # Scratchpad toggle (C-a Space)
74
+ bind-key Space if-shell -F '#{==:#{session_name},scratch}' {
75
+ detach-client
76
+ } {
77
+ display-popup -E -w 80% -h 70% -b heavy -S 'fg=#4f565d' "tmux new-session -A -s scratch"
78
+ }
79
+
80
+ # --- Terminal ---
81
+ set -g default-terminal "tmux-256color"
82
+ set -g set-clipboard on
83
+ set -g focus-events on
84
+ set -g mouse on
85
+ set -gq status-utf8 on
86
+ set-window-option -gq utf8 on
87
+ set-option -ga terminal-overrides ",xterm-256color:Tc"
88
+ set-option -ga terminal-overrides ',*:Smulx=\E[4::%p1%dm'
89
+ set-option -ga terminal-overrides ',*:Setulc=\E[58::2::%p1%{65536}%/%d::%p1%{256}%/%{255}%&%d::%p1%{255}%&%d%;m'
90
+
91
+ # --- Bell / Activity ---
92
+ set-window-option -g visual-bell off
93
+ set-window-option -g bell-action other
94
+ set-window-option -g monitor-activity off
95
+ set-window-option -g visual-activity off
96
+ set-hook -g pane-focus-in 'setw monitor-bell off; setw monitor-bell on'
97
+ unbind -T root DoubleClick1Pane
98
+ unbind -T root TripleClick1Pane
99
+
100
+ # --- Status bar ---
101
+ # Minimal — session indicator handled by jmux sidebar, no system metrics
102
+ set -g status-position bottom
103
+ set -g status-interval 1
104
+ set -g status-justify left
105
+ set -g status-bg "#181f26"
106
+ set -g status-left ""
107
+ set -g status-right ""
108
+ set -g status-left-length 0
109
+ set -g status-right-length 0
110
+
111
+ # Window tabs
112
+ setw -g window-status-current-format "#[bg=#181f26 fg=#fbd4b8]◢#[bg=#fbd4b8 fg=#131a21] #W #{?window_zoomed_flag,󰊓 ,}#[bg=#181f26 fg=#fbd4b8]◣"
113
+ setw -g window-status-format "#{?window_bell_flag,#[bg=#181f26 fg=#ced4df] #W #{?window_zoomed_flag,󰊓 ,},#[bg=#181f26 fg=#4f565d] #W #{?window_zoomed_flag,󰊓 ,}}"
114
+ set -g window-status-bell-style 'fg=#ced4df,bg=#0c1117'
115
+ set -g window-status-activity-style 'fg=#b5bcc9,bg=#4e555c,bold'
package/package.json ADDED
@@ -0,0 +1,42 @@
1
+ {
2
+ "name": "@jx0/jmux",
3
+ "version": "0.1.0",
4
+ "description": "A persistent session sidebar for tmux",
5
+ "type": "module",
6
+ "bin": {
7
+ "jmux": "./bin/jmux"
8
+ },
9
+ "files": [
10
+ "bin",
11
+ "src",
12
+ "config"
13
+ ],
14
+ "scripts": {
15
+ "dev": "bun run src/main.ts",
16
+ "test": "bun test",
17
+ "typecheck": "bun run tsc --noEmit"
18
+ },
19
+ "keywords": [
20
+ "tmux",
21
+ "terminal",
22
+ "sidebar",
23
+ "session-manager",
24
+ "tui",
25
+ "cli"
26
+ ],
27
+ "author": "Jarred Kenny",
28
+ "license": "MIT",
29
+ "repository": {
30
+ "type": "git",
31
+ "url": "git+https://github.com/jarredkenny/jmux.git"
32
+ },
33
+ "homepage": "https://github.com/jarredkenny/jmux",
34
+ "dependencies": {
35
+ "@xterm/headless": "^6.0.0",
36
+ "bun-pty": "^0.4.8"
37
+ },
38
+ "devDependencies": {
39
+ "@types/bun": "latest",
40
+ "typescript": "^5.7.0"
41
+ }
42
+ }
@@ -0,0 +1,75 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { createGrid, writeString, DEFAULT_CELL } from "../cell-grid";
3
+ import { ColorMode } from "../types";
4
+
5
+ describe("createGrid", () => {
6
+ test("creates grid with correct dimensions", () => {
7
+ const grid = createGrid(10, 5);
8
+ expect(grid.cols).toBe(10);
9
+ expect(grid.rows).toBe(5);
10
+ expect(grid.cells.length).toBe(5);
11
+ expect(grid.cells[0].length).toBe(10);
12
+ });
13
+
14
+ test("fills cells with spaces and default attributes", () => {
15
+ const grid = createGrid(3, 2);
16
+ const cell = grid.cells[0][0];
17
+ expect(cell.char).toBe(" ");
18
+ expect(cell.fg).toBe(0);
19
+ expect(cell.bg).toBe(0);
20
+ expect(cell.fgMode).toBe(ColorMode.Default);
21
+ expect(cell.bgMode).toBe(ColorMode.Default);
22
+ expect(cell.bold).toBe(false);
23
+ });
24
+ });
25
+
26
+ describe("writeString", () => {
27
+ test("writes characters at specified position", () => {
28
+ const grid = createGrid(10, 3);
29
+ writeString(grid, 1, 2, "hello");
30
+ expect(grid.cells[1][2].char).toBe("h");
31
+ expect(grid.cells[1][3].char).toBe("e");
32
+ expect(grid.cells[1][4].char).toBe("l");
33
+ expect(grid.cells[1][5].char).toBe("l");
34
+ expect(grid.cells[1][6].char).toBe("o");
35
+ });
36
+
37
+ test("applies attributes to written characters", () => {
38
+ const grid = createGrid(10, 3);
39
+ writeString(grid, 0, 0, "hi", {
40
+ fg: 2,
41
+ fgMode: ColorMode.Palette,
42
+ bold: true,
43
+ });
44
+ expect(grid.cells[0][0].fg).toBe(2);
45
+ expect(grid.cells[0][0].fgMode).toBe(ColorMode.Palette);
46
+ expect(grid.cells[0][0].bold).toBe(true);
47
+ expect(grid.cells[0][1].bold).toBe(true);
48
+ });
49
+
50
+ test("truncates at grid boundary", () => {
51
+ const grid = createGrid(5, 1);
52
+ writeString(grid, 0, 3, "hello");
53
+ expect(grid.cells[0][3].char).toBe("h");
54
+ expect(grid.cells[0][4].char).toBe("e");
55
+ // "llo" truncated — no crash
56
+ });
57
+
58
+ test("ignores writes outside grid bounds", () => {
59
+ const grid = createGrid(5, 3);
60
+ writeString(grid, 5, 0, "hello"); // row 5 doesn't exist
61
+ // no crash
62
+ });
63
+ });
64
+
65
+ describe("DEFAULT_CELL", () => {
66
+ test("is a space with default colors and no attributes", () => {
67
+ expect(DEFAULT_CELL.char).toBe(" ");
68
+ expect(DEFAULT_CELL.fgMode).toBe(ColorMode.Default);
69
+ expect(DEFAULT_CELL.bgMode).toBe(ColorMode.Default);
70
+ expect(DEFAULT_CELL.bold).toBe(false);
71
+ expect(DEFAULT_CELL.italic).toBe(false);
72
+ expect(DEFAULT_CELL.underline).toBe(false);
73
+ expect(DEFAULT_CELL.dim).toBe(false);
74
+ });
75
+ });
@@ -0,0 +1,113 @@
1
+ import { describe, test, expect } from "bun:test";
2
+ import { translateMouseX, parseSgrMouse, InputRouter } from "../input-router";
3
+
4
+ describe("parseSgrMouse", () => {
5
+ test("parses SGR mouse button press", () => {
6
+ const result = parseSgrMouse("\x1b[<0;30;5M");
7
+ expect(result).not.toBeNull();
8
+ expect(result!.button).toBe(0);
9
+ expect(result!.x).toBe(30);
10
+ expect(result!.y).toBe(5);
11
+ expect(result!.release).toBe(false);
12
+ });
13
+
14
+ test("parses SGR mouse button release", () => {
15
+ const result = parseSgrMouse("\x1b[<0;30;5m");
16
+ expect(result).not.toBeNull();
17
+ expect(result!.release).toBe(true);
18
+ });
19
+
20
+ test("parses wheel up event", () => {
21
+ const result = parseSgrMouse("\x1b[<64;10;5M");
22
+ expect(result).not.toBeNull();
23
+ expect(result!.button).toBe(64);
24
+ expect(result!.x).toBe(10);
25
+ });
26
+
27
+ test("returns null for non-mouse sequence", () => {
28
+ const result = parseSgrMouse("\x1b[A");
29
+ expect(result).toBeNull();
30
+ });
31
+ });
32
+
33
+ describe("translateMouseX", () => {
34
+ test("translates x coordinate by subtracting sidebar offset", () => {
35
+ const result = translateMouseX("\x1b[<0;30;5M", 25);
36
+ expect(result).toBe("\x1b[<0;5;5M");
37
+ });
38
+
39
+ test("preserves release suffix", () => {
40
+ const result = translateMouseX("\x1b[<0;30;5m", 25);
41
+ expect(result).toBe("\x1b[<0;5;5m");
42
+ });
43
+
44
+ test("returns null if translated x would be <= 0", () => {
45
+ const result = translateMouseX("\x1b[<0;10;5M", 25);
46
+ expect(result).toBeNull();
47
+ });
48
+ });
49
+
50
+ describe("Ctrl-Shift arrow detection", () => {
51
+ test("calls onSessionPrev for Ctrl-Shift-Up", () => {
52
+ let prevCalled = false;
53
+ const router = new InputRouter(
54
+ {
55
+ sidebarCols: 24,
56
+ onPtyData: () => {},
57
+ onSidebarClick: () => {},
58
+ onSessionPrev: () => { prevCalled = true; },
59
+ },
60
+ true,
61
+ );
62
+ router.handleInput("\x1b[1;6A");
63
+ expect(prevCalled).toBe(true);
64
+ });
65
+
66
+ test("calls onSessionNext for Ctrl-Shift-Down", () => {
67
+ let nextCalled = false;
68
+ const router = new InputRouter(
69
+ {
70
+ sidebarCols: 24,
71
+ onPtyData: () => {},
72
+ onSidebarClick: () => {},
73
+ onSessionNext: () => { nextCalled = true; },
74
+ },
75
+ true,
76
+ );
77
+ router.handleInput("\x1b[1;6B");
78
+ expect(nextCalled).toBe(true);
79
+ });
80
+
81
+ test("Ctrl-Shift arrows are not forwarded to PTY", () => {
82
+ let ptyData = "";
83
+ const router = new InputRouter(
84
+ {
85
+ sidebarCols: 24,
86
+ onPtyData: (d) => { ptyData += d; },
87
+ onSidebarClick: () => {},
88
+ onSessionPrev: () => {},
89
+ onSessionNext: () => {},
90
+ },
91
+ true,
92
+ );
93
+ router.handleInput("\x1b[1;6A");
94
+ router.handleInput("\x1b[1;6B");
95
+ expect(ptyData).toBe("");
96
+ });
97
+ });
98
+
99
+ describe("passthrough", () => {
100
+ test("forwards regular input to PTY", () => {
101
+ let ptyData = "";
102
+ const router = new InputRouter(
103
+ {
104
+ sidebarCols: 24,
105
+ onPtyData: (d) => { ptyData += d; },
106
+ onSidebarClick: () => {},
107
+ },
108
+ true,
109
+ );
110
+ router.handleInput("hello");
111
+ expect(ptyData).toBe("hello");
112
+ });
113
+ });