@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 +49 -30
- package/config/defaults.conf +3 -0
- package/config/new-session.sh +161 -25
- package/config/release-notes.sh +48 -0
- package/config/settings.sh +139 -0
- package/package.json +1 -1
- package/src/main.ts +111 -10
- package/src/sidebar.ts +55 -13
- package/src/types.ts +1 -0
package/README.md
CHANGED
|
@@ -4,12 +4,12 @@
|
|
|
4
4
|
|
|
5
5
|
**The terminal workspace for agentic development.**
|
|
6
6
|
|
|
7
|
-
|
|
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
|
[](https://www.npmjs.com/package/@jx0/jmux)
|
|
10
10
|
[](LICENSE)
|
|
11
11
|
|
|
12
|
-

|
|
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
|
-
|
|
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
|
|
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 `▎`
|
|
49
|
+
- Green `▎` marker + highlighted background on the active session
|
|
40
50
|
- Green `●` dot for sessions with new output
|
|
41
|
-
- Orange `!` flag for attention (
|
|
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
|
-
###
|
|
65
|
+
### Bring Your Own Everything
|
|
52
66
|
|
|
53
|
-
|
|
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
|
-
|
|
69
|
+
Use any editor. Any Git tool. Any AI agent. Any shell. jmux doesn't replace your tools — it organizes them.
|
|
56
70
|
|
|
57
|
-
###
|
|
71
|
+
### Works Great With
|
|
58
72
|
|
|
59
|
-
|
|
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
|
-
###
|
|
78
|
+
### Agent Integration
|
|
62
79
|
|
|
63
|
-
Built for
|
|
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
|
-
|
|
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-
|
|
101
|
-
| `Ctrl-a
|
|
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
|
|
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
|
|
119
|
-
~/.tmux.conf
|
|
120
|
-
config/core.conf
|
|
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
|
|
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
|
-
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
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
|
-
~
|
|
160
|
+
~1800 lines of TypeScript. No opinions about what you run inside tmux.
|
|
142
161
|
|
|
143
162
|
---
|
|
144
163
|
|
package/config/defaults.conf
CHANGED
|
@@ -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
|
|
package/config/new-session.sh
CHANGED
|
@@ -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
|
-
#
|
|
44
|
-
DEFAULT_NAME=$(basename "$WORK_DIR")
|
|
53
|
+
# ─── Step 1.5: Detect wtm bare repo ──────────────────────────────────
|
|
45
54
|
|
|
46
|
-
#
|
|
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
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
--
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
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.
|
|
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
|
-
|
|
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
|
-
|
|
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 -
|
|
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(
|
|
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:
|
|
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 -
|
|
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(
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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.
|
|
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
|
|
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
|
|
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 >=
|
|
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 +
|
|
312
|
-
|
|
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;
|