@jx0/jmux 0.4.0 → 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
@@ -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.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.4.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
- const SIDEBAR_WIDTH = 26;
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,13 +168,15 @@ 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
181
  const sessionDetailsCache = new Map<string, { directory?: string; gitBranch?: string; project?: string }>();
167
182
 
@@ -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) => {
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;