@jx0/jmux 0.3.0 → 0.3.2
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/README.md +64 -141
- package/config/defaults.conf +6 -0
- package/config/move-window.sh +32 -0
- package/config/new-session.sh +2 -3
- package/config/rename-session.sh +26 -0
- package/package.json +2 -2
- package/src/__tests__/sidebar.test.ts +10 -9
- package/src/main.ts +19 -5
- package/src/sidebar.ts +117 -57
- package/src/tmux-control.ts +6 -0
package/README.md
CHANGED
|
@@ -1,142 +1,95 @@
|
|
|
1
|
+
<div align="center">
|
|
2
|
+
|
|
1
3
|
# jmux
|
|
2
4
|
|
|
3
|
-
|
|
5
|
+
**The terminal workspace for agentic development.**
|
|
4
6
|
|
|
5
|
-
|
|
7
|
+
A tmux environment built for running coding agents in parallel — with a persistent sidebar that shows every session, what's running, and what needs your attention.
|
|
6
8
|
|
|
7
|
-
|
|
9
|
+
[](https://www.npmjs.com/package/@jx0/jmux)
|
|
10
|
+
[](LICENSE)
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+

|
|
10
13
|
|
|
11
|
-
|
|
14
|
+
</div>
|
|
12
15
|
|
|
13
|
-
|
|
16
|
+
## Install
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
```bash
|
|
19
|
+
bun install -g @jx0/jmux
|
|
20
|
+
jmux
|
|
21
|
+
```
|
|
16
22
|
|
|
17
|
-
|
|
23
|
+
Requires [Bun](https://bun.sh) 1.2+, [tmux](https://github.com/tmux/tmux) 3.2+, [fzf](https://github.com/junegunn/fzf), and optionally [git](https://git-scm.com/) for branch display.
|
|
18
24
|
|
|
19
|
-
|
|
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
|
-
- Bring your own `~/.tmux.conf` — your plugins and keybindings just work
|
|
25
|
+
---
|
|
27
26
|
|
|
28
|
-
|
|
27
|
+
## Why
|
|
29
28
|
|
|
30
|
-
|
|
29
|
+
tmux sessions are invisible. You have 30 of them, but the status bar shows one name. To switch, you `prefix-s`, scan a wall of text, and hope you remember what's where.
|
|
31
30
|
|
|
32
|
-
jmux
|
|
31
|
+
jmux fixes this with a persistent sidebar that shows every session, all the time.
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
## Features
|
|
35
34
|
|
|
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
|
-
```
|
|
35
|
+
### Session Sidebar
|
|
56
36
|
|
|
57
|
-
|
|
37
|
+
Every session visible at a glance — name, window count, git branch. Sessions sharing a parent directory are automatically grouped under a header.
|
|
58
38
|
|
|
59
|
-
|
|
39
|
+
- Green `▎` left marker on the active session
|
|
40
|
+
- Green `●` dot for sessions with new output
|
|
41
|
+
- Orange `!` flag for attention (set programmatically)
|
|
60
42
|
|
|
61
|
-
|
|
43
|
+
### Instant Switching
|
|
62
44
|
|
|
63
|
-
|
|
64
|
-
- Green `▎` left marker — active session
|
|
65
|
-
- Green `●` dot — new output since you last viewed that session
|
|
66
|
-
- Orange `!` flag — attention needed (set programmatically)
|
|
45
|
+
`Ctrl-Shift-Up/Down` moves between sessions with zero delay. No prefix key, no menu, no mode to enter. Or just click a session in the sidebar.
|
|
67
46
|
|
|
68
|
-
|
|
47
|
+
### New Session Modal
|
|
69
48
|
|
|
70
|
-
|
|
71
|
-
# Install globally
|
|
72
|
-
bun install -g @jx0/jmux
|
|
49
|
+
`Ctrl-a n` opens a two-step fzf flow: fuzzy-search your git repos for a directory, then name the session. Pre-filled with the directory basename.
|
|
73
50
|
|
|
74
|
-
|
|
75
|
-
jmux
|
|
76
|
-
```
|
|
77
|
-
|
|
78
|
-
### Requirements
|
|
51
|
+
### Window Picker
|
|
79
52
|
|
|
80
|
-
-
|
|
81
|
-
- [tmux](https://github.com/tmux/tmux) 3.2+
|
|
82
|
-
- [fzf](https://github.com/junegunn/fzf) (for new session modal)
|
|
83
|
-
- [git](https://git-scm.com/) (optional, for branch display)
|
|
53
|
+
`Ctrl-a j` opens a full-height fzf popup with every window in the current session. Type to filter, Enter to switch.
|
|
84
54
|
|
|
85
|
-
|
|
55
|
+

|
|
86
56
|
|
|
87
|
-
|
|
88
|
-
# Start jmux (creates or attaches to default session)
|
|
89
|
-
jmux
|
|
57
|
+
### Bring Your Own Config
|
|
90
58
|
|
|
91
|
-
|
|
92
|
-
jmux my-project
|
|
59
|
+
jmux works with your existing `~/.tmux.conf`. Your plugins, theme, prefix key, and custom bindings carry over — jmux applies its defaults first, then your config overrides them. Only a small set of core settings the sidebar needs are enforced.
|
|
93
60
|
|
|
94
|
-
|
|
95
|
-
jmux -L work
|
|
96
|
-
```
|
|
61
|
+
### Claude Code Integration
|
|
97
62
|
|
|
98
|
-
|
|
63
|
+
Built for agentic workflows. Run Claude Code in multiple sessions and get notified when each one finishes.
|
|
99
64
|
|
|
100
65
|
```bash
|
|
101
|
-
|
|
102
|
-
cd jmux
|
|
103
|
-
bun install
|
|
104
|
-
bun run bin/jmux
|
|
66
|
+
jmux --install-agent-hooks
|
|
105
67
|
```
|
|
106
68
|
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
Press `Ctrl-a n` to create a new session. The modal walks you through two steps:
|
|
69
|
+
One command adds a hook to `~/.claude/settings.json`. When Claude finishes a response, the orange `!` appears on that session. Switch to it, review the work, move on. See [docs/claude-code-integration.md](docs/claude-code-integration.md) for details.
|
|
110
70
|
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
2. **Name the session** — pre-filled with the directory basename. Edit or accept with Enter.
|
|
114
|
-
|
|
115
|
-
The session is created in the selected directory and the sidebar updates immediately. The new session is auto-selected.
|
|
71
|
+
---
|
|
116
72
|
|
|
117
73
|
## Keybindings
|
|
118
74
|
|
|
119
|
-
###
|
|
75
|
+
### Sessions
|
|
120
76
|
|
|
121
77
|
| Key | Action |
|
|
122
78
|
|-----|--------|
|
|
123
|
-
| `Ctrl-Shift-Up` | Switch to
|
|
124
|
-
| `Ctrl-
|
|
125
|
-
| `Ctrl-a
|
|
126
|
-
|
|
|
79
|
+
| `Ctrl-Shift-Up/Down` | Switch to prev/next session |
|
|
80
|
+
| `Ctrl-a n` | New session |
|
|
81
|
+
| `Ctrl-a r` | Rename session |
|
|
82
|
+
| `Ctrl-a m` | Move window to another session |
|
|
83
|
+
| Click sidebar | Switch to session |
|
|
127
84
|
|
|
128
85
|
### Windows
|
|
129
86
|
|
|
130
87
|
| Key | Action |
|
|
131
88
|
|-----|--------|
|
|
132
|
-
| `Ctrl-a c` | New window (opens in `~`) |
|
|
133
89
|
| `Ctrl-a j` | fzf window picker |
|
|
134
|
-
| `Ctrl-
|
|
135
|
-
| `Ctrl-
|
|
136
|
-
|
|
137
|
-
`Ctrl-a j` opens a full-height fzf popup on the left side of the screen with all windows in the current session. Type to fuzzy search, Enter to switch.
|
|
138
|
-
|
|
139
|
-

|
|
90
|
+
| `Ctrl-a c` | New window |
|
|
91
|
+
| `Ctrl-Right/Left` | Next/prev window |
|
|
92
|
+
| `Ctrl-Shift-Right/Left` | Reorder windows |
|
|
140
93
|
|
|
141
94
|
### Panes
|
|
142
95
|
|
|
@@ -146,79 +99,49 @@ The session is created in the selected directory and the sidebar updates immedia
|
|
|
146
99
|
| `Ctrl-a -` | Split vertical |
|
|
147
100
|
| `Shift-arrows` | Navigate panes |
|
|
148
101
|
| `Ctrl-a arrows` | Resize panes |
|
|
149
|
-
| `Ctrl-a P` | Toggle pane border titles |
|
|
150
102
|
|
|
151
103
|
### Utilities
|
|
152
104
|
|
|
153
105
|
| Key | Action |
|
|
154
106
|
|-----|--------|
|
|
155
|
-
| `Ctrl-a k` | Clear pane
|
|
156
|
-
| `Ctrl-a y` | Copy
|
|
157
|
-
|
|
158
|
-
## Claude Code Integration
|
|
159
|
-
|
|
160
|
-
jmux is built for agentic workflows. When you have Claude Code running in multiple sessions, you need to know which ones need your attention.
|
|
161
|
-
|
|
162
|
-
### One-Command Setup
|
|
163
|
-
|
|
164
|
-
```bash
|
|
165
|
-
jmux --install-agent-hooks
|
|
166
|
-
```
|
|
167
|
-
|
|
168
|
-
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.
|
|
169
|
-
|
|
170
|
-
### Manual Setup
|
|
171
|
-
|
|
172
|
-
Set an attention flag on any session:
|
|
173
|
-
|
|
174
|
-
```bash
|
|
175
|
-
tmux set-option -t my-session @jmux-attention 1
|
|
176
|
-
```
|
|
177
|
-
|
|
178
|
-
jmux shows an orange `!` indicator. When you switch to that session, the flag clears automatically.
|
|
179
|
-
|
|
180
|
-
### Workflow
|
|
107
|
+
| `Ctrl-a k` | Clear pane + scrollback |
|
|
108
|
+
| `Ctrl-a y` | Copy pane to clipboard |
|
|
109
|
+
| `Ctrl-a P` | Toggle pane border titles |
|
|
181
110
|
|
|
182
|
-
|
|
183
|
-
2. Create sessions for each project (`Ctrl-a n`)
|
|
184
|
-
3. Run Claude Code in each session on different tasks
|
|
185
|
-
4. Work in one session while others run in the background
|
|
186
|
-
5. Orange `!` flags appear when Claude finishes — switch instantly with `Ctrl-Shift-Down`
|
|
111
|
+
---
|
|
187
112
|
|
|
188
113
|
## Configuration
|
|
189
114
|
|
|
190
|
-
|
|
115
|
+
Config loads in three layers:
|
|
191
116
|
|
|
192
117
|
```
|
|
193
118
|
config/defaults.conf ← jmux defaults (baseline)
|
|
194
119
|
~/.tmux.conf ← your config (overrides defaults)
|
|
195
|
-
config/core.conf ← jmux
|
|
120
|
+
config/core.conf ← jmux core (always wins)
|
|
196
121
|
```
|
|
197
122
|
|
|
198
|
-
|
|
123
|
+
Override any default in your `~/.tmux.conf` — prefix key, colors, keybindings, plugins. Only four core settings are enforced: `detach-on-destroy off`, `mouse on`, `prefix + n` binding, and empty `status-left`.
|
|
199
124
|
|
|
200
|
-
|
|
201
|
-
- `detach-on-destroy off` — switch to next session on kill, don't exit jmux
|
|
202
|
-
- `mouse on` — required for sidebar clicks
|
|
203
|
-
- `prefix + n` — new session modal
|
|
204
|
-
- Empty `status-left` — session info is in the sidebar
|
|
125
|
+
See [docs/configuration.md](docs/configuration.md) for the full guide.
|
|
205
126
|
|
|
206
|
-
|
|
127
|
+
---
|
|
207
128
|
|
|
208
129
|
## Architecture
|
|
209
130
|
|
|
210
131
|
```
|
|
211
132
|
Terminal (Ghostty, iTerm, etc.)
|
|
212
133
|
└── jmux (owns the terminal surface)
|
|
213
|
-
├── Sidebar (24 cols) ── session groups, indicators
|
|
214
|
-
├── Border (1 col)
|
|
134
|
+
├── Sidebar (24 cols) ── session groups, indicators
|
|
135
|
+
├── Border (1 col)
|
|
215
136
|
└── tmux PTY (remaining cols)
|
|
216
|
-
├── PTY client ────
|
|
217
|
-
└── Control client ─ tmux -C for real-time
|
|
137
|
+
├── PTY client ──── @xterm/headless for VT emulation
|
|
138
|
+
└── Control client ─ tmux -C for real-time metadata
|
|
218
139
|
```
|
|
219
140
|
|
|
220
|
-
|
|
141
|
+
~1500 lines of TypeScript. No opinions about what you run inside tmux.
|
|
142
|
+
|
|
143
|
+
---
|
|
221
144
|
|
|
222
145
|
## License
|
|
223
146
|
|
|
224
|
-
MIT
|
|
147
|
+
[MIT](LICENSE)
|
package/config/defaults.conf
CHANGED
|
@@ -59,6 +59,12 @@ set -g window-active-style 'fg=#b5bcc9'
|
|
|
59
59
|
bind k send-keys -R \; clear-history \; display-message "Pane cleared"
|
|
60
60
|
bind y run-shell "tmux capture-pane -pS - -E - | grep . | pbcopy" \; display-message "Copied pane to clipboard"
|
|
61
61
|
|
|
62
|
+
# Move window to another session (C-a m)
|
|
63
|
+
bind-key m display-popup -E -w 40% -h 50% -b heavy -S 'fg=#4f565d' "$JMUX_DIR/config/move-window.sh"
|
|
64
|
+
|
|
65
|
+
# Rename session (C-a r)
|
|
66
|
+
bind-key r display-popup -E -w 40% -h 8 -b heavy -S 'fg=#4f565d' "$JMUX_DIR/config/rename-session.sh"
|
|
67
|
+
|
|
62
68
|
# Window switcher popup (C-a j) — jmux overrides this for sidebar mode,
|
|
63
69
|
# but it's still available if sidebar mode changes
|
|
64
70
|
bind-key j display-popup -E -x 0 -y 0 -w 30% -h 100% -b heavy -S 'fg=#4f565d' \
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# jmux move window — pick a destination session
|
|
3
|
+
# Called via: display-popup -E "move-window.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
|
+
CURRENT_WINDOW=$(tmux display-message -p '#W')
|
|
8
|
+
CURRENT_SESSION=$(tmux display-message -p '#S')
|
|
9
|
+
|
|
10
|
+
# List all sessions except the current one
|
|
11
|
+
SESSIONS=$(tmux list-sessions -F '#S' | grep -v "^${CURRENT_SESSION}$")
|
|
12
|
+
|
|
13
|
+
if [ -z "$SESSIONS" ]; then
|
|
14
|
+
echo "No other sessions to move to."
|
|
15
|
+
sleep 1
|
|
16
|
+
exit 0
|
|
17
|
+
fi
|
|
18
|
+
|
|
19
|
+
TARGET=$(echo "$SESSIONS" | fzf \
|
|
20
|
+
--height=100% \
|
|
21
|
+
--layout=reverse \
|
|
22
|
+
--border=rounded \
|
|
23
|
+
--border-label=" Move Window " \
|
|
24
|
+
--header="Moving: $CURRENT_WINDOW → ?" \
|
|
25
|
+
--header-first \
|
|
26
|
+
--prompt="Session: " \
|
|
27
|
+
--pointer="▸" \
|
|
28
|
+
--color="$FZF_COLORS")
|
|
29
|
+
|
|
30
|
+
[ -z "$TARGET" ] && exit 0
|
|
31
|
+
|
|
32
|
+
tmux move-window -t "$TARGET:"
|
package/config/new-session.sh
CHANGED
|
@@ -6,7 +6,7 @@ FZF_COLORS="border:#4f565d,header:#b5bcc9,prompt:#9fe8c3,label:#9fe8c3,pointer:#
|
|
|
6
6
|
|
|
7
7
|
# ─── Step 1: Pick a directory ─────────────────────────────────────────
|
|
8
8
|
|
|
9
|
-
# Build project list: find directories with .git (
|
|
9
|
+
# Build project list: find directories with .git (dir or file — worktrees use a file)
|
|
10
10
|
# Search common code directories, limit depth for speed
|
|
11
11
|
PROJECT_DIRS=$(find \
|
|
12
12
|
"$HOME/Code" \
|
|
@@ -14,8 +14,7 @@ PROJECT_DIRS=$(find \
|
|
|
14
14
|
"$HOME/src" \
|
|
15
15
|
"$HOME/work" \
|
|
16
16
|
"$HOME/dev" \
|
|
17
|
-
2>/dev/null \
|
|
18
|
-
-maxdepth 3 -name ".git" -type d 2>/dev/null \
|
|
17
|
+
-maxdepth 4 -name ".git" 2>/dev/null \
|
|
19
18
|
| sed 's|/\.git$||' \
|
|
20
19
|
| sort -u)
|
|
21
20
|
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
#!/bin/bash
|
|
2
|
+
# jmux rename session
|
|
3
|
+
# Called via: display-popup -E "rename-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
|
+
CURRENT_NAME=$(tmux display-message -p '#S')
|
|
8
|
+
|
|
9
|
+
NEW_NAME=$(echo "" | fzf --print-query \
|
|
10
|
+
--height=100% \
|
|
11
|
+
--layout=reverse \
|
|
12
|
+
--border=rounded \
|
|
13
|
+
--border-label=" Rename Session " \
|
|
14
|
+
--header="Current: $CURRENT_NAME" \
|
|
15
|
+
--header-first \
|
|
16
|
+
--prompt="Name: " \
|
|
17
|
+
--query="$CURRENT_NAME" \
|
|
18
|
+
--pointer="" \
|
|
19
|
+
--no-info \
|
|
20
|
+
--color="$FZF_COLORS" \
|
|
21
|
+
| head -1)
|
|
22
|
+
|
|
23
|
+
[ -z "$NEW_NAME" ] && exit 0
|
|
24
|
+
[ "$NEW_NAME" = "$CURRENT_NAME" ] && exit 0
|
|
25
|
+
|
|
26
|
+
tmux rename-session "$NEW_NAME"
|
package/package.json
CHANGED
|
@@ -64,10 +64,10 @@ describe("Sidebar", () => {
|
|
|
64
64
|
(_, i) => grid.cells[2][i].char,
|
|
65
65
|
).join("");
|
|
66
66
|
expect(headerRow).toContain("Code/work");
|
|
67
|
-
// Row 3: first session in group "api"
|
|
67
|
+
// Row 3: spacer, Row 4: first session in group "api"
|
|
68
68
|
const apiRow = Array.from(
|
|
69
69
|
{ length: SIDEBAR_WIDTH },
|
|
70
|
-
(_, i) => grid.cells[
|
|
70
|
+
(_, i) => grid.cells[4][i].char,
|
|
71
71
|
).join("");
|
|
72
72
|
expect(apiRow).toContain("api");
|
|
73
73
|
});
|
|
@@ -90,7 +90,7 @@ describe("Sidebar", () => {
|
|
|
90
90
|
expect(row2).toContain("only-one");
|
|
91
91
|
});
|
|
92
92
|
|
|
93
|
-
test("grouped sessions show branch
|
|
93
|
+
test("grouped sessions show branch on detail line", () => {
|
|
94
94
|
const sidebar = new Sidebar(SIDEBAR_WIDTH, 30);
|
|
95
95
|
sidebar.updateSessions(
|
|
96
96
|
makeSessions([
|
|
@@ -107,11 +107,10 @@ describe("Sidebar", () => {
|
|
|
107
107
|
]),
|
|
108
108
|
);
|
|
109
109
|
const grid = sidebar.getGrid();
|
|
110
|
-
//
|
|
111
|
-
// Row 2: group header, Row 3: api name, Row 4: api detail
|
|
110
|
+
// Row 2: group header, Row 3: spacer, Row 4: api name, Row 5: api detail
|
|
112
111
|
const detailRow = Array.from(
|
|
113
112
|
{ length: SIDEBAR_WIDTH },
|
|
114
|
-
(_, i) => grid.cells[
|
|
113
|
+
(_, i) => grid.cells[5][i].char,
|
|
115
114
|
).join("");
|
|
116
115
|
expect(detailRow).toContain("main");
|
|
117
116
|
expect(detailRow).not.toContain("Code/work");
|
|
@@ -209,10 +208,12 @@ describe("Sidebar", () => {
|
|
|
209
208
|
|
|
210
209
|
// Row 2: group header → null
|
|
211
210
|
expect(sidebar.getSessionByRow(2)).toBeNull();
|
|
212
|
-
// Row 3:
|
|
213
|
-
expect(sidebar.getSessionByRow(3)
|
|
214
|
-
// Row 4: first session
|
|
211
|
+
// Row 3: spacer → null
|
|
212
|
+
expect(sidebar.getSessionByRow(3)).toBeNull();
|
|
213
|
+
// Row 4: first session name row → api
|
|
215
214
|
expect(sidebar.getSessionByRow(4)?.name).toBe("api");
|
|
215
|
+
// Row 5: first session detail row → api
|
|
216
|
+
expect(sidebar.getSessionByRow(5)?.name).toBe("api");
|
|
216
217
|
});
|
|
217
218
|
|
|
218
219
|
test("shows window count", () => {
|
package/src/main.ts
CHANGED
|
@@ -12,7 +12,7 @@ import { homedir } from "os";
|
|
|
12
12
|
|
|
13
13
|
// --- CLI commands (run and exit before TUI) ---
|
|
14
14
|
|
|
15
|
-
const VERSION = "0.3.
|
|
15
|
+
const VERSION = "0.3.1";
|
|
16
16
|
|
|
17
17
|
const HELP = `jmux — a persistent session sidebar for tmux
|
|
18
18
|
|
|
@@ -161,6 +161,7 @@ let ptyClientName: string | null = null;
|
|
|
161
161
|
let sidebarShown = sidebarVisible;
|
|
162
162
|
let currentSessions: SessionInfo[] = [];
|
|
163
163
|
const lastViewedTimestamps = new Map<string, number>();
|
|
164
|
+
const sessionDetailsCache = new Map<string, { directory?: string; gitBranch?: string }>();
|
|
164
165
|
|
|
165
166
|
function switchByOffset(offset: number): void {
|
|
166
167
|
const ids = sidebar.getDisplayOrderIds();
|
|
@@ -182,6 +183,7 @@ async function fetchSessions(): Promise<void> {
|
|
|
182
183
|
.filter((l) => l.length > 0)
|
|
183
184
|
.map((line) => {
|
|
184
185
|
const [id, name, activity, attached, windows] = line.split(":");
|
|
186
|
+
const cached = sessionDetailsCache.get(id);
|
|
185
187
|
return {
|
|
186
188
|
id,
|
|
187
189
|
name,
|
|
@@ -189,6 +191,8 @@ async function fetchSessions(): Promise<void> {
|
|
|
189
191
|
attached: attached === "1",
|
|
190
192
|
attention: false,
|
|
191
193
|
windowCount: parseInt(windows, 10) || 1,
|
|
194
|
+
directory: cached?.directory,
|
|
195
|
+
gitBranch: cached?.gitBranch,
|
|
192
196
|
};
|
|
193
197
|
});
|
|
194
198
|
currentSessions = sessions;
|
|
@@ -339,6 +343,7 @@ process.on("SIGWINCH", () => {
|
|
|
339
343
|
control.onEvent((event: ControlEvent) => {
|
|
340
344
|
switch (event.type) {
|
|
341
345
|
case "sessions-changed":
|
|
346
|
+
case "session-renamed":
|
|
342
347
|
fetchSessions();
|
|
343
348
|
break;
|
|
344
349
|
case "session-changed":
|
|
@@ -374,24 +379,33 @@ async function lookupSessionDetails(sessions: SessionInfo[]): Promise<void> {
|
|
|
374
379
|
const home = process.env.HOME || "";
|
|
375
380
|
for (const session of sessions) {
|
|
376
381
|
try {
|
|
377
|
-
// Use control mode connection — respects -L socket and -f config
|
|
378
382
|
const lines = await control.sendCommand(
|
|
379
383
|
`display-message -t '${session.id}' -p '#{pane_current_path}'`,
|
|
380
384
|
);
|
|
381
385
|
const cwd = (lines[0] || "").trim();
|
|
382
386
|
if (!cwd) continue;
|
|
383
|
-
|
|
387
|
+
const directory = cwd.startsWith(home)
|
|
384
388
|
? "~" + cwd.slice(home.length)
|
|
385
389
|
: cwd;
|
|
386
390
|
const branch = await $`git -C ${cwd} branch --show-current`
|
|
387
391
|
.text()
|
|
388
392
|
.catch(() => "");
|
|
389
|
-
|
|
393
|
+
const gitBranch = branch.trim() || undefined;
|
|
394
|
+
|
|
395
|
+
// Write to persistent cache
|
|
396
|
+
sessionDetailsCache.set(session.id, { directory, gitBranch });
|
|
397
|
+
session.directory = directory;
|
|
398
|
+
session.gitBranch = gitBranch;
|
|
390
399
|
} catch {
|
|
391
400
|
// Session may not exist or no git repo
|
|
392
401
|
}
|
|
393
402
|
}
|
|
394
|
-
|
|
403
|
+
// Rebuild currentSessions with cached data
|
|
404
|
+
currentSessions = currentSessions.map((s) => {
|
|
405
|
+
const cached = sessionDetailsCache.get(s.id);
|
|
406
|
+
return cached ? { ...s, ...cached } : s;
|
|
407
|
+
});
|
|
408
|
+
sidebar.updateSessions(currentSessions);
|
|
395
409
|
renderFrame();
|
|
396
410
|
}
|
|
397
411
|
|
package/src/sidebar.ts
CHANGED
|
@@ -45,19 +45,38 @@ interface SessionGroup {
|
|
|
45
45
|
sessionIndices: number[];
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
function
|
|
49
|
-
const
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
if (segments[0] === "~"
|
|
55
|
-
|
|
48
|
+
function getGroupLabel(dir: string): string | null {
|
|
49
|
+
const segments = dir.split("/").filter((s) => s.length > 0);
|
|
50
|
+
// For ~/X/Y/... paths, group by X/Y (fixed depth)
|
|
51
|
+
// ~/Code/personal/jmux → "Code/personal"
|
|
52
|
+
// ~/Code/personal → "Code/personal"
|
|
53
|
+
// ~/Code/tracktile/platform → "Code/tracktile"
|
|
54
|
+
if (segments[0] === "~") {
|
|
55
|
+
if (segments.length < 3) return null; // ~ or ~/Code — too shallow
|
|
56
|
+
return segments[1] + "/" + segments[2];
|
|
57
|
+
}
|
|
58
|
+
// Absolute paths: /X/Y/... → group by X/Y
|
|
59
|
+
if (segments.length < 2) return null;
|
|
60
|
+
return segments[0] + "/" + segments[1];
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
function getSubdirectory(dir: string, groupLabel: string): string | null {
|
|
64
|
+
// dir: "~/Code/personal/jmux", groupLabel: "Code/personal" → "jmux"
|
|
65
|
+
// dir: "~/Code/personal/jmux/sub", groupLabel: "Code/personal" → "jmux/sub"
|
|
66
|
+
const idx = dir.indexOf(groupLabel);
|
|
67
|
+
if (idx < 0) return null;
|
|
68
|
+
const rest = dir.slice(idx + groupLabel.length);
|
|
69
|
+
// rest is e.g. "/jmux" or "/jmux/sub/deep"
|
|
70
|
+
const trimmed = rest.replace(/^\/+/, "");
|
|
71
|
+
if (!trimmed) return null;
|
|
72
|
+
// For nested paths, just show the last directory name
|
|
73
|
+
const lastSlash = trimmed.lastIndexOf("/");
|
|
74
|
+
return lastSlash >= 0 ? trimmed.slice(lastSlash + 1) : trimmed;
|
|
56
75
|
}
|
|
57
76
|
|
|
58
77
|
type RenderItem =
|
|
59
78
|
| { type: "group-header"; label: string }
|
|
60
|
-
| { type: "session"; sessionIndex: number; grouped: boolean }
|
|
79
|
+
| { type: "session"; sessionIndex: number; grouped: boolean; groupLabel?: string }
|
|
61
80
|
| { type: "spacer" };
|
|
62
81
|
|
|
63
82
|
function buildRenderPlan(sessions: SessionInfo[]): {
|
|
@@ -73,7 +92,7 @@ function buildRenderPlan(sessions: SessionInfo[]): {
|
|
|
73
92
|
ungrouped.push(i);
|
|
74
93
|
continue;
|
|
75
94
|
}
|
|
76
|
-
const label =
|
|
95
|
+
const label = getGroupLabel(dir);
|
|
77
96
|
if (!label) {
|
|
78
97
|
ungrouped.push(i);
|
|
79
98
|
continue;
|
|
@@ -109,8 +128,9 @@ function buildRenderPlan(sessions: SessionInfo[]): {
|
|
|
109
128
|
|
|
110
129
|
for (const group of sortedGroups) {
|
|
111
130
|
items.push({ type: "group-header", label: group.label });
|
|
131
|
+
items.push({ type: "spacer" });
|
|
112
132
|
for (const idx of group.sessionIndices) {
|
|
113
|
-
items.push({ type: "session", sessionIndex: idx, grouped: true });
|
|
133
|
+
items.push({ type: "session", sessionIndex: idx, grouped: true, groupLabel: group.label });
|
|
114
134
|
displayOrder.push(idx);
|
|
115
135
|
items.push({ type: "spacer" });
|
|
116
136
|
}
|
|
@@ -210,64 +230,104 @@ export class Sidebar {
|
|
|
210
230
|
if (!session) continue;
|
|
211
231
|
|
|
212
232
|
const nameRow = row;
|
|
213
|
-
const detailRow = row + 1;
|
|
214
233
|
const isActive = session.id === this.activeSessionId;
|
|
215
234
|
const hasActivity = this.activitySet.has(session.id);
|
|
216
235
|
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
this.rowToSessionIndex.set(detailRow, sessionIdx);
|
|
221
|
-
}
|
|
236
|
+
if (item.grouped) {
|
|
237
|
+
// Grouped: two rows — name + window count, then subdirectory + branch
|
|
238
|
+
const detailRow = row + 1;
|
|
222
239
|
|
|
223
|
-
|
|
224
|
-
if (isActive) {
|
|
225
|
-
writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
240
|
+
this.rowToSessionIndex.set(nameRow, sessionIdx);
|
|
226
241
|
if (detailRow < this.height) {
|
|
227
|
-
|
|
242
|
+
this.rowToSessionIndex.set(detailRow, sessionIdx);
|
|
228
243
|
}
|
|
229
|
-
}
|
|
230
244
|
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
245
|
+
if (isActive) {
|
|
246
|
+
writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
247
|
+
if (detailRow < this.height) {
|
|
248
|
+
writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
237
251
|
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
252
|
+
if (session.attention) {
|
|
253
|
+
writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
|
|
254
|
+
} else if (hasActivity) {
|
|
255
|
+
writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
|
|
256
|
+
}
|
|
241
257
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
258
|
+
const windowCountStr = `${session.windowCount}w`;
|
|
259
|
+
const windowCountCol = this.width - windowCountStr.length - 1;
|
|
260
|
+
const nameStart = 3;
|
|
261
|
+
const nameMaxLen = windowCountCol - 1 - nameStart;
|
|
262
|
+
let displayName = session.name;
|
|
263
|
+
if (displayName.length > nameMaxLen) {
|
|
264
|
+
displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
|
|
265
|
+
}
|
|
249
266
|
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
267
|
+
const nameAttrs: CellAttrs = isActive
|
|
268
|
+
? { ...ACTIVE_NAME_ATTRS }
|
|
269
|
+
: { ...INACTIVE_NAME_ATTRS };
|
|
270
|
+
writeString(grid, nameRow, nameStart, displayName, nameAttrs);
|
|
254
271
|
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
272
|
+
if (windowCountCol > nameStart) {
|
|
273
|
+
writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
|
|
274
|
+
}
|
|
258
275
|
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
|
|
276
|
+
// Detail line: branch name
|
|
277
|
+
if (detailRow < this.height && session.gitBranch) {
|
|
278
|
+
const detailStart = 3;
|
|
279
|
+
const maxLen = this.width - detailStart - 1;
|
|
280
|
+
let branch = session.gitBranch;
|
|
281
|
+
if (branch.length > maxLen) {
|
|
282
|
+
branch = branch.slice(0, maxLen - 1) + "\u2026";
|
|
283
|
+
}
|
|
284
|
+
writeString(grid, detailRow, detailStart, branch, DIM_ATTRS);
|
|
285
|
+
}
|
|
262
286
|
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
287
|
+
row += 2;
|
|
288
|
+
} else {
|
|
289
|
+
// Ungrouped: two rows (name + detail)
|
|
290
|
+
const detailRow = row + 1;
|
|
291
|
+
|
|
292
|
+
this.rowToSessionIndex.set(nameRow, sessionIdx);
|
|
293
|
+
if (detailRow < this.height) {
|
|
294
|
+
this.rowToSessionIndex.set(detailRow, sessionIdx);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
if (isActive) {
|
|
298
|
+
writeString(grid, nameRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
299
|
+
if (detailRow < this.height) {
|
|
300
|
+
writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
|
|
269
301
|
}
|
|
270
|
-
}
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
if (session.attention) {
|
|
305
|
+
writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
|
|
306
|
+
} else if (hasActivity) {
|
|
307
|
+
writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
|
|
308
|
+
}
|
|
309
|
+
|
|
310
|
+
const windowCountStr = `${session.windowCount}w`;
|
|
311
|
+
const windowCountCol = this.width - windowCountStr.length - 1;
|
|
312
|
+
const nameStart = 3;
|
|
313
|
+
const nameMaxLen = windowCountCol - 1 - nameStart;
|
|
314
|
+
let displayName = session.name;
|
|
315
|
+
if (displayName.length > nameMaxLen) {
|
|
316
|
+
displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
const nameAttrs: CellAttrs = isActive
|
|
320
|
+
? { ...ACTIVE_NAME_ATTRS }
|
|
321
|
+
: { ...INACTIVE_NAME_ATTRS };
|
|
322
|
+
writeString(grid, nameRow, nameStart, displayName, nameAttrs);
|
|
323
|
+
|
|
324
|
+
if (windowCountCol > nameStart) {
|
|
325
|
+
writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
|
|
326
|
+
}
|
|
327
|
+
|
|
328
|
+
// Detail line
|
|
329
|
+
if (detailRow < this.height) {
|
|
330
|
+
const detailStart = 3;
|
|
271
331
|
let branchCols = 0;
|
|
272
332
|
if (session.gitBranch) {
|
|
273
333
|
const branchCol = this.width - session.gitBranch.length - 1;
|
|
@@ -285,9 +345,9 @@ export class Sidebar {
|
|
|
285
345
|
writeString(grid, detailRow, detailStart, displayDir, DIM_ATTRS);
|
|
286
346
|
}
|
|
287
347
|
}
|
|
288
|
-
}
|
|
289
348
|
|
|
290
|
-
|
|
349
|
+
row += 2;
|
|
350
|
+
}
|
|
291
351
|
}
|
|
292
352
|
|
|
293
353
|
return grid;
|
package/src/tmux-control.ts
CHANGED
|
@@ -5,6 +5,7 @@ import type { Subprocess } from "bun";
|
|
|
5
5
|
export type ControlEvent =
|
|
6
6
|
| { type: "sessions-changed" }
|
|
7
7
|
| { type: "session-changed"; args: string }
|
|
8
|
+
| { type: "session-renamed"; args: string }
|
|
8
9
|
| { type: "window-renamed"; args: string }
|
|
9
10
|
| { type: "client-session-changed"; args: string }
|
|
10
11
|
| {
|
|
@@ -83,6 +84,11 @@ export class ControlParser {
|
|
|
83
84
|
type: "session-changed",
|
|
84
85
|
args: line.slice("%session-changed ".length),
|
|
85
86
|
});
|
|
87
|
+
} else if (line.startsWith("%session-renamed ")) {
|
|
88
|
+
this.emit({
|
|
89
|
+
type: "session-renamed",
|
|
90
|
+
args: line.slice("%session-renamed ".length),
|
|
91
|
+
});
|
|
86
92
|
} else if (line.startsWith("%window-renamed ")) {
|
|
87
93
|
this.emit({
|
|
88
94
|
type: "window-renamed",
|