@humanu/orchestra 0.5.71 → 0.5.73

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@humanu/orchestra",
3
- "version": "0.5.71",
3
+ "version": "0.5.73",
4
4
  "description": "AI-powered Git worktree and tmux session manager with modern TUI",
5
5
  "keywords": [
6
6
  "git",
@@ -236,18 +236,422 @@ _tmux_source_command_hook() {
236
236
  # Helper: orchestra prefix including delimiter
237
237
  _tmux_orch_prefix() { printf "orchestra%s" "$( _tmux_delim )"; }
238
238
 
239
+ _tmux_session_registry_path() {
240
+ if [[ -n "${ORCHESTRA_SESSION_REGISTRY_PATH-}" ]]; then
241
+ printf '%s\n' "$ORCHESTRA_SESSION_REGISTRY_PATH"
242
+ return
243
+ fi
244
+ printf '%s\n' "$HOME/.orchestra/orchestra.sqlite3"
245
+ }
246
+
247
+ _tmux_shared_root_for_path() {
248
+ local path="$1"
249
+ local old_pwd="$PWD"
250
+ local root=""
251
+ if [[ -n "$path" && -d "$path" ]]; then
252
+ cd "$path" 2>/dev/null || return 1
253
+ root="$(git_shared_root 2>/dev/null || git_repo_root 2>/dev/null || true)"
254
+ cd "$old_pwd" 2>/dev/null || true
255
+ fi
256
+ [[ -n "$root" ]] || return 1
257
+ printf '%s\n' "$root"
258
+ }
259
+
260
+ _tmux_branch_for_path() {
261
+ local path="$1"
262
+ local old_pwd="$PWD"
263
+ local branch=""
264
+ if [[ -n "$path" && -d "$path" ]]; then
265
+ cd "$path" 2>/dev/null || return 1
266
+ branch="$(git_current_branch 2>/dev/null || true)"
267
+ cd "$old_pwd" 2>/dev/null || true
268
+ fi
269
+ [[ -n "$branch" ]] || return 1
270
+ printf '%s\n' "$branch"
271
+ }
272
+
273
+ _tmux_registry_upsert_session() {
274
+ local repo_root="$1"
275
+ local branch="$2"
276
+ local worktree_path="$3"
277
+ local tmux_name="$4"
278
+ local db_path
279
+ db_path="$(_tmux_session_registry_path)"
280
+
281
+ [[ -n "$repo_root" && -n "$branch" && -n "$worktree_path" && -n "$tmux_name" ]] || return 1
282
+ have_cmd python3 || return 1
283
+
284
+ python3 - "$db_path" "$repo_root" "$branch" "$worktree_path" "$tmux_name" <<'PY'
285
+ import os
286
+ import sqlite3
287
+ import sys
288
+ import time
289
+
290
+ db_path, repo_root, branch, worktree_path, tmux_name = sys.argv[1:6]
291
+ os.makedirs(os.path.dirname(db_path), exist_ok=True)
292
+ slug = branch.replace("/", "-")
293
+ now = int(time.time())
294
+
295
+ conn = sqlite3.connect(db_path)
296
+ conn.execute(
297
+ """
298
+ CREATE TABLE IF NOT EXISTS sessions (
299
+ id INTEGER PRIMARY KEY AUTOINCREMENT,
300
+ tmux_name TEXT NOT NULL UNIQUE,
301
+ repo_root TEXT NOT NULL,
302
+ worktree_path TEXT NOT NULL,
303
+ branch TEXT NOT NULL,
304
+ slug TEXT NOT NULL,
305
+ display_name TEXT,
306
+ created_at INTEGER NOT NULL,
307
+ updated_at INTEGER NOT NULL,
308
+ last_seen_at INTEGER NOT NULL
309
+ )
310
+ """
311
+ )
312
+ conn.execute(
313
+ "CREATE INDEX IF NOT EXISTS idx_sessions_worktree ON sessions(repo_root, worktree_path)"
314
+ )
315
+ conn.execute("CREATE INDEX IF NOT EXISTS idx_sessions_slug ON sessions(repo_root, slug)")
316
+ conn.execute(
317
+ """
318
+ INSERT INTO sessions (
319
+ tmux_name, repo_root, worktree_path, branch, slug,
320
+ created_at, updated_at, last_seen_at
321
+ ) VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?6, ?6)
322
+ ON CONFLICT(tmux_name) DO UPDATE SET
323
+ repo_root = excluded.repo_root,
324
+ worktree_path = excluded.worktree_path,
325
+ branch = excluded.branch,
326
+ slug = excluded.slug,
327
+ updated_at = excluded.updated_at,
328
+ last_seen_at = excluded.last_seen_at
329
+ """,
330
+ (tmux_name, repo_root, worktree_path, branch, slug, now),
331
+ )
332
+ conn.commit()
333
+ PY
334
+ }
335
+
336
+ _tmux_registry_rename_session() {
337
+ local old_tmux_name="$1"
338
+ local new_tmux_name="$2"
339
+ local display_name="${3:-}"
340
+ local db_path
341
+ db_path="$(_tmux_session_registry_path)"
342
+
343
+ [[ -f "$db_path" ]] || return 0
344
+ have_cmd python3 || return 1
345
+
346
+ python3 - "$db_path" "$old_tmux_name" "$new_tmux_name" "$display_name" <<'PY'
347
+ import sqlite3
348
+ import sys
349
+ import time
350
+
351
+ db_path, old_tmux_name, new_tmux_name, display_name = sys.argv[1:5]
352
+ now = int(time.time())
353
+ value = display_name if display_name else None
354
+
355
+ conn = sqlite3.connect(db_path)
356
+ conn.execute(
357
+ """
358
+ UPDATE sessions
359
+ SET tmux_name = ?1,
360
+ display_name = COALESCE(?2, display_name),
361
+ updated_at = ?3,
362
+ last_seen_at = ?3
363
+ WHERE tmux_name = ?4
364
+ """,
365
+ (new_tmux_name, value, now, old_tmux_name),
366
+ )
367
+ conn.commit()
368
+ PY
369
+ }
370
+
371
+ _tmux_registry_remove_session() {
372
+ local tmux_name="$1"
373
+ local db_path
374
+ db_path="$(_tmux_session_registry_path)"
375
+
376
+ [[ -f "$db_path" ]] || return 0
377
+ have_cmd python3 || return 1
378
+
379
+ python3 - "$db_path" "$tmux_name" <<'PY'
380
+ import sqlite3
381
+ import sys
382
+
383
+ db_path, tmux_name = sys.argv[1:3]
384
+ conn = sqlite3.connect(db_path)
385
+ conn.execute("DELETE FROM sessions WHERE tmux_name = ?1", (tmux_name,))
386
+ conn.commit()
387
+ PY
388
+ }
389
+
390
+ _tmux_registry_upsert_current_session() {
391
+ local session_name="$1"
392
+ local session_dir repo_root branch
393
+ session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
394
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
395
+ branch="$(_tmux_branch_for_path "$session_dir" 2>/dev/null || true)"
396
+ [[ -n "$repo_root" && -n "$branch" ]] || return 1
397
+ _tmux_registry_upsert_session "$repo_root" "$branch" "$session_dir" "$session_name"
398
+ }
399
+
400
+ _tmux_registry_sync_active_workspace_sessions() {
401
+ local repo_root="$1"
402
+ [[ -n "$repo_root" ]] || return 1
403
+ tmux_available || return 1
404
+
405
+ local session_name session_dir session_root branch display_name
406
+ while IFS= read -r session_name; do
407
+ [[ -n "$session_name" ]] || continue
408
+ display_name="$(tmux show-option -t "$session_name" -qv @orchestra_display_name 2>/dev/null || true)"
409
+ [[ -n "$display_name" ]] || continue
410
+ session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
411
+ session_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
412
+ [[ "$session_root" == "$repo_root" ]] || continue
413
+ branch="$(_tmux_branch_for_path "$session_dir" 2>/dev/null || true)"
414
+ [[ -n "$branch" ]] || continue
415
+ _tmux_registry_upsert_session "$repo_root" "$branch" "$session_dir" "$session_name" >/dev/null 2>&1 || true
416
+ done < <(tmux list-sessions -F '#{session_name}' 2>/dev/null || true)
417
+ }
418
+
419
+ _tmux_workspace_cycle_target_cached() {
420
+ local current_session="$1"
421
+ local direction="$2"
422
+ local db_path="$3"
423
+ local active_file="$4"
424
+
425
+ python3 - "$db_path" "$current_session" "$direction" "$active_file" <<'PY'
426
+ import sqlite3
427
+ import sys
428
+
429
+ db_path, current_session, direction, active_path = sys.argv[1:5]
430
+ active = set()
431
+ active_orchestra = set()
432
+ with open(active_path, "r", encoding="utf-8") as handle:
433
+ for raw_line in handle:
434
+ line = raw_line.rstrip("\n")
435
+ if not line:
436
+ continue
437
+ parts = line.split("\t", 1)
438
+ name = parts[0].strip()
439
+ display_name = parts[1].strip() if len(parts) > 1 else ""
440
+ if not name:
441
+ continue
442
+ active.add(name)
443
+ if display_name:
444
+ active_orchestra.add(name)
445
+
446
+ try:
447
+ conn = sqlite3.connect(db_path)
448
+ current_row = conn.execute(
449
+ "SELECT repo_root FROM sessions WHERE tmux_name = ?1",
450
+ (current_session,),
451
+ ).fetchone()
452
+ if current_row is None:
453
+ raise SystemExit(2)
454
+
455
+ registered = {
456
+ name
457
+ for (name,) in conn.execute("SELECT tmux_name FROM sessions").fetchall()
458
+ }
459
+ if active_orchestra.difference(registered):
460
+ raise SystemExit(2)
461
+
462
+ rows = conn.execute(
463
+ """
464
+ SELECT tmux_name
465
+ FROM sessions
466
+ WHERE repo_root = ?1
467
+ ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
468
+ """,
469
+ (current_row[0],),
470
+ ).fetchall()
471
+ except sqlite3.Error:
472
+ raise SystemExit(2)
473
+
474
+ names = []
475
+ seen = set()
476
+ for (name,) in rows:
477
+ if name in active and name not in seen:
478
+ names.append(name)
479
+ seen.add(name)
480
+
481
+ if len(names) < 2 or current_session not in seen:
482
+ raise SystemExit(3)
483
+
484
+ index = names.index(current_session)
485
+ if direction == "next":
486
+ index = (index + 1) % len(names)
487
+ else:
488
+ index = (index - 1) % len(names)
489
+ print(names[index])
490
+ PY
491
+ }
492
+
239
493
  _tmux_status_escape_text() {
240
494
  local text="$1"
241
495
  text="${text//\#/##}"
242
496
  printf '%s' "$text"
243
497
  }
244
498
 
499
+ _tmux_truncate_tab_label() {
500
+ local label="$1"
501
+ local max_len="${2:-14}"
502
+ if (( ${#label} > max_len )); then
503
+ printf '%s' "${label:0:max_len}"
504
+ else
505
+ printf '%s' "$label"
506
+ fi
507
+ }
508
+
509
+ _tmux_pad_tab_label() {
510
+ local label="$1"
511
+ local width="${2:-14}"
512
+
513
+ label="$(_tmux_truncate_tab_label "$label" "$width")"
514
+ printf '%-*s' "$width" "$label"
515
+ }
516
+
517
+ _tmux_workspace_session_rows() {
518
+ local current_session="$1"
519
+ local repo_root="$2"
520
+ local db_path active_file
521
+ db_path="$(_tmux_session_registry_path)"
522
+
523
+ [[ -f "$db_path" ]] || return 0
524
+ have_cmd python3 || return 0
525
+
526
+ active_file="$(mktemp)"
527
+ tmux list-sessions -F '#{session_name}' > "$active_file" 2>/dev/null || {
528
+ rm -f "$active_file"
529
+ return 0
530
+ }
531
+
532
+ python3 - "$db_path" "$repo_root" "$current_session" "$active_file" <<'PY'
533
+ import sqlite3
534
+ import sys
535
+
536
+ db_path, repo_root, current_session, active_path = sys.argv[1:5]
537
+ with open(active_path, "r", encoding="utf-8") as handle:
538
+ active = {line.strip() for line in handle if line.strip()}
539
+
540
+ conn = sqlite3.connect(db_path)
541
+ rows = conn.execute(
542
+ """
543
+ SELECT tmux_name, COALESCE(display_name, '')
544
+ FROM sessions
545
+ WHERE repo_root = ?1
546
+ ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
547
+ """,
548
+ (repo_root,),
549
+ ).fetchall()
550
+
551
+ filtered = []
552
+ seen = set()
553
+ for name, display_name in rows:
554
+ if name in active and name not in seen:
555
+ filtered.append((name, display_name or ""))
556
+ seen.add(name)
557
+
558
+ if not filtered:
559
+ raise SystemExit(0)
560
+
561
+ max_tabs = 8
562
+ if len(filtered) <= max_tabs:
563
+ visible = filtered
564
+ prefix = suffix = False
565
+ else:
566
+ try:
567
+ current_index = [name for name, _ in filtered].index(current_session)
568
+ except ValueError:
569
+ current_index = 0
570
+ start = max(0, min(current_index - 2, len(filtered) - max_tabs))
571
+ end = min(len(filtered), start + max_tabs)
572
+ visible = filtered[start:end]
573
+ prefix = start > 0
574
+ suffix = end < len(filtered)
575
+
576
+ if prefix:
577
+ print("__ellipsis__\t")
578
+ for name, display_name in visible:
579
+ print(f"{name}\t{display_name}")
580
+ if suffix:
581
+ print("__ellipsis__\t")
582
+ PY
583
+ local status=$?
584
+ rm -f "$active_file"
585
+ return $status
586
+ }
587
+
588
+ _tmux_workspace_session_tabs() {
589
+ local current_session="$1"
590
+ local fallback_display_name="$2"
591
+ local session_dir repo_root rows tabs name display_name tab_label escaped_tab_label divider active_style inactive_style muted_style reset_style
592
+ local tab_width=21
593
+ local active_label_width=19
594
+
595
+ session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
596
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
597
+ if [[ -n "$repo_root" ]]; then
598
+ _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
599
+ rows="$(_tmux_workspace_session_rows "$current_session" "$repo_root" 2>/dev/null || true)"
600
+ else
601
+ rows=""
602
+ fi
603
+
604
+ if [[ -z "$rows" ]]; then
605
+ tab_label="$(_tmux_pad_tab_label "$fallback_display_name" "$active_label_width")"
606
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
607
+ printf '#[bg=#414868] #[fg=#ff9e64,bg=#414868,bold]● #[fg=#c0caf5,bg=#414868,bold]%s #[default]' "$escaped_tab_label"
608
+ return
609
+ fi
610
+
611
+ # Tokyo Night palette: midnight footer, muted inactive tabs, orange active marker.
612
+ active_style="#[fg=#c0caf5,bg=#414868,bold]"
613
+ inactive_style="#[fg=#a9b1d6,bg=#24283b,nobold]"
614
+ muted_style="#[fg=#565f89,bg=#1a1b26,nobold]"
615
+ reset_style="#[default]"
616
+ tabs=""
617
+ divider="${muted_style}|${reset_style}"
618
+ while IFS=$'\t' read -r name display_name; do
619
+ [[ -n "$name" ]] || continue
620
+ if [[ -n "$tabs" ]]; then
621
+ tabs+="$divider"
622
+ fi
623
+ if [[ "$name" == "__ellipsis__" ]]; then
624
+ tab_label="$(_tmux_pad_tab_label "⋯" "$tab_width")"
625
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
626
+ tabs+="${muted_style} ${escaped_tab_label} ${reset_style}"
627
+ continue
628
+ fi
629
+
630
+ if [[ -z "$display_name" ]]; then
631
+ display_name="$(tmux_format_session_display "$name" without-timestamp)"
632
+ fi
633
+ if [[ "$name" == "$current_session" ]]; then
634
+ tab_label="$(_tmux_pad_tab_label "$display_name" "$active_label_width")"
635
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
636
+ tabs+="#[bg=#414868] #[fg=#ff9e64,bg=#414868,bold]● ${active_style}${escaped_tab_label} ${reset_style}"
637
+ else
638
+ tab_label="$(_tmux_pad_tab_label "$display_name" "$tab_width")"
639
+ escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
640
+ tabs+="${inactive_style} ${escaped_tab_label} ${reset_style}"
641
+ fi
642
+ done <<< "$rows"
643
+
644
+ printf '%s' "$tabs"
645
+ }
646
+
245
647
  _tmux_orchestra_status_left() {
246
- local worktree_name="$1"
247
- local session_display_name="$2"
248
- worktree_name="$(_tmux_status_escape_text "$worktree_name")"
249
- session_display_name="$(_tmux_status_escape_text "$session_display_name")"
250
- printf '#[fg=white,bg=colour22,bold] %s #[default] Rename: Ctrl+b,r | Go back (detach): Ctrl+b,d | Copy (scroll) mode: Ctrl+b,[ | Worktree: %s' "$session_display_name" "$worktree_name"
648
+ local session_name="$1"
649
+ local session_display_name="$3"
650
+ _tmux_workspace_session_tabs "$session_name" "$session_display_name"
651
+ }
652
+
653
+ _tmux_orchestra_status_right() {
654
+ printf ''
251
655
  }
252
656
 
253
657
  _tmux_configure_orchestra_bindings() {
@@ -255,32 +659,79 @@ _tmux_configure_orchestra_bindings() {
255
659
  bridge="$(_orchestra_bridge_script)"
256
660
  [[ -f "$bridge" ]] || return
257
661
 
258
- local quoted_bridge rename_command prompt_command
662
+ local quoted_bridge rename_command prompt_command next_command previous_command help_command
259
663
  printf -v quoted_bridge '%q' "$bridge"
260
664
  rename_command="$quoted_bridge manual-rename-session \\\"#{session_name}\\\" \\\"%%\\\" >/dev/null 2>&1"
261
665
  prompt_command="command-prompt -p 'Rename Orchestra session:' 'run-shell -b \"$rename_command\"'"
666
+ next_command="$quoted_bridge cycle-workspace-session \\\"#{session_name}\\\" next \\\"#{client_tty}\\\" >/dev/null 2>&1"
667
+ previous_command="$quoted_bridge cycle-workspace-session \\\"#{session_name}\\\" previous \\\"#{client_tty}\\\" >/dev/null 2>&1"
668
+ help_command="$quoted_bridge tmux-help-popup \\\"#{client_tty}\\\" >/dev/null 2>&1"
262
669
 
670
+ tmux bind-key -T prefix '?' if-shell -F '#{@orchestra_display_name}' \
671
+ "run-shell -b \"$help_command\"" 'list-keys -N' >/dev/null 2>&1 || true
263
672
  tmux bind-key -T prefix r if-shell -F '#{@orchestra_display_name}' \
264
673
  "$prompt_command" 'refresh-client -S' >/dev/null 2>&1 || true
674
+ tmux bind-key -T prefix '>' if-shell -F '#{@orchestra_display_name}' \
675
+ "run-shell -b \"$next_command\"" 'switch-client -n' >/dev/null 2>&1 || true
676
+ tmux bind-key -T prefix '<' if-shell -F '#{@orchestra_display_name}' \
677
+ "run-shell -b \"$previous_command\"" 'switch-client -p' >/dev/null 2>&1 || true
678
+ tmux bind-key -r -T prefix Right if-shell -F '#{@orchestra_display_name}' \
679
+ "run-shell -b \"$next_command\"" 'select-pane -R' >/dev/null 2>&1 || true
680
+ tmux bind-key -r -T prefix Left if-shell -F '#{@orchestra_display_name}' \
681
+ "run-shell -b \"$previous_command\"" 'select-pane -L' >/dev/null 2>&1 || true
265
682
  }
266
683
 
267
684
  _tmux_configure_orchestra_status() {
268
685
  local session_name="$1"
269
686
  local worktree_name="$2"
270
687
  local session_display_name="${3:-}"
271
- local status_left
688
+ local status_left status_right
272
689
  if [[ -z "$session_display_name" ]]; then
273
690
  session_display_name="$(tmux_format_session_display "$session_name" without-timestamp)"
274
691
  fi
275
- status_left="$(_tmux_orchestra_status_left "$worktree_name" "$session_display_name")"
692
+ status_left="$(_tmux_orchestra_status_left "$session_name" "$worktree_name" "$session_display_name")"
693
+ status_right="$(_tmux_orchestra_status_right "$worktree_name")"
276
694
 
277
695
  tmux set-option -t "$session_name" @orchestra_display_name "$session_display_name" >/dev/null 2>&1 || true
278
696
  tmux set-option -t "$session_name" @orchestra_worktree_name "$worktree_name" >/dev/null 2>&1 || true
697
+ tmux set-option -t "$session_name" status on >/dev/null 2>&1 || true
698
+ tmux set-option -t "$session_name" status-position bottom >/dev/null 2>&1 || true
699
+ tmux set-option -t "$session_name" status-style "fg=#c0caf5,bg=#1a1b26" >/dev/null 2>&1 || true
279
700
  tmux set-option -t "$session_name" status-left "$status_left" >/dev/null 2>&1 || true
280
- tmux set-option -t "$session_name" status-left-length 220 >/dev/null 2>&1 || true
701
+ tmux set-option -t "$session_name" status-left-length 1000 >/dev/null 2>&1 || true
702
+ tmux set-option -t "$session_name" status-right "$status_right" >/dev/null 2>&1 || true
703
+ tmux set-option -t "$session_name" status-right-length 0 >/dev/null 2>&1 || true
704
+ tmux set-option -t "$session_name" window-status-format "" >/dev/null 2>&1 || true
705
+ tmux set-option -t "$session_name" window-status-current-format "" >/dev/null 2>&1 || true
706
+ tmux set-option -t "$session_name" window-status-separator "" >/dev/null 2>&1 || true
281
707
  _tmux_configure_orchestra_bindings
282
708
  }
283
709
 
710
+ _tmux_refresh_orchestra_session_status() {
711
+ local session_name="$1"
712
+ local session_dir branch_name worktree_name display_name old_pwd
713
+
714
+ session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
715
+ branch_name=""
716
+ if [[ -n "$session_dir" && -d "$session_dir" ]]; then
717
+ old_pwd="$PWD"
718
+ cd "$session_dir" 2>/dev/null || true
719
+ branch_name="$(git_current_branch 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
720
+ cd "$old_pwd" 2>/dev/null || true
721
+ fi
722
+
723
+ if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
724
+ worktree_name="$branch_name"
725
+ elif [[ -n "$session_dir" && -d "$session_dir" ]]; then
726
+ worktree_name="$(basename "$session_dir")"
727
+ else
728
+ worktree_name="$branch_name"
729
+ fi
730
+
731
+ display_name="$(tmux show-option -t "$session_name" -qv @orchestra_display_name 2>/dev/null || true)"
732
+ _tmux_configure_orchestra_status "$session_name" "$worktree_name" "$display_name"
733
+ }
734
+
284
735
  # Helper: split a string by multi-char delimiter into bash array named by ref
285
736
  # Usage: _tmux_split_by_delim "string" "::" out_array_name
286
737
  _tmux_split_by_delim() {
@@ -355,14 +806,20 @@ tmux_create_session() {
355
806
  # Get repository info from the working directory context
356
807
  local repo_name=""
357
808
  local branch_name=""
809
+ local repo_root=""
358
810
  local old_pwd="$PWD"
811
+ local resolved_working_dir="$working_dir"
812
+ if [[ -n "$working_dir" && -d "$working_dir" ]]; then
813
+ resolved_working_dir="$(cd "$working_dir" 2>/dev/null && pwd -P || printf '%s' "$working_dir")"
814
+ fi
359
815
 
360
816
  # Change to working directory to get accurate git info
361
- cd "$working_dir" 2>/dev/null || true
817
+ cd "$resolved_working_dir" 2>/dev/null || true
362
818
 
363
819
  if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
364
820
  repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
365
821
  branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
822
+ repo_root="$(git_shared_root 2>/dev/null || git_repo_root 2>/dev/null || true)"
366
823
  fi
367
824
 
368
825
  # Restore original directory
@@ -376,21 +833,24 @@ tmux_create_session() {
376
833
  fi
377
834
 
378
835
  # Create session with custom Orchestra status configuration
379
- tmux new-session -Ad -s "$session_name" -c "$working_dir" >/dev/null 2>&1 || {
836
+ tmux new-session -Ad -s "$session_name" -c "$resolved_working_dir" >/dev/null 2>&1 || {
380
837
  err "Failed to create tmux session: $session_name"
381
838
  return 1
382
839
  }
383
840
 
841
+ local worktree_name=""
842
+ if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
843
+ worktree_name="$branch_name"
844
+ else
845
+ worktree_name="$(basename "$resolved_working_dir")"
846
+ fi
847
+
848
+ if [[ -n "$repo_root" && -n "$branch_name" && "$branch_name" != "detached" ]]; then
849
+ _tmux_registry_upsert_session "$repo_root" "$branch_name" "$resolved_working_dir" "$session_name" >/dev/null 2>&1 || true
850
+ fi
851
+
384
852
  # Customize the default status bar to include Orchestra info on the left
385
853
  if [[ -n "$repo_name" ]]; then
386
- # Set custom status-left with Orchestra branding without full path
387
- # Example: Go back (detach): Ctrl+b,d | Copy (scroll) mode: Ctrl+b,[ | Worktree: main
388
- local worktree_name=""
389
- if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
390
- worktree_name="$branch_name"
391
- else
392
- worktree_name="$(basename "$working_dir")"
393
- fi
394
854
  _tmux_configure_orchestra_status "$session_name" "$worktree_name"
395
855
  fi
396
856
 
@@ -467,6 +927,7 @@ tmux_kill_session() {
467
927
  err "Failed to kill session: $session_name"
468
928
  return 1
469
929
  }
930
+ _tmux_registry_remove_session "$session_name" >/dev/null 2>&1 || true
470
931
  }
471
932
 
472
933
  # Attach or switch to session
@@ -519,19 +980,7 @@ tmux_attach_session() {
519
980
  "🎼 Orchestra Session | Repository: $repo_name | Branch: $branch_name" 2>/dev/null || true
520
981
  fi
521
982
 
522
- # Ensure status-left shows Orchestra help without full path
523
- local worktree_name=""
524
- if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
525
- worktree_name="$branch_name"
526
- else
527
- # Fallback to directory name of session path if available
528
- if [[ -n "$session_dir" && -d "$session_dir" ]]; then
529
- worktree_name="$(basename "$session_dir")"
530
- else
531
- worktree_name="$branch_name"
532
- fi
533
- fi
534
- _tmux_configure_orchestra_status "$sess" "$worktree_name"
983
+ _tmux_refresh_orchestra_session_status "$sess"
535
984
 
536
985
  if tmux_inside_session; then
537
986
  tmux switch-client -t "$sess" >/dev/null 2>&1 || true
@@ -609,6 +1058,7 @@ tmux_rename_session() {
609
1058
  err "Failed to rename session"
610
1059
  return 1
611
1060
  }
1061
+ _tmux_registry_rename_session "$old_session" "$new_session" "$(format_session_display_name "$new_name")" >/dev/null 2>&1 || true
612
1062
  local worktree_name
613
1063
  worktree_name="$(tmux show-option -t "$new_session" -qv @orchestra_worktree_name 2>/dev/null || true)"
614
1064
  if [[ -z "$worktree_name" ]]; then
@@ -1044,6 +1494,186 @@ tmux_send_keys() {
1044
1494
  tmux send-keys -t "$session_name" C-m 2>/dev/null || return 1
1045
1495
  }
1046
1496
 
1497
+ # Show Orchestra tmux shortcuts in a popup modal.
1498
+ # Usage: tmux_show_orchestra_help_popup [client_tty]
1499
+ tmux_show_orchestra_help_popup() {
1500
+ local target_client="${1:-}"
1501
+ if ! tmux_available; then
1502
+ err "tmux not installed"
1503
+ return 1
1504
+ fi
1505
+
1506
+ local target_args=()
1507
+ if [[ -n "$target_client" ]]; then
1508
+ target_args=(-c "$target_client")
1509
+ fi
1510
+
1511
+ local popup_command
1512
+ popup_command='printf "%s\n" \
1513
+ "Orchestra tmux shortcuts" \
1514
+ "" \
1515
+ "Ctrl+b, Left Previous Orchestra session in this workspace" \
1516
+ "Ctrl+b, Right Next Orchestra session in this workspace" \
1517
+ "Ctrl+b, < Previous Orchestra session in this workspace" \
1518
+ "Ctrl+b, > Next Orchestra session in this workspace" \
1519
+ "Ctrl+b, r Rename the current Orchestra session" \
1520
+ "Ctrl+b, d Detach and return to Orchestra" \
1521
+ "Ctrl+b, [ Copy/scroll mode" \
1522
+ "Ctrl+b, ? Show this help" \
1523
+ "" \
1524
+ "Press Enter to close..."; read -r _'
1525
+
1526
+ tmux display-popup "${target_args[@]}" -E -w 78 -h 15 -T "Orchestra shortcuts" "$popup_command" >/dev/null 2>&1
1527
+ }
1528
+
1529
+ # Find the adjacent active Orchestra session registered for the current repo.
1530
+ # Usage: tmux_workspace_cycle_target <current_session> <next|previous>
1531
+ tmux_workspace_cycle_target() {
1532
+ local current_session="$1"
1533
+ local direction="$2"
1534
+ local session_dir repo_root db_path active_file target query_status
1535
+
1536
+ if ! tmux_available; then
1537
+ err "tmux not installed"
1538
+ return 1
1539
+ fi
1540
+ if [[ -z "$current_session" ]]; then
1541
+ err "tmux_workspace_cycle_target: current session required"
1542
+ return 1
1543
+ fi
1544
+ case "$direction" in
1545
+ next|previous|prev) ;;
1546
+ *)
1547
+ err "tmux_workspace_cycle_target: direction must be next or previous"
1548
+ return 1
1549
+ ;;
1550
+ esac
1551
+
1552
+ db_path="$(_tmux_session_registry_path)"
1553
+ have_cmd python3 || {
1554
+ err "python3 is required to read the Orchestra session registry"
1555
+ return 1
1556
+ }
1557
+
1558
+ active_file="$(mktemp)"
1559
+ tmux list-sessions -F $'#{session_name}\t#{@orchestra_display_name}' > "$active_file" 2>/dev/null || {
1560
+ rm -f "$active_file"
1561
+ err "Unable to list tmux sessions"
1562
+ return 1
1563
+ }
1564
+
1565
+ if [[ -f "$db_path" ]]; then
1566
+ if target="$(_tmux_workspace_cycle_target_cached "$current_session" "$direction" "$db_path" "$active_file")"; then
1567
+ query_status=0
1568
+ else
1569
+ query_status=$?
1570
+ fi
1571
+ if [[ $query_status -eq 0 && -n "$target" ]]; then
1572
+ rm -f "$active_file"
1573
+ printf '%s\n' "$target"
1574
+ return 0
1575
+ fi
1576
+ if [[ $query_status -eq 3 ]]; then
1577
+ rm -f "$active_file"
1578
+ return 1
1579
+ fi
1580
+ fi
1581
+
1582
+ _tmux_registry_upsert_current_session "$current_session" >/dev/null 2>&1 || true
1583
+
1584
+ session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1585
+ repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
1586
+ if [[ -z "$repo_root" ]]; then
1587
+ rm -f "$active_file"
1588
+ err "Unable to determine Orchestra workspace for session"
1589
+ return 1
1590
+ fi
1591
+ _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
1592
+
1593
+ if [[ ! -f "$db_path" ]]; then
1594
+ rm -f "$active_file"
1595
+ err "No Orchestra session registry found"
1596
+ return 1
1597
+ fi
1598
+
1599
+ if target="$(python3 - "$db_path" "$repo_root" "$current_session" "$direction" "$active_file" <<'PY'
1600
+ import sqlite3
1601
+ import sys
1602
+
1603
+ db_path, repo_root, current_session, direction, active_path = sys.argv[1:6]
1604
+ with open(active_path, "r", encoding="utf-8") as handle:
1605
+ active = {
1606
+ line.rstrip("\n").split("\t", 1)[0].strip()
1607
+ for line in handle
1608
+ if line.rstrip("\n").split("\t", 1)[0].strip()
1609
+ }
1610
+
1611
+ conn = sqlite3.connect(db_path)
1612
+ rows = conn.execute(
1613
+ """
1614
+ SELECT tmux_name
1615
+ FROM sessions
1616
+ WHERE repo_root = ?1
1617
+ ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
1618
+ """,
1619
+ (repo_root,),
1620
+ ).fetchall()
1621
+
1622
+ names = []
1623
+ seen = set()
1624
+ for (name,) in rows:
1625
+ if name in active and name not in seen:
1626
+ names.append(name)
1627
+ seen.add(name)
1628
+
1629
+ if len(names) < 2 or current_session not in seen:
1630
+ raise SystemExit(0)
1631
+
1632
+ index = names.index(current_session)
1633
+ if direction == "next":
1634
+ index = (index + 1) % len(names)
1635
+ else:
1636
+ index = (index - 1) % len(names)
1637
+ print(names[index])
1638
+ PY
1639
+ )"; then
1640
+ query_status=0
1641
+ else
1642
+ query_status=$?
1643
+ fi
1644
+ rm -f "$active_file"
1645
+ if [[ $query_status -ne 0 ]]; then
1646
+ err "Unable to query Orchestra session registry"
1647
+ return 1
1648
+ fi
1649
+ [[ -n "$target" ]] || return 1
1650
+ printf '%s\n' "$target"
1651
+ }
1652
+
1653
+ # Switch the current tmux client to an adjacent registered Orchestra session.
1654
+ # Usage: tmux_cycle_workspace_session <current_session> <next|previous> [client_tty]
1655
+ tmux_cycle_workspace_session() {
1656
+ local current_session="$1"
1657
+ local direction="$2"
1658
+ local target_client="${3:-}"
1659
+ local target_session
1660
+
1661
+ if ! target_session="$(tmux_workspace_cycle_target "$current_session" "$direction")"; then
1662
+ tmux display-message -d 2500 "No other Orchestra sessions in this workspace" 2>/dev/null || true
1663
+ return 1
1664
+ fi
1665
+
1666
+ if [[ -n "$target_client" ]]; then
1667
+ tmux switch-client -c "$target_client" -t "$target_session" >/dev/null 2>&1 || return 1
1668
+ else
1669
+ tmux switch-client -t "$target_session" >/dev/null 2>&1 || return 1
1670
+ fi
1671
+
1672
+ {
1673
+ _tmux_refresh_orchestra_session_status "$target_session" >/dev/null 2>&1 || true
1674
+ } &
1675
+ }
1676
+
1047
1677
  # Load .env file if it exists
1048
1678
  # Usage: tmux_load_env_file [env_file_path]
1049
1679
  tmux_load_env_file() {
@@ -109,6 +109,14 @@ case "${1:-}" in
109
109
  "tmux-send-keys")
110
110
  bridge_tmux_send_keys "${2:-}" "${@:3}"
111
111
  ;;
112
+
113
+ "cycle-workspace-session")
114
+ bridge_cycle_workspace_session "${2:-}" "${3:-}" "${4:-}"
115
+ ;;
116
+
117
+ "tmux-help-popup")
118
+ bridge_tmux_help_popup "${2:-}"
119
+ ;;
112
120
 
113
121
  "session-metadata")
114
122
  bridge_session_metadata "${2:-}"
@@ -233,6 +241,9 @@ Commands:
233
241
  delete-branch-only <branch> Delete branch only (keep worktree)
234
242
  switch-worktree <path> Generate cd command for path
235
243
  copy-env-files <source> <target> Copy .env files between directories
244
+ cycle-workspace-session <session> <next|previous> [client]
245
+ Switch within registered Orchestra sessions for this repo
246
+ tmux-help-popup [client] Show Orchestra tmux shortcut popup
236
247
  repo-info Get repository information
237
248
  git-status Get git status --porcelain
238
249
  enhanced-git-status [dir] Get enhanced git status with file stats and line counts
@@ -112,6 +112,42 @@ bridge_tmux_send_keys() {
112
112
  fi
113
113
  }
114
114
 
115
+ # Cycle to next/previous registered Orchestra session in the current workspace
116
+ bridge_cycle_workspace_session() {
117
+ if [[ -z "${1:-}" ]] || [[ -z "${2:-}" ]]; then
118
+ json_error "Session name and direction required"
119
+ return 1
120
+ fi
121
+ session_name="$1"
122
+ direction="$2"
123
+ target_client="${3:-}"
124
+
125
+ if tmux_available; then
126
+ tmux_cycle_workspace_session "$session_name" "$direction" "$target_client" || {
127
+ json_error "Failed to cycle workspace session"
128
+ return 1
129
+ }
130
+ echo '{"ok":true}'
131
+ else
132
+ json_error "tmux not available"
133
+ fi
134
+ }
135
+
136
+ # Show Orchestra tmux shortcut help popup
137
+ bridge_tmux_help_popup() {
138
+ target_client="${1:-}"
139
+
140
+ if tmux_available; then
141
+ tmux_show_orchestra_help_popup "$target_client" || {
142
+ json_error "Failed to show tmux help popup"
143
+ return 1
144
+ }
145
+ echo '{"ok":true}'
146
+ else
147
+ json_error "tmux not available"
148
+ fi
149
+ }
150
+
115
151
  # Get session metadata
116
152
  bridge_session_metadata() {
117
153
  if [[ -z "${1:-}" ]]; then