@jx0/jmux 0.3.9 → 0.5.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/README.md CHANGED
@@ -4,12 +4,12 @@
4
4
 
5
5
  **The terminal workspace for agentic development.**
6
6
 
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.
7
+ Run coding agents in parallel from your terminal. A persistent sidebar shows every session, what's running, and what needs your attention — without leaving the tools you already use.
8
8
 
9
9
  [![npm](https://img.shields.io/npm/v/@jx0/jmux)](https://www.npmjs.com/package/@jx0/jmux)
10
10
  [![license](https://img.shields.io/github/license/jarredkenny/jmux)](LICENSE)
11
11
 
12
- ![jmux sidebar with grouped sessions alongside vim and server logs](docs/screenshots/hero.png)
12
+ ![jmux sidebar with grouped sessions alongside vim and Claude Code](docs/screenshots/hero.png)
13
13
 
14
14
  </div>
15
15
 
@@ -26,47 +26,64 @@ Requires [Bun](https://bun.sh) 1.2+, [tmux](https://github.com/tmux/tmux) 3.2+,
26
26
 
27
27
  ## Why
28
28
 
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.
29
+ GUI agent orchestrators are 100MB+ Electron apps that lock you into their editor, their diff viewer, their Git workflow. They work on one platform. They'll charge you eventually.
30
30
 
31
- jmux fixes this with a persistent sidebar that shows every session, all the time.
31
+ jmux takes the opposite approach: it's a thin orchestration layer over tmux — the tool you already know. Your editor, your Git workflow, your shell, your tools. jmux just makes them visible and navigable when you're running 10+ agents in parallel.
32
+
33
+ | | jmux | GUI orchestrators |
34
+ |---|---|---|
35
+ | **Size** | ~0.3 MB | ~100+ MB |
36
+ | **Platform** | Anywhere tmux runs (macOS, Linux, SSH, containers) | macOS only |
37
+ | **Editor** | Yours (vim, emacs, VS Code, whatever) | Built-in (take it or leave it) |
38
+ | **Git** | `git`, `gh`, lazygit, [wtm](https://github.com/jarredkenny/worktree-manager), your workflow | Built-in GUI (their workflow) |
39
+ | **Agents** | Any (Claude Code, Codex, aider, custom) | Bundled subset |
40
+ | **Lock-in** | None — it's tmux underneath | Proprietary workspace format |
41
+ | **Cost** | Free, open source | Free today, VC-funded |
32
42
 
33
43
  ## Features
34
44
 
35
45
  ### Session Sidebar
36
46
 
37
- Every session visible at a glance — name, window count, git branch. Sessions sharing a parent directory are automatically grouped under a header.
47
+ Every session visible at a glance — name, window count, git branch. Sessions sharing a parent directory are automatically grouped under a header. Mouse wheel scrolling when sessions overflow.
38
48
 
39
- - Green `▎` left marker on the active session
49
+ - Green `▎` marker + highlighted background on the active session
40
50
  - Green `●` dot for sessions with new output
41
- - Orange `!` flag for attention (set programmatically)
51
+ - Orange `!` flag for attention (e.g., an agent finished and needs review)
52
+
53
+ ### Smart Window & Pane Titles
54
+
55
+ Window tabs auto-name to the working directory. Pane borders show the running command with automatic detection for tools like Claude Code. No more tabs full of `zsh` or garbled version strings.
42
56
 
43
57
  ### Instant Switching
44
58
 
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.
59
+ `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. Indicators only clear when you actually interact with a session — not when you're cycling through.
46
60
 
47
61
  ### New Session Modal
48
62
 
49
63
  `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.
50
64
 
51
- ### Window Picker
65
+ ### Bring Your Own Everything
52
66
 
53
- `Ctrl-a j` opens a full-height fzf popup with every window in the current session. Type to filter, Enter to switch.
67
+ 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 are enforced.
54
68
 
55
- ![fzf window picker popup](docs/screenshots/window-picker.png)
69
+ Use any editor. Any Git tool. Any AI agent. Any shell. jmux doesn't replace your tools — it organizes them.
56
70
 
57
- ### Bring Your Own Config
71
+ ### Works Great With
58
72
 
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.
73
+ - **[wtm](https://github.com/jarredkenny/worktree-manager)** Git worktree manager. Create isolated worktrees for each agent, one session per branch. `wtm create feature-auth --from main` + jmux = parallel agents on parallel branches.
74
+ - **[Claude Code](https://docs.anthropic.com/en/docs/claude-code)** — AI coding agent with built-in attention flag support.
75
+ - **[lazygit](https://github.com/jesseduffield/lazygit)** — Terminal Git UI. Run it in a jmux pane alongside your agent.
76
+ - **[gh](https://cli.github.com/)** / **[glab](https://gitlab.com/gitlab-org/cli)** — GitHub and GitLab CLIs for PRs, issues, and reviews without leaving the terminal.
60
77
 
61
- ### Claude Code Integration
78
+ ### Agent Integration
62
79
 
63
- Built for agentic workflows. Run Claude Code in multiple sessions and get notified when each one finishes.
80
+ Built for running multiple coding agents in parallel. One command sets up attention notifications:
64
81
 
65
82
  ```bash
66
83
  jmux --install-agent-hooks
67
84
  ```
68
85
 
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.
86
+ When Claude Code finishes a response, the orange `!` appears on that session in the sidebar. Switch to it, review the work, move on. Works with any agent that can run a shell command on completion. See [docs/claude-code-integration.md](docs/claude-code-integration.md) for details.
70
87
 
71
88
  ---
72
89
 
@@ -81,6 +98,7 @@ One command adds a hook to `~/.claude/settings.json`. When Claude finishes a res
81
98
  | `Ctrl-a r` | Rename session |
82
99
  | `Ctrl-a m` | Move window to another session |
83
100
  | Click sidebar | Switch to session |
101
+ | Scroll wheel (sidebar) | Scroll session list |
84
102
 
85
103
  ### Windows
86
104
 
@@ -97,8 +115,9 @@ One command adds a hook to `~/.claude/settings.json`. When Claude finishes a res
97
115
  |-----|--------|
98
116
  | `Ctrl-a \|` | Split horizontal |
99
117
  | `Ctrl-a -` | Split vertical |
100
- | `Shift-arrows` | Navigate panes |
101
- | `Ctrl-a arrows` | Resize panes |
118
+ | `Shift-Left/Right/Up/Down` | Navigate panes (vim-aware) |
119
+ | `Ctrl-a Left/Right/Up/Down` | Resize panes |
120
+ | `Ctrl-a P` | Toggle pane border titles |
102
121
 
103
122
  ### Utilities
104
123
 
@@ -106,7 +125,7 @@ One command adds a hook to `~/.claude/settings.json`. When Claude finishes a res
106
125
  |-----|--------|
107
126
  | `Ctrl-a k` | Clear pane + scrollback |
108
127
  | `Ctrl-a y` | Copy pane to clipboard |
109
- | `Ctrl-a P` | Toggle pane border titles |
128
+ | `Ctrl-a i` | Settings |
110
129
 
111
130
  ---
112
131
 
@@ -115,12 +134,12 @@ One command adds a hook to `~/.claude/settings.json`. When Claude finishes a res
115
134
  Config loads in three layers:
116
135
 
117
136
  ```
118
- config/defaults.conf jmux defaults (baseline)
119
- ~/.tmux.conf your config (overrides defaults)
120
- config/core.conf jmux core (always wins)
137
+ config/defaults.conf <- jmux defaults (baseline)
138
+ ~/.tmux.conf <- your config (overrides defaults)
139
+ config/core.conf <- jmux core (always wins)
121
140
  ```
122
141
 
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`.
142
+ Override any default in your `~/.tmux.conf` — prefix key, colors, keybindings, plugins. Only core settings the sidebar depends on are enforced (`mouse on`, `detach-on-destroy off`, window naming, `status-left`).
124
143
 
125
144
  See [docs/configuration.md](docs/configuration.md) for the full guide.
126
145
 
@@ -130,15 +149,15 @@ See [docs/configuration.md](docs/configuration.md) for the full guide.
130
149
 
131
150
  ```
132
151
  Terminal (Ghostty, iTerm, etc.)
133
- └── jmux (owns the terminal surface)
134
- ├── Sidebar (24 cols) ── session groups, indicators
135
- ├── Border (1 col)
136
- └── tmux PTY (remaining cols)
137
- ├── PTY client ──── @xterm/headless for VT emulation
138
- └── Control client tmux -C for real-time metadata
152
+ +-- jmux (owns the terminal surface)
153
+ +-- Sidebar (26 cols) -- session groups, indicators
154
+ +-- Border (1 col)
155
+ +-- tmux PTY (remaining cols)
156
+ +-- PTY client ---- @xterm/headless for VT emulation
157
+ +-- Control client - tmux -C for real-time metadata
139
158
  ```
140
159
 
141
- ~1500 lines of TypeScript. No opinions about what you run inside tmux.
160
+ ~1800 lines of TypeScript. No opinions about what you run inside tmux.
142
161
 
143
162
  ---
144
163
 
@@ -63,6 +63,9 @@ bind y run-shell "tmux capture-pane -pS - -E - | grep . | pbcopy" \; display-mes
63
63
  # Move window to another session (C-a m)
64
64
  bind-key m display-popup -E -w 40% -h 50% -b heavy -S 'fg=#4f565d' "$JMUX_DIR/config/move-window.sh"
65
65
 
66
+ # Settings modal (C-a i)
67
+ bind-key i display-popup -E -w 50% -h 40% -b heavy -S 'fg=#4f565d' "$JMUX_DIR/config/settings.sh"
68
+
66
69
  # Rename session (C-a r)
67
70
  bind-key r display-popup -E -w 40% -h 8 -b heavy -S 'fg=#4f565d' "$JMUX_DIR/config/rename-session.sh"
68
71
 
@@ -1,19 +1,29 @@
1
1
  #!/bin/bash
2
- # jmux new session — name + directory picker
2
+ # jmux new session — name + directory picker with wtm worktree support
3
3
  # Called via: display-popup -E "new-session.sh"
4
4
 
5
5
  FZF_COLORS="border:#4f565d,header:#b5bcc9,prompt:#9fe8c3,label:#9fe8c3,pointer:#9fe8c3,fg:#6b7280,fg+:#b5bcc9,hl:#fbd4b8,hl+:#fbd4b8"
6
6
 
7
7
  # ─── Step 1: Pick a directory ─────────────────────────────────────────
8
8
 
9
+ # Read project directories from config, fall back to defaults
10
+ CONFIG_FILE="$HOME/.config/jmux/config.json"
11
+ SEARCH_DIRS=""
12
+ if [ -f "$CONFIG_FILE" ]; then
13
+ SEARCH_DIRS=$(bun -e "
14
+ const c = await Bun.file('$CONFIG_FILE').json().catch(() => ({}));
15
+ const dirs = c.projectDirs ?? [];
16
+ console.log(dirs.map(d => d.replace('~', process.env.HOME)).join('\n'));
17
+ " 2>/dev/null)
18
+ fi
19
+ if [ -z "$SEARCH_DIRS" ]; then
20
+ SEARCH_DIRS=$(printf "%s\n%s\n%s\n%s\n%s" \
21
+ "$HOME/Code" "$HOME/Projects" "$HOME/src" "$HOME/work" "$HOME/dev")
22
+ fi
23
+
9
24
  # Build project list: find directories with .git (dir or file — worktrees use a file)
10
25
  # 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" \
26
+ PROJECT_DIRS=$(echo "$SEARCH_DIRS" | xargs -I{} find "{}" \
17
27
  -maxdepth 4 -name ".git" 2>/dev/null \
18
28
  | sed 's|/\.git$||' \
19
29
  | sort -u)
@@ -40,26 +50,152 @@ SELECTED_DIR=$(echo "$DISPLAY_DIRS" | fzf \
40
50
  # Expand ~ back to $HOME
41
51
  WORK_DIR="${SELECTED_DIR/#\~/$HOME}"
42
52
 
43
- # Default session name to directory basename
44
- DEFAULT_NAME=$(basename "$WORK_DIR")
53
+ # ─── Step 1.5: Detect wtm bare repo ──────────────────────────────────
45
54
 
46
- # ─── Step 2: Session name ─────────────────────────────────────────────
55
+ # Check if this is a bare repo (wtm-managed) and wtm is available
56
+ IS_BARE=false
57
+ WTM_ENABLED=$(bun -e "
58
+ const c = await Bun.file('$CONFIG_FILE').json().catch(() => ({}));
59
+ console.log(c.wtmIntegration ?? true);
60
+ " 2>/dev/null || echo "true")
61
+ if [ "$WTM_ENABLED" = "true" ] && command -v wtm &>/dev/null && [ -f "$WORK_DIR/.git/config" ]; then
62
+ if git --git-dir="$WORK_DIR/.git" config --get core.bare 2>/dev/null | grep -q "true"; then
63
+ IS_BARE=true
64
+ fi
65
+ fi
47
66
 
48
- SESSION_NAME=$(echo "" | fzf --print-query \
49
- --height=100% \
50
- --layout=reverse \
51
- --border=rounded \
52
- --border-label=" New Session Name " \
53
- --header="Directory: $SELECTED_DIR" \
54
- --header-first \
55
- --prompt="Name: " \
56
- --query="$DEFAULT_NAME" \
57
- --pointer="" \
58
- --no-info \
59
- --color="$FZF_COLORS" \
60
- | head -1)
61
-
62
- [ -z "$SESSION_NAME" ] && exit 0
67
+ if [ "$IS_BARE" = true ]; then
68
+ # ─── wtm flow: create worktree or pick existing ───────────────
69
+
70
+ # List existing worktrees (non-bare)
71
+ EXISTING=$(git --git-dir="$WORK_DIR/.git" worktree list --porcelain 2>/dev/null \
72
+ | grep "^branch " \
73
+ | sed 's|branch refs/heads/||' \
74
+ | sort)
75
+
76
+ # Detect default branch
77
+ DEFAULT_BRANCH=""
78
+ for b in main master develop; do
79
+ if git --git-dir="$WORK_DIR/.git" rev-parse --verify "refs/remotes/origin/$b" &>/dev/null; then
80
+ DEFAULT_BRANCH="$b"
81
+ break
82
+ fi
83
+ done
84
+
85
+ # Build options: existing worktrees + "new worktree" option
86
+ OPTIONS=""
87
+ if [ -n "$EXISTING" ]; then
88
+ OPTIONS=$(echo "$EXISTING" | sed 's/^/ /')
89
+ fi
90
+ OPTIONS=$(printf "+ new worktree\n%s" "$OPTIONS" | grep -v '^$')
91
+
92
+ PROJECT_NAME=$(basename "$WORK_DIR")
93
+ CHOICE=$(echo "$OPTIONS" | fzf \
94
+ --height=100% \
95
+ --layout=reverse \
96
+ --border=rounded \
97
+ --border-label=" $PROJECT_NAME — Worktree " \
98
+ --header="Pick a worktree or create a new one" \
99
+ --header-first \
100
+ --prompt="Branch: " \
101
+ --pointer="▸" \
102
+ --color="$FZF_COLORS")
103
+
104
+ [ -z "$CHOICE" ] && exit 0
105
+
106
+ if [ "$CHOICE" = "+ new worktree" ]; then
107
+ # ─── New worktree: pick base branch, then name ────────────
108
+
109
+ # List remote branches for base selection
110
+ REMOTE_BRANCHES=$(git --git-dir="$WORK_DIR/.git" for-each-ref \
111
+ --format='%(refname:short)' refs/remotes/origin/ 2>/dev/null \
112
+ | sed 's|^origin/||' \
113
+ | grep -v '^HEAD$' \
114
+ | sort)
115
+
116
+ BASE_BRANCH=$(echo "$REMOTE_BRANCHES" | fzf \
117
+ --height=100% \
118
+ --layout=reverse \
119
+ --border=rounded \
120
+ --border-label=" $PROJECT_NAME — Base Branch " \
121
+ --header="Branch to create worktree from" \
122
+ --header-first \
123
+ --prompt="From: " \
124
+ --pointer="▸" \
125
+ --query="$DEFAULT_BRANCH" \
126
+ --color="$FZF_COLORS")
127
+
128
+ [ -z "$BASE_BRANCH" ] && exit 0
129
+
130
+ # Prompt for worktree/branch name
131
+ WORKTREE_NAME=$(echo "" | fzf --print-query \
132
+ --height=100% \
133
+ --layout=reverse \
134
+ --border=rounded \
135
+ --border-label=" $PROJECT_NAME — Branch Name " \
136
+ --header="From: $BASE_BRANCH" \
137
+ --header-first \
138
+ --prompt="Name: " \
139
+ --pointer="" \
140
+ --no-info \
141
+ --color="$FZF_COLORS" \
142
+ | head -1)
143
+
144
+ [ -z "$WORKTREE_NAME" ] && exit 0
145
+
146
+ # Create session in bare repo dir, split into two panes
147
+ WORKTREE_PATH="$WORK_DIR/$WORKTREE_NAME"
148
+ PARENT_CLIENT=$(tmux display-message -p '#{client_name}' 2>/dev/null)
149
+ # Left pane: run wtm create (fetch, hooks visible here)
150
+ tmux new-session -d -s "$WORKTREE_NAME" -c "$WORK_DIR" \
151
+ "wtm create $WORKTREE_NAME --from $BASE_BRANCH --no-shell; cd $WORKTREE_NAME; exec \$SHELL"
152
+ # Right pane: wait for worktree, then open shell
153
+ tmux split-window -h -d -t "$WORKTREE_NAME" -c "$WORK_DIR" \
154
+ "while [ ! -d '$WORKTREE_PATH' ]; do sleep 0.2; done; cd '$WORKTREE_PATH' && exec \$SHELL"
155
+ # Switch to the session (focus on left pane)
156
+ tmux select-pane -t "$WORKTREE_NAME.0"
157
+ tmux switch-client -c "$PARENT_CLIENT" -t "$WORKTREE_NAME"
158
+ exit 0
159
+ else
160
+ # Existing worktree selected — find its path
161
+ BRANCH_NAME=$(echo "$CHOICE" | sed 's/^ //')
162
+ WORKTREE_PATH=$(git --git-dir="$WORK_DIR/.git" worktree list --porcelain 2>/dev/null \
163
+ | awk -v branch="$BRANCH_NAME" '
164
+ /^worktree / { path = substr($0, 10) }
165
+ /^branch / { b = $0; sub(/branch refs\/heads\//, "", b); if (b == branch) print path }
166
+ ')
167
+
168
+ if [ -z "$WORKTREE_PATH" ]; then
169
+ echo "Could not find worktree path for $BRANCH_NAME"
170
+ sleep 2
171
+ exit 1
172
+ fi
173
+
174
+ WORK_DIR="$WORKTREE_PATH"
175
+ SESSION_NAME="$BRANCH_NAME"
176
+ fi
177
+ else
178
+ # ─── Standard flow: just pick a name ──────────────────────────
179
+
180
+ # Default session name to directory basename
181
+ DEFAULT_NAME=$(basename "$WORK_DIR")
182
+
183
+ SESSION_NAME=$(echo "" | fzf --print-query \
184
+ --height=100% \
185
+ --layout=reverse \
186
+ --border=rounded \
187
+ --border-label=" New Session — Name " \
188
+ --header="Directory: $SELECTED_DIR" \
189
+ --header-first \
190
+ --prompt="Name: " \
191
+ --query="$DEFAULT_NAME" \
192
+ --pointer="" \
193
+ --no-info \
194
+ --color="$FZF_COLORS" \
195
+ | head -1)
196
+
197
+ [ -z "$SESSION_NAME" ] && exit 0
198
+ fi
63
199
 
64
200
  # ─── Create session ───────────────────────────────────────────────────
65
201
 
@@ -0,0 +1,48 @@
1
+ #!/bin/bash
2
+ # Show release notes for a jmux version in a formatted popup
3
+ # Usage: release-notes.sh <tag>
4
+
5
+ TAG="${1:-v0.0.0}"
6
+ REPO="jarredkenny/jmux"
7
+
8
+ # Colors
9
+ BOLD="\033[1m"
10
+ DIM="\033[2m"
11
+ GREEN="\033[32m"
12
+ YELLOW="\033[33m"
13
+ CYAN="\033[36m"
14
+ RESET="\033[0m"
15
+
16
+ # Fetch release data
17
+ BODY=$(gh release view "$TAG" --repo "$REPO" --json body,publishedAt,name -q '"\(.name)\n\(.publishedAt)\n\(.body)"' 2>/dev/null)
18
+
19
+ if [ -z "$BODY" ]; then
20
+ echo -e "\n ${DIM}jmux ${TAG}${RESET}\n"
21
+ echo -e " ${DIM}No release notes available.${RESET}\n"
22
+ echo -e " ${DIM}Press q to close${RESET}"
23
+ read -rsn1
24
+ exit 0
25
+ fi
26
+
27
+ NAME=$(echo "$BODY" | head -1)
28
+ DATE=$(echo "$BODY" | sed -n '2p' | cut -dT -f1)
29
+ NOTES=$(echo "$BODY" | tail -n +3)
30
+
31
+ {
32
+ echo ""
33
+ echo -e " ${BOLD}${GREEN}jmux ${NAME}${RESET}"
34
+ echo -e " ${DIM}Released ${DATE}${RESET}"
35
+ echo ""
36
+ # Format markdown: bold **text**, headers ##, bullet points
37
+ echo "$NOTES" | sed \
38
+ -e "s/^## \(.*\)/ $(printf '\033[1m')\1$(printf '\033[0m')/" \
39
+ -e "s/^- / • /" \
40
+ -e "s/\*\*\([^*]*\)\*\*/$(printf '\033[1m')\1$(printf '\033[0m')/g" \
41
+ -e "s/\`\([^\`]*\)\`/$(printf '\033[36m')\1$(printf '\033[0m')/g" \
42
+ -e '/^$/s/^$//'
43
+ echo ""
44
+ echo -e " ${DIM}https://github.com/${REPO}/releases/tag/${TAG}${RESET}"
45
+ echo ""
46
+ echo -e " ${DIM}Press q to close${RESET}"
47
+ }
48
+ read -rsn1
@@ -0,0 +1,139 @@
1
+ #!/bin/bash
2
+ # jmux settings modal — reads/writes ~/.config/jmux/config.json
3
+ # Called via: display-popup -E "settings.sh"
4
+
5
+ CONFIG_DIR="$HOME/.config/jmux"
6
+ CONFIG_FILE="$CONFIG_DIR/config.json"
7
+ FZF_COLORS="border:#4f565d,header:#b5bcc9,prompt:#9fe8c3,label:#9fe8c3,pointer:#9fe8c3,fg:#6b7280,fg+:#b5bcc9,hl:#fbd4b8,hl+:#fbd4b8"
8
+
9
+ # ─── Helpers ──────────────────────────────────────────────────────────
10
+
11
+ read_config() {
12
+ bun -e "
13
+ const c = await Bun.file('$CONFIG_FILE').json().catch(() => ({}));
14
+ console.log(JSON.stringify(c));
15
+ " 2>/dev/null || echo "{}"
16
+ }
17
+
18
+ write_config() {
19
+ local json="$1"
20
+ mkdir -p "$CONFIG_DIR"
21
+ echo "$json" | bun -e "
22
+ const stdin = await Bun.stdin.text();
23
+ const c = JSON.parse(stdin);
24
+ await Bun.write('$CONFIG_FILE', JSON.stringify(c, null, 2) + '\n');
25
+ "
26
+ }
27
+
28
+ get_value() {
29
+ local json="$1" key="$2" default="$3"
30
+ echo "$json" | bun -e "
31
+ const stdin = await Bun.stdin.text();
32
+ const c = JSON.parse(stdin);
33
+ console.log(c['$key'] ?? '$default');
34
+ " 2>/dev/null || echo "$default"
35
+ }
36
+
37
+ set_value() {
38
+ local json="$1" key="$2" value="$3" type="$4"
39
+ echo "$json" | bun -e "
40
+ const stdin = await Bun.stdin.text();
41
+ const c = JSON.parse(stdin);
42
+ const v = '$value';
43
+ const t = '$type';
44
+ if (t === 'number') c['$key'] = parseInt(v, 10);
45
+ else if (t === 'bool') c['$key'] = v === 'true';
46
+ else if (t === 'array') c['$key'] = v.split(',').map(s => s.trim()).filter(Boolean);
47
+ else c['$key'] = v;
48
+ console.log(JSON.stringify(c));
49
+ " 2>/dev/null
50
+ }
51
+
52
+ # ─── Main loop ────────────────────────────────────────────────────────
53
+
54
+ while true; do
55
+ CONFIG=$(read_config)
56
+
57
+ SIDEBAR_WIDTH=$(get_value "$CONFIG" "sidebarWidth" "26")
58
+ PROJECT_DIRS_RAW=$(echo "$CONFIG" | bun -e "
59
+ const c = JSON.parse(await Bun.stdin.text());
60
+ const dirs = c.projectDirs ?? ['~/Code', '~/Projects', '~/src', '~/work', '~/dev'];
61
+ console.log(dirs.join(', '));
62
+ " 2>/dev/null || echo "~/Code, ~/Projects, ~/src, ~/work, ~/dev")
63
+ WTM_ENABLED=$(get_value "$CONFIG" "wtmIntegration" "true")
64
+
65
+ # Format display
66
+ WTM_DISPLAY="on"
67
+ [ "$WTM_ENABLED" = "false" ] && WTM_DISPLAY="off"
68
+
69
+ SELECTION=$(printf "%s\n%s\n%s" \
70
+ "Sidebar Width $SIDEBAR_WIDTH" \
71
+ "Project Directories $PROJECT_DIRS_RAW" \
72
+ "wtm Integration $WTM_DISPLAY" \
73
+ | fzf \
74
+ --height=100% \
75
+ --layout=reverse \
76
+ --border=rounded \
77
+ --border-label=" Settings " \
78
+ --header="Select a setting to change" \
79
+ --header-first \
80
+ --prompt=" " \
81
+ --pointer="▸" \
82
+ --no-info \
83
+ --color="$FZF_COLORS")
84
+
85
+ [ -z "$SELECTION" ] && exit 0
86
+
87
+ case "$SELECTION" in
88
+ "Sidebar Width"*)
89
+ NEW_WIDTH=$(echo "" | fzf --print-query \
90
+ --height=100% \
91
+ --layout=reverse \
92
+ --border=rounded \
93
+ --border-label=" Sidebar Width " \
94
+ --header="Current: $SIDEBAR_WIDTH (takes effect on restart)" \
95
+ --header-first \
96
+ --prompt="Width: " \
97
+ --query="$SIDEBAR_WIDTH" \
98
+ --pointer="" \
99
+ --no-info \
100
+ --color="$FZF_COLORS" \
101
+ | head -1)
102
+
103
+ if [ -n "$NEW_WIDTH" ]; then
104
+ CONFIG=$(set_value "$CONFIG" "sidebarWidth" "$NEW_WIDTH" "number")
105
+ write_config "$CONFIG"
106
+ fi
107
+ ;;
108
+
109
+ "Project Directories"*)
110
+ NEW_DIRS=$(echo "" | fzf --print-query \
111
+ --height=100% \
112
+ --layout=reverse \
113
+ --border=rounded \
114
+ --border-label=" Project Directories " \
115
+ --header="Comma-separated list of directories to search" \
116
+ --header-first \
117
+ --prompt="Dirs: " \
118
+ --query="$PROJECT_DIRS_RAW" \
119
+ --pointer="" \
120
+ --no-info \
121
+ --color="$FZF_COLORS" \
122
+ | head -1)
123
+
124
+ if [ -n "$NEW_DIRS" ]; then
125
+ CONFIG=$(set_value "$CONFIG" "projectDirs" "$NEW_DIRS" "array")
126
+ write_config "$CONFIG"
127
+ fi
128
+ ;;
129
+
130
+ "wtm Integration"*)
131
+ if [ "$WTM_ENABLED" = "true" ]; then
132
+ CONFIG=$(set_value "$CONFIG" "wtmIntegration" "false" "bool")
133
+ else
134
+ CONFIG=$(set_value "$CONFIG" "wtmIntegration" "true" "bool")
135
+ fi
136
+ write_config "$CONFIG"
137
+ ;;
138
+ esac
139
+ done
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@jx0/jmux",
3
- "version": "0.3.9",
3
+ "version": "0.5.0",
4
4
  "description": "The terminal workspace for agentic development",
5
5
  "type": "module",
6
6
  "bin": {
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.9";
15
+ const VERSION = "0.5.0";
16
16
 
17
17
  const HELP = `jmux — a persistent session sidebar for tmux
18
18
 
@@ -108,9 +108,22 @@ function installAgentHooks(): void {
108
108
 
109
109
  // --- TUI startup ---
110
110
 
111
- const SIDEBAR_WIDTH = 24;
111
+ // Read sidebar width from user config, fall back to default
112
+ function loadUserConfig(): Record<string, any> {
113
+ const configPath = resolve(homedir(), ".config", "jmux", "config.json");
114
+ try {
115
+ if (existsSync(configPath)) {
116
+ return JSON.parse(readFileSync(configPath, "utf-8"));
117
+ }
118
+ } catch {
119
+ // Invalid config — use defaults
120
+ }
121
+ return {};
122
+ }
123
+ const userConfig = loadUserConfig();
124
+ let sidebarWidth = (userConfig.sidebarWidth as number) || 26;
112
125
  const BORDER_WIDTH = 1;
113
- const SIDEBAR_TOTAL = SIDEBAR_WIDTH + BORDER_WIDTH;
126
+ function sidebarTotal(): number { return sidebarWidth + BORDER_WIDTH; }
114
127
 
115
128
  // Resolve paths relative to source
116
129
  const jmuxDir = resolve(dirname(import.meta.dir));
@@ -138,7 +151,7 @@ for (let i = 2; i < process.argv.length; i++) {
138
151
  const cols = process.stdout.columns || 80;
139
152
  const rows = process.stdout.rows || 24;
140
153
  const sidebarVisible = cols >= 80;
141
- const mainCols = sidebarVisible ? cols - SIDEBAR_TOTAL : cols;
154
+ const mainCols = sidebarVisible ? cols - sidebarTotal() : cols;
142
155
 
143
156
  // Enter alternate screen, raw mode, enable mouse tracking
144
157
  process.stdout.write("\x1b[?1049h");
@@ -155,15 +168,17 @@ process.stdin.resume();
155
168
  const pty = new TmuxPty({ sessionName, socketName, configFile, jmuxDir, cols: mainCols, rows });
156
169
  const bridge = new ScreenBridge(mainCols, rows);
157
170
  const renderer = new Renderer();
158
- const sidebar = new Sidebar(SIDEBAR_WIDTH, rows);
171
+ const sidebar = new Sidebar(sidebarWidth, rows);
159
172
  const control = new TmuxControl();
160
173
 
161
174
  let currentSessionId: string | null = null;
162
175
  let ptyClientName: string | null = null;
163
176
  let sidebarShown = sidebarVisible;
164
177
  let currentSessions: SessionInfo[] = [];
178
+
179
+ sidebar.setVersion(VERSION);
165
180
  const lastViewedTimestamps = new Map<string, number>();
166
- const sessionDetailsCache = new Map<string, { directory?: string; gitBranch?: string }>();
181
+ const sessionDetailsCache = new Map<string, { directory?: string; gitBranch?: string; project?: string }>();
167
182
 
168
183
  function switchByOffset(offset: number): void {
169
184
  const ids = sidebar.getDisplayOrderIds();
@@ -303,12 +318,16 @@ function clearSessionIndicators(): void {
303
318
 
304
319
  const inputRouter = new InputRouter(
305
320
  {
306
- sidebarCols: SIDEBAR_WIDTH,
321
+ sidebarCols: sidebarWidth,
307
322
  onPtyData: (data) => {
308
323
  pty.write(data);
309
324
  clearSessionIndicators();
310
325
  },
311
326
  onSidebarClick: (row) => {
327
+ if (sidebar.isVersionRow(row)) {
328
+ showVersionInfo();
329
+ return;
330
+ }
312
331
  const session = sidebar.getSessionByRow(row);
313
332
  if (session) switchSession(session.id);
314
333
  },
@@ -393,15 +412,76 @@ process.on("SIGWINCH", () => {
393
412
  const newCols = process.stdout.columns || 80;
394
413
  const newRows = process.stdout.rows || 24;
395
414
  const newSidebarVisible = newCols >= 80;
396
- const newMainCols = newSidebarVisible ? newCols - SIDEBAR_TOTAL : newCols;
415
+ const newMainCols = newSidebarVisible ? newCols - sidebarTotal() : newCols;
397
416
 
398
417
  sidebarShown = newSidebarVisible;
399
418
  inputRouter.setSidebarVisible(newSidebarVisible);
400
419
  pty.resize(newMainCols, newRows);
401
420
  bridge.resize(newMainCols, newRows);
402
- sidebar.resize(SIDEBAR_WIDTH, newRows);
421
+ sidebar.resize(sidebarWidth, newRows);
403
422
  });
404
423
 
424
+ // --- Config file watcher ---
425
+
426
+ const configPath = resolve(homedir(), ".config", "jmux", "config.json");
427
+ try {
428
+ const { watch } = await import("fs");
429
+ watch(configPath, () => {
430
+ const updated = loadUserConfig();
431
+ const newWidth = (updated.sidebarWidth as number) || 26;
432
+ if (newWidth !== sidebarWidth) {
433
+ sidebarWidth = newWidth;
434
+ const cols = process.stdout.columns || 80;
435
+ const rows = process.stdout.rows || 24;
436
+ const newSidebarVisible = cols >= 80;
437
+ const newMainCols = newSidebarVisible ? cols - sidebarTotal() : cols;
438
+
439
+ sidebarShown = newSidebarVisible;
440
+ inputRouter.setSidebarVisible(newSidebarVisible);
441
+ pty.resize(newMainCols, rows);
442
+ bridge.resize(newMainCols, rows);
443
+ sidebar.resize(sidebarWidth, rows);
444
+ }
445
+ });
446
+ } catch {
447
+ // Config file may not exist yet — watcher will fail silently
448
+ }
449
+
450
+ // --- Update check ---
451
+
452
+ async function checkForUpdates(): Promise<void> {
453
+ try {
454
+ const resp = await fetch(
455
+ "https://api.github.com/repos/jarredkenny/jmux/releases/latest",
456
+ { headers: { "Accept": "application/vnd.github.v3+json" } },
457
+ );
458
+ if (!resp.ok) return;
459
+ const data = await resp.json() as { tag_name?: string };
460
+ const latest = data.tag_name?.replace(/^v/, "");
461
+ if (latest && latest !== VERSION) {
462
+ sidebar.setVersion(VERSION, latest);
463
+ scheduleRender();
464
+ }
465
+ } catch {
466
+ // Offline or rate-limited — no problem
467
+ }
468
+ }
469
+
470
+ async function showVersionInfo(): Promise<void> {
471
+ if (!ptyClientName) await resolveClientName();
472
+ if (!ptyClientName) return;
473
+ const tag = `v${VERSION}`;
474
+ const cmd = `${jmuxDir}/config/release-notes.sh ${tag}`;
475
+ // Use tmux CLI directly — confirmed working from terminal tests
476
+ const args = ["tmux"];
477
+ if (socketName) args.push("-L", socketName);
478
+ args.push("display-popup", "-c", ptyClientName, "-E", "-w", "70%", "-h", "40%", "-b", "heavy", "-S", "fg=#4f565d", "sh", "-c", cmd);
479
+ Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
480
+ }
481
+
482
+ // Check for updates in the background (non-blocking)
483
+ checkForUpdates();
484
+
405
485
  // --- Control mode events ---
406
486
 
407
487
  control.onEvent((event: ControlEvent) => {
@@ -446,10 +526,31 @@ async function lookupSessionDetails(sessions: SessionInfo[]): Promise<void> {
446
526
  .catch(() => "");
447
527
  const gitBranch = branch.trim() || undefined;
448
528
 
529
+ // Detect wtm worktree — .git is a file pointing to a bare repo
530
+ let project: string | undefined;
531
+ try {
532
+ const commonDir = await $`git -C ${cwd} rev-parse --git-common-dir`
533
+ .text()
534
+ .catch(() => "");
535
+ const gitDir = await $`git -C ${cwd} rev-parse --git-dir`
536
+ .text()
537
+ .catch(() => "");
538
+ if (commonDir.trim() && gitDir.trim() && commonDir.trim() !== gitDir.trim()) {
539
+ // In a worktree — commonDir points to the bare repo's .git
540
+ // Bare repo structure: /path/to/project/.git → project name is parent dir basename
541
+ const resolved = resolve(cwd, commonDir.trim());
542
+ const bareRoot = dirname(resolved);
543
+ project = bareRoot.split("/").pop();
544
+ }
545
+ } catch {
546
+ // Not a worktree
547
+ }
548
+
449
549
  // Write to persistent cache
450
- sessionDetailsCache.set(session.id, { directory, gitBranch });
550
+ sessionDetailsCache.set(session.id, { directory, gitBranch, project });
451
551
  session.directory = directory;
452
552
  session.gitBranch = gitBranch;
553
+ session.project = project;
453
554
  } catch {
454
555
  // Session may not exist or no git repo
455
556
  }
package/src/sidebar.ts CHANGED
@@ -98,12 +98,11 @@ function buildRenderPlan(sessions: SessionInfo[]): {
98
98
  const ungrouped: number[] = [];
99
99
 
100
100
  for (let i = 0; i < sessions.length; i++) {
101
- const dir = sessions[i].directory;
102
- if (!dir) {
103
- ungrouped.push(i);
104
- continue;
105
- }
106
- const label = getGroupLabel(dir);
101
+ // Prefer project name (wtm) over directory-based grouping
102
+ const label = sessions[i].project ?? (() => {
103
+ const dir = sessions[i].directory;
104
+ return dir ? getGroupLabel(dir) : null;
105
+ })();
107
106
  if (!label) {
108
107
  ungrouped.push(i);
109
108
  continue;
@@ -162,6 +161,11 @@ function itemHeight(item: RenderItem): number {
162
161
 
163
162
  // --- Sidebar class ---
164
163
 
164
+ const UPDATE_AVAILABLE_ATTRS: CellAttrs = {
165
+ fg: 3,
166
+ fgMode: ColorMode.Palette,
167
+ };
168
+
165
169
  export class Sidebar {
166
170
  private width: number;
167
171
  private height: number;
@@ -172,6 +176,8 @@ export class Sidebar {
172
176
  private rowToSessionIndex = new Map<number, number>();
173
177
  private activitySet = new Set<string>();
174
178
  private scrollOffset = 0;
179
+ private currentVersion: string = "";
180
+ private latestVersion: string | null = null;
175
181
 
176
182
  constructor(width: number, height: number) {
177
183
  this.width = width;
@@ -213,6 +219,19 @@ export class Sidebar {
213
219
  .filter(Boolean) as string[];
214
220
  }
215
221
 
222
+ setVersion(current: string, latest?: string): void {
223
+ this.currentVersion = current;
224
+ this.latestVersion = latest ?? null;
225
+ }
226
+
227
+ hasUpdate(): boolean {
228
+ return this.latestVersion !== null && this.latestVersion !== this.currentVersion;
229
+ }
230
+
231
+ isVersionRow(row: number): boolean {
232
+ return this.currentVersion !== "" && row === this.height - 1;
233
+ }
234
+
216
235
  getSessionByRow(row: number): SessionInfo | null {
217
236
  const sessionIdx = this.rowToSessionIndex.get(row);
218
237
  if (sessionIdx === undefined) return null;
@@ -232,7 +251,7 @@ export class Sidebar {
232
251
 
233
252
  scrollToActive(): void {
234
253
  if (!this.activeSessionId) return;
235
- const viewportHeight = this.height - HEADER_ROWS;
254
+ const viewportHeight = this.viewportHeight();
236
255
  let vRow = 0;
237
256
  for (const item of this.items) {
238
257
  const h = itemHeight(item);
@@ -252,10 +271,17 @@ export class Sidebar {
252
271
  }
253
272
  }
254
273
 
274
+ private footerRows(): number {
275
+ return this.currentVersion ? 1 : 0;
276
+ }
277
+
278
+ private viewportHeight(): number {
279
+ return this.height - HEADER_ROWS - this.footerRows();
280
+ }
281
+
255
282
  private clampScroll(): void {
256
283
  const totalRows = this.items.reduce((sum, item) => sum + itemHeight(item), 0);
257
- const viewportHeight = this.height - HEADER_ROWS;
258
- const maxOffset = Math.max(0, totalRows - viewportHeight);
284
+ const maxOffset = Math.max(0, totalRows - this.viewportHeight());
259
285
  this.scrollOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset));
260
286
  }
261
287
 
@@ -267,7 +293,8 @@ export class Sidebar {
267
293
  writeString(grid, 0, 1, "jmux", { ...ACCENT_ATTRS, bold: true });
268
294
  writeString(grid, 1, 0, "\u2500".repeat(this.width), DIM_ATTRS);
269
295
 
270
- const viewportHeight = this.height - HEADER_ROWS;
296
+ const vpHeight = this.viewportHeight();
297
+ const contentBottom = HEADER_ROWS + vpHeight;
271
298
  let vRow = 0;
272
299
  let totalRows = 0;
273
300
 
@@ -282,7 +309,7 @@ export class Sidebar {
282
309
  continue;
283
310
  }
284
311
  // Track total rows even after viewport
285
- if (screenRow >= this.height) {
312
+ if (screenRow >= contentBottom) {
286
313
  vRow += h;
287
314
  totalRows += h;
288
315
  continue;
@@ -308,8 +335,23 @@ export class Sidebar {
308
335
  if (this.scrollOffset > 0) {
309
336
  writeString(grid, HEADER_ROWS, this.width - 1, "\u25b2", DIM_ATTRS);
310
337
  }
311
- if (this.scrollOffset + viewportHeight < totalRows) {
312
- writeString(grid, this.height - 1, this.width - 1, "\u25bc", DIM_ATTRS);
338
+ if (this.scrollOffset + vpHeight < totalRows) {
339
+ const scrollRow = this.footerRows() ? contentBottom - 1 : this.height - 1;
340
+ writeString(grid, scrollRow, this.width - 1, "\u25bc", DIM_ATTRS);
341
+ }
342
+
343
+ // Version footer
344
+ if (this.currentVersion) {
345
+ const footerRow = this.height - 1;
346
+ const versionText = `v${this.currentVersion}`;
347
+ if (this.hasUpdate()) {
348
+ const updateText = `v${this.latestVersion} avail`;
349
+ const maxLen = this.width - 2;
350
+ const display = updateText.length <= maxLen ? updateText : `v${this.latestVersion}`;
351
+ writeString(grid, footerRow, 1, display, UPDATE_AVAILABLE_ATTRS);
352
+ } else {
353
+ writeString(grid, footerRow, 1, versionText, DIM_ATTRS);
354
+ }
313
355
  }
314
356
 
315
357
  return grid;
package/src/types.ts CHANGED
@@ -36,4 +36,5 @@ export interface SessionInfo {
36
36
  attention: boolean;
37
37
  windowCount: number;
38
38
  directory?: string;
39
+ project?: string; // wtm project name (bare repo basename)
39
40
  }