@jx0/jmux 0.4.0 → 0.5.1

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
@@ -125,6 +125,7 @@ When Claude Code finishes a response, the orange `!` appears on that session in
125
125
  |-----|--------|
126
126
  | `Ctrl-a k` | Clear pane + scrollback |
127
127
  | `Ctrl-a y` | Copy pane to clipboard |
128
+ | `Ctrl-a i` | Settings |
128
129
 
129
130
  ---
130
131
 
@@ -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
 
@@ -6,14 +6,24 @@ FZF_COLORS="border:#4f565d,header:#b5bcc9,prompt:#9fe8c3,label:#9fe8c3,pointer:#
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)
@@ -44,7 +54,11 @@ WORK_DIR="${SELECTED_DIR/#\~/$HOME}"
44
54
 
45
55
  # Check if this is a bare repo (wtm-managed) and wtm is available
46
56
  IS_BARE=false
47
- if command -v wtm &>/dev/null && [ -f "$WORK_DIR/.git/config" ]; then
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
48
62
  if git --git-dir="$WORK_DIR/.git" config --get core.bare 2>/dev/null | grep -q "true"; then
49
63
  IS_BARE=true
50
64
  fi
@@ -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.4.0",
3
+ "version": "0.5.1",
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.4.0";
15
+ const VERSION = "0.5.1";
16
16
 
17
17
  const HELP = `jmux — a persistent session sidebar for tmux
18
18
 
@@ -48,6 +48,12 @@ if (process.argv.includes("-v") || process.argv.includes("--version")) {
48
48
  process.exit(0);
49
49
  }
50
50
 
51
+ if (process.env.JMUX) {
52
+ console.error("Already running inside jmux.");
53
+ process.exit(1);
54
+ }
55
+ process.env.JMUX = "1";
56
+
51
57
  if (process.argv.includes("--install-agent-hooks")) {
52
58
  installAgentHooks();
53
59
  process.exit(0);
@@ -108,9 +114,22 @@ function installAgentHooks(): void {
108
114
 
109
115
  // --- TUI startup ---
110
116
 
111
- const SIDEBAR_WIDTH = 26;
117
+ // Read sidebar width from user config, fall back to default
118
+ function loadUserConfig(): Record<string, any> {
119
+ const configPath = resolve(homedir(), ".config", "jmux", "config.json");
120
+ try {
121
+ if (existsSync(configPath)) {
122
+ return JSON.parse(readFileSync(configPath, "utf-8"));
123
+ }
124
+ } catch {
125
+ // Invalid config — use defaults
126
+ }
127
+ return {};
128
+ }
129
+ const userConfig = loadUserConfig();
130
+ let sidebarWidth = (userConfig.sidebarWidth as number) || 26;
112
131
  const BORDER_WIDTH = 1;
113
- const SIDEBAR_TOTAL = SIDEBAR_WIDTH + BORDER_WIDTH;
132
+ function sidebarTotal(): number { return sidebarWidth + BORDER_WIDTH; }
114
133
 
115
134
  // Resolve paths relative to source
116
135
  const jmuxDir = resolve(dirname(import.meta.dir));
@@ -138,7 +157,7 @@ for (let i = 2; i < process.argv.length; i++) {
138
157
  const cols = process.stdout.columns || 80;
139
158
  const rows = process.stdout.rows || 24;
140
159
  const sidebarVisible = cols >= 80;
141
- const mainCols = sidebarVisible ? cols - SIDEBAR_TOTAL : cols;
160
+ const mainCols = sidebarVisible ? cols - sidebarTotal() : cols;
142
161
 
143
162
  // Enter alternate screen, raw mode, enable mouse tracking
144
163
  process.stdout.write("\x1b[?1049h");
@@ -155,13 +174,15 @@ process.stdin.resume();
155
174
  const pty = new TmuxPty({ sessionName, socketName, configFile, jmuxDir, cols: mainCols, rows });
156
175
  const bridge = new ScreenBridge(mainCols, rows);
157
176
  const renderer = new Renderer();
158
- const sidebar = new Sidebar(SIDEBAR_WIDTH, rows);
177
+ const sidebar = new Sidebar(sidebarWidth, rows);
159
178
  const control = new TmuxControl();
160
179
 
161
180
  let currentSessionId: string | null = null;
162
181
  let ptyClientName: string | null = null;
163
182
  let sidebarShown = sidebarVisible;
164
183
  let currentSessions: SessionInfo[] = [];
184
+
185
+ sidebar.setVersion(VERSION);
165
186
  const lastViewedTimestamps = new Map<string, number>();
166
187
  const sessionDetailsCache = new Map<string, { directory?: string; gitBranch?: string; project?: string }>();
167
188
 
@@ -303,12 +324,16 @@ function clearSessionIndicators(): void {
303
324
 
304
325
  const inputRouter = new InputRouter(
305
326
  {
306
- sidebarCols: SIDEBAR_WIDTH,
327
+ sidebarCols: sidebarWidth,
307
328
  onPtyData: (data) => {
308
329
  pty.write(data);
309
330
  clearSessionIndicators();
310
331
  },
311
332
  onSidebarClick: (row) => {
333
+ if (sidebar.isVersionRow(row)) {
334
+ showVersionInfo();
335
+ return;
336
+ }
312
337
  const session = sidebar.getSessionByRow(row);
313
338
  if (session) switchSession(session.id);
314
339
  },
@@ -393,15 +418,76 @@ process.on("SIGWINCH", () => {
393
418
  const newCols = process.stdout.columns || 80;
394
419
  const newRows = process.stdout.rows || 24;
395
420
  const newSidebarVisible = newCols >= 80;
396
- const newMainCols = newSidebarVisible ? newCols - SIDEBAR_TOTAL : newCols;
421
+ const newMainCols = newSidebarVisible ? newCols - sidebarTotal() : newCols;
397
422
 
398
423
  sidebarShown = newSidebarVisible;
399
424
  inputRouter.setSidebarVisible(newSidebarVisible);
400
425
  pty.resize(newMainCols, newRows);
401
426
  bridge.resize(newMainCols, newRows);
402
- sidebar.resize(SIDEBAR_WIDTH, newRows);
427
+ sidebar.resize(sidebarWidth, newRows);
403
428
  });
404
429
 
430
+ // --- Config file watcher ---
431
+
432
+ const configPath = resolve(homedir(), ".config", "jmux", "config.json");
433
+ try {
434
+ const { watch } = await import("fs");
435
+ watch(configPath, () => {
436
+ const updated = loadUserConfig();
437
+ const newWidth = (updated.sidebarWidth as number) || 26;
438
+ if (newWidth !== sidebarWidth) {
439
+ sidebarWidth = newWidth;
440
+ const cols = process.stdout.columns || 80;
441
+ const rows = process.stdout.rows || 24;
442
+ const newSidebarVisible = cols >= 80;
443
+ const newMainCols = newSidebarVisible ? cols - sidebarTotal() : cols;
444
+
445
+ sidebarShown = newSidebarVisible;
446
+ inputRouter.setSidebarVisible(newSidebarVisible);
447
+ pty.resize(newMainCols, rows);
448
+ bridge.resize(newMainCols, rows);
449
+ sidebar.resize(sidebarWidth, rows);
450
+ }
451
+ });
452
+ } catch {
453
+ // Config file may not exist yet — watcher will fail silently
454
+ }
455
+
456
+ // --- Update check ---
457
+
458
+ async function checkForUpdates(): Promise<void> {
459
+ try {
460
+ const resp = await fetch(
461
+ "https://api.github.com/repos/jarredkenny/jmux/releases/latest",
462
+ { headers: { "Accept": "application/vnd.github.v3+json" } },
463
+ );
464
+ if (!resp.ok) return;
465
+ const data = await resp.json() as { tag_name?: string };
466
+ const latest = data.tag_name?.replace(/^v/, "");
467
+ if (latest && latest !== VERSION) {
468
+ sidebar.setVersion(VERSION, latest);
469
+ scheduleRender();
470
+ }
471
+ } catch {
472
+ // Offline or rate-limited — no problem
473
+ }
474
+ }
475
+
476
+ async function showVersionInfo(): Promise<void> {
477
+ if (!ptyClientName) await resolveClientName();
478
+ if (!ptyClientName) return;
479
+ const tag = `v${VERSION}`;
480
+ const cmd = `${jmuxDir}/config/release-notes.sh ${tag}`;
481
+ // Use tmux CLI directly — confirmed working from terminal tests
482
+ const args = ["tmux"];
483
+ if (socketName) args.push("-L", socketName);
484
+ args.push("display-popup", "-c", ptyClientName, "-E", "-w", "70%", "-h", "40%", "-b", "heavy", "-S", "fg=#4f565d", "sh", "-c", cmd);
485
+ Bun.spawn(args, { stdout: "ignore", stderr: "ignore" });
486
+ }
487
+
488
+ // Check for updates in the background (non-blocking)
489
+ checkForUpdates();
490
+
405
491
  // --- Control mode events ---
406
492
 
407
493
  control.onEvent((event: ControlEvent) => {
package/src/sidebar.ts CHANGED
@@ -161,6 +161,11 @@ function itemHeight(item: RenderItem): number {
161
161
 
162
162
  // --- Sidebar class ---
163
163
 
164
+ const UPDATE_AVAILABLE_ATTRS: CellAttrs = {
165
+ fg: 3,
166
+ fgMode: ColorMode.Palette,
167
+ };
168
+
164
169
  export class Sidebar {
165
170
  private width: number;
166
171
  private height: number;
@@ -171,6 +176,8 @@ export class Sidebar {
171
176
  private rowToSessionIndex = new Map<number, number>();
172
177
  private activitySet = new Set<string>();
173
178
  private scrollOffset = 0;
179
+ private currentVersion: string = "";
180
+ private latestVersion: string | null = null;
174
181
 
175
182
  constructor(width: number, height: number) {
176
183
  this.width = width;
@@ -212,6 +219,19 @@ export class Sidebar {
212
219
  .filter(Boolean) as string[];
213
220
  }
214
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
+
215
235
  getSessionByRow(row: number): SessionInfo | null {
216
236
  const sessionIdx = this.rowToSessionIndex.get(row);
217
237
  if (sessionIdx === undefined) return null;
@@ -231,7 +251,7 @@ export class Sidebar {
231
251
 
232
252
  scrollToActive(): void {
233
253
  if (!this.activeSessionId) return;
234
- const viewportHeight = this.height - HEADER_ROWS;
254
+ const viewportHeight = this.viewportHeight();
235
255
  let vRow = 0;
236
256
  for (const item of this.items) {
237
257
  const h = itemHeight(item);
@@ -251,10 +271,17 @@ export class Sidebar {
251
271
  }
252
272
  }
253
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
+
254
282
  private clampScroll(): void {
255
283
  const totalRows = this.items.reduce((sum, item) => sum + itemHeight(item), 0);
256
- const viewportHeight = this.height - HEADER_ROWS;
257
- const maxOffset = Math.max(0, totalRows - viewportHeight);
284
+ const maxOffset = Math.max(0, totalRows - this.viewportHeight());
258
285
  this.scrollOffset = Math.max(0, Math.min(maxOffset, this.scrollOffset));
259
286
  }
260
287
 
@@ -266,7 +293,8 @@ export class Sidebar {
266
293
  writeString(grid, 0, 1, "jmux", { ...ACCENT_ATTRS, bold: true });
267
294
  writeString(grid, 1, 0, "\u2500".repeat(this.width), DIM_ATTRS);
268
295
 
269
- const viewportHeight = this.height - HEADER_ROWS;
296
+ const vpHeight = this.viewportHeight();
297
+ const contentBottom = HEADER_ROWS + vpHeight;
270
298
  let vRow = 0;
271
299
  let totalRows = 0;
272
300
 
@@ -281,7 +309,7 @@ export class Sidebar {
281
309
  continue;
282
310
  }
283
311
  // Track total rows even after viewport
284
- if (screenRow >= this.height) {
312
+ if (screenRow >= contentBottom) {
285
313
  vRow += h;
286
314
  totalRows += h;
287
315
  continue;
@@ -307,8 +335,23 @@ export class Sidebar {
307
335
  if (this.scrollOffset > 0) {
308
336
  writeString(grid, HEADER_ROWS, this.width - 1, "\u25b2", DIM_ATTRS);
309
337
  }
310
- if (this.scrollOffset + viewportHeight < totalRows) {
311
- 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
+ }
312
355
  }
313
356
 
314
357
  return grid;