@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 CHANGED
@@ -1,142 +1,95 @@
1
+ <div align="center">
2
+
1
3
  # jmux
2
4
 
3
- A persistent session sidebar for tmux. See every project at a glance, switch instantly, never lose context.
5
+ **The terminal workspace for agentic development.**
4
6
 
5
- ![jmux sidebar with grouped sessions alongside vim and server logs](docs/screenshots/hero.png)
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
+ [![npm](https://img.shields.io/npm/v/@jx0/jmux)](https://www.npmjs.com/package/@jx0/jmux)
10
+ [![license](https://img.shields.io/github/license/jarredkenny/jmux)](LICENSE)
8
11
 
9
- ## The Problem
12
+ ![jmux sidebar with grouped sessions alongside vim and server logs](docs/screenshots/hero.png)
10
13
 
11
- You have 30+ tmux sessions. Each one is a project, a context, a train of thought. But tmux gives you a flat list and a status bar that shows one session name. To switch, you `prefix-s`, scan a wall of text, find the one you want, and hope you remember which window you were in.
14
+ </div>
12
15
 
13
- You lose context constantly. You forget what's running where. You can't see at a glance which sessions have new output or which ones need attention.
16
+ ## Install
14
17
 
15
- ## The Solution
18
+ ```bash
19
+ bun install -g @jx0/jmux
20
+ jmux
21
+ ```
16
22
 
17
- jmux wraps tmux with a persistent sidebar that shows all your sessions, all the time. It works with your existing `~/.tmux.conf` your plugins, your colors, your keybindings — and layers a sidebar on top.
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
- **What you get:**
20
- - Every session visible at all times with git branch and window count
21
- - Sessions grouped by project directory — related work stays together
22
- - Instant switching with `Ctrl-Shift-Up/Down` — no prefix, no menu, no delay
23
- - Activity indicators (green dot) and attention flags (orange `!`) for agentic workflows
24
- - Mouse click to switch sessions
25
- - New session modal with fuzzy directory picker
26
- - Bring your own `~/.tmux.conf` — your plugins and keybindings just work
25
+ ---
27
26
 
28
- ![jmux sidebar alongside vim with split panes and a dev server](docs/screenshots/blog.png)
27
+ ## Why
29
28
 
30
- ## How It Works
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 owns the terminal. It spawns tmux in a PTY, feeds the output through a headless terminal emulator ([xterm.js](https://xtermjs.org/)), and composites a 24-column sidebar alongside the tmux rendering. A separate tmux control mode connection provides real-time session metadata via push notifications.
31
+ jmux fixes this with a persistent sidebar that shows every session, all the time.
33
32
 
34
- jmux sources your `~/.tmux.conf` first, then layers its own defaults and the few settings the sidebar requires. Your existing setup carries over — jmux just adds the sidebar.
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
- ### Sidebar Features
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
- **Session grouping** Sessions that share a parent directory are automatically grouped under a header. `~/Code/work/api` and `~/Code/work/web` group under `Code/work`. Solo sessions render ungrouped.
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
- **Two-line entries** — Each session shows its name and window count on the first line, git branch on the second. Grouped sessions inherit directory context from the group header. Ungrouped sessions show their directory path.
43
+ ### Instant Switching
62
44
 
63
- **Visual indicators:**
64
- - Green `▎` left marker — active session
65
- - Green `●` dot — new output since you last viewed that session
66
- - Orange `!` flag — attention needed (set programmatically)
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
- ## Installation
47
+ ### New Session Modal
69
48
 
70
- ```bash
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
- # Run
75
- jmux
76
- ```
77
-
78
- ### Requirements
51
+ ### Window Picker
79
52
 
80
- - [Bun](https://bun.sh) 1.2+
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
- ### Usage
55
+ ![fzf window picker popup](docs/screenshots/window-picker.png)
86
56
 
87
- ```bash
88
- # Start jmux (creates or attaches to default session)
89
- jmux
57
+ ### Bring Your Own Config
90
58
 
91
- # Start with a named session
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
- # Use a separate tmux server (won't touch your existing sessions)
95
- jmux -L work
96
- ```
61
+ ### Claude Code Integration
97
62
 
98
- ### From Source
63
+ Built for agentic workflows. Run Claude Code in multiple sessions and get notified when each one finishes.
99
64
 
100
65
  ```bash
101
- git clone https://github.com/jarredkenny/jmux.git
102
- cd jmux
103
- bun install
104
- bun run bin/jmux
66
+ jmux --install-agent-hooks
105
67
  ```
106
68
 
107
- ## New Session Modal
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
- 1. **Pick a directory** — fuzzy search over all git repos found under `~/Code`, `~/Projects`, `~/src`, `~/work`, and `~/dev`. Start typing to narrow down instantly.
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
- ### Session Navigation (always active)
75
+ ### Sessions
120
76
 
121
77
  | Key | Action |
122
78
  |-----|--------|
123
- | `Ctrl-Shift-Up` | Switch to previous session |
124
- | `Ctrl-Shift-Down` | Switch to next session |
125
- | `Ctrl-a n` | New session (directory picker + name) |
126
- | Click sidebar | Switch to that session |
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-Right` / `Ctrl-Left` | Next / previous window |
135
- | `Ctrl-Shift-Right` / `Ctrl-Shift-Left` | Reorder windows |
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
- ![fzf window picker popup](docs/screenshots/window-picker.png)
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 screen and scrollback |
156
- | `Ctrl-a y` | Copy entire pane to clipboard |
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
- 1. Start jmux
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
- jmux loads config in three layers:
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 requirements (always wins)
120
+ config/core.conf ← jmux core (always wins)
196
121
  ```
197
122
 
198
- jmux defaults are applied first as a baseline. Your `~/.tmux.conf` is sourced next anything you set overrides the defaults. Finally, the core settings the sidebar depends on are applied last and cannot be overridden.
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
- **Core settings** (cannot be overridden):
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
- Everything else is yours to customize. See [docs/configuration.md](docs/configuration.md) for details.
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, navigation
214
- ├── Border (1 col) ──── vertical separator
134
+ ├── Sidebar (24 cols) ── session groups, indicators
135
+ ├── Border (1 col)
215
136
  └── tmux PTY (remaining cols)
216
- ├── PTY client ──── spawns tmux, feeds output through @xterm/headless
217
- └── Control client ─ tmux -C for real-time session metadata
137
+ ├── PTY client ──── @xterm/headless for VT emulation
138
+ └── Control client ─ tmux -C for real-time metadata
218
139
  ```
219
140
 
220
- jmux is ~1500 lines of TypeScript. It has no opinions about what you run inside tmux.
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)
@@ -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:"
@@ -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 (real projects)
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
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@jx0/jmux",
3
- "version": "0.3.0",
4
- "description": "A persistent session sidebar for tmux",
3
+ "version": "0.3.2",
4
+ "description": "The terminal workspace for agentic development",
5
5
  "type": "module",
6
6
  "bin": {
7
7
  "jmux": "./bin/jmux"
@@ -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[3][i].char,
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 but not directory on detail line", () => {
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
- // Find the detail row for "api" (row after "api" name row)
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[4][i].char,
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: first session name row api
213
- expect(sidebar.getSessionByRow(3)?.name).toBe("api");
214
- // Row 4: first session detail row → api
211
+ // Row 3: spacernull
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.0";
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
- session.directory = cwd.startsWith(home)
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
- session.gitBranch = branch.trim() || undefined;
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
- sidebar.updateSessions(sessions);
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 getParentLabel(dir: string): string | null {
49
- const lastSlash = dir.lastIndexOf("/");
50
- if (lastSlash <= 0) return null;
51
- const parent = dir.slice(0, lastSlash);
52
- const segments = parent.split("/").filter((s) => s.length > 0);
53
- if (segments.length === 0) return null;
54
- if (segments[0] === "~" && segments.length === 1) return null;
55
- return segments.slice(-2).join("/");
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 = getParentLabel(dir);
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
- // Map rows to session for click handling
218
- this.rowToSessionIndex.set(nameRow, sessionIdx);
219
- if (detailRow < this.height) {
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
- // Active marker
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
- writeString(grid, detailRow, 0, "\u258e", ACTIVE_MARKER_ATTRS);
242
+ this.rowToSessionIndex.set(detailRow, sessionIdx);
228
243
  }
229
- }
230
244
 
231
- // Indicator
232
- if (session.attention) {
233
- writeString(grid, nameRow, 1, "!", ATTENTION_ATTRS);
234
- } else if (hasActivity) {
235
- writeString(grid, nameRow, 1, "\u25CF", ACTIVITY_ATTRS);
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
- // Window count right-aligned
239
- const windowCountStr = `${session.windowCount}w`;
240
- const windowCountCol = this.width - windowCountStr.length - 1;
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
- // Session name
243
- const nameStart = 3;
244
- const nameMaxLen = windowCountCol - 1 - nameStart;
245
- let displayName = session.name;
246
- if (displayName.length > nameMaxLen) {
247
- displayName = displayName.slice(0, nameMaxLen - 1) + "\u2026";
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
- const nameAttrs: CellAttrs = isActive
251
- ? { ...ACTIVE_NAME_ATTRS }
252
- : { ...INACTIVE_NAME_ATTRS };
253
- writeString(grid, nameRow, nameStart, displayName, nameAttrs);
267
+ const nameAttrs: CellAttrs = isActive
268
+ ? { ...ACTIVE_NAME_ATTRS }
269
+ : { ...INACTIVE_NAME_ATTRS };
270
+ writeString(grid, nameRow, nameStart, displayName, nameAttrs);
254
271
 
255
- if (windowCountCol > nameStart) {
256
- writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
257
- }
272
+ if (windowCountCol > nameStart) {
273
+ writeString(grid, nameRow, windowCountCol, windowCountStr, DIM_ATTRS);
274
+ }
258
275
 
259
- // Detail line
260
- if (detailRow < this.height) {
261
- const detailStart = 3;
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
- if (item.grouped) {
264
- if (session.gitBranch) {
265
- const branchCol = this.width - session.gitBranch.length - 1;
266
- if (branchCol > detailStart) {
267
- writeString(grid, detailRow, branchCol, session.gitBranch, DIM_ATTRS);
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
- } else {
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
- row += 2;
349
+ row += 2;
350
+ }
291
351
  }
292
352
 
293
353
  return grid;
@@ -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",