@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 +21 -0
- package/README.md +219 -0
- package/bin/jmux +2 -0
- package/config/new-session.sh +69 -0
- package/config/tmux.conf +115 -0
- package/package.json +42 -0
- package/src/__tests__/cell-grid.test.ts +75 -0
- package/src/__tests__/input-router.test.ts +113 -0
- package/src/__tests__/renderer.test.ts +112 -0
- package/src/__tests__/screen-bridge.test.ts +61 -0
- package/src/__tests__/sidebar.test.ts +237 -0
- package/src/__tests__/tmux-control.test.ts +142 -0
- package/src/cell-grid.ts +63 -0
- package/src/input-router.ts +85 -0
- package/src/main.ts +405 -0
- package/src/renderer.ts +132 -0
- package/src/screen-bridge.ts +70 -0
- package/src/sidebar.ts +295 -0
- package/src/tmux-control.ts +223 -0
- package/src/tmux-pty.ts +80 -0
- package/src/types.ts +39 -0
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
|
+

|
|
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
|
+

|
|
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,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"
|
package/config/tmux.conf
ADDED
|
@@ -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
|
+
});
|