@humanu/orchestra 0.5.77 → 0.5.79

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.
Files changed (84) hide show
  1. package/bin/{gw.js → orchestra-cli.js} +1 -1
  2. package/bin/orchestra.js +2 -2
  3. package/install.js +41 -42
  4. package/package.json +2 -3
  5. package/resources/api/git.sh +4 -444
  6. package/resources/api/tmux.sh +4 -2804
  7. package/resources/prebuilt/linux-x64/{gw-env-copy → env-copy} +0 -0
  8. package/resources/prebuilt/linux-x64/orchestra +0 -0
  9. package/resources/prebuilt/macos-arm64/{gw-env-copy → env-copy} +0 -0
  10. package/resources/prebuilt/macos-arm64/orchestra +0 -0
  11. package/resources/prebuilt/macos-intel/{gw-env-copy → env-copy} +0 -0
  12. package/resources/prebuilt/macos-intel/orchestra +0 -0
  13. package/resources/scripts/{gw.sh → orchestra-cli.sh} +14 -14
  14. package/resources/scripts/orchestra-local.sh +6 -6
  15. package/resources/scripts/{gwr.sh → orchestra.sh} +11 -55
  16. package/resources/scripts/{shell/bridge → server/services}/ai.sh +4 -4
  17. package/resources/scripts/{gw-bridge.sh → server/services/dispatch.sh} +62 -59
  18. package/resources/scripts/server/services/git/api.sh +447 -0
  19. package/resources/scripts/{shell/git/bridge_check_branch.sh → server/services/git/check_branch_api.sh} +1 -1
  20. package/resources/scripts/{shell/git/bridge_create_worktree.sh → server/services/git/create_worktree_api.sh} +3 -3
  21. package/resources/scripts/{shell/git/bridge_create_worktree_from_existing.sh → server/services/git/create_worktree_from_existing_api.sh} +2 -2
  22. package/resources/scripts/{shell/git/bridge_create_worktree_from_remote.sh → server/services/git/create_worktree_from_remote_api.sh} +2 -2
  23. package/resources/scripts/{shell/git/bridge_delete_branch_only.sh → server/services/git/delete_branch_only_api.sh} +1 -1
  24. package/resources/scripts/{shell/git/bridge_delete_worktree.sh → server/services/git/delete_worktree_api.sh} +2 -2
  25. package/resources/scripts/{shell/git/bridge_delete_worktree_only.sh → server/services/git/delete_worktree_only_api.sh} +1 -1
  26. package/resources/scripts/{shell/git/bridge_enhanced_git_status.sh → server/services/git/enhanced_git_status_api.sh} +3 -3
  27. package/resources/scripts/{shell/git/bridge_git_status.sh → server/services/git/git_status_api.sh} +2 -2
  28. package/resources/scripts/{shell/git/bridge_list_worktrees.sh → server/services/git/list_worktrees_api.sh} +2 -2
  29. package/resources/scripts/server/services/git/merge_api.sh +12 -0
  30. package/resources/scripts/{shell/git/bridge_merge_from_primary.sh → server/services/git/merge_from_primary_api.sh} +1 -1
  31. package/resources/scripts/{shell/git/bridge_merge_into_primary.sh → server/services/git/merge_into_primary_api.sh} +1 -1
  32. package/resources/scripts/{shell/git/bridge_primary_branch.sh → server/services/git/primary_branch_api.sh} +1 -1
  33. package/resources/scripts/{shell/git/bridge_rebase_from_primary.sh → server/services/git/rebase_from_primary_api.sh} +1 -1
  34. package/resources/scripts/server/services/git/repo_api.sh +12 -0
  35. package/resources/scripts/{shell/git/bridge_repo_info.sh → server/services/git/repo_info_api.sh} +1 -1
  36. package/resources/scripts/{shell/git/bridge_squash_into_primary.sh → server/services/git/squash_into_primary_api.sh} +1 -1
  37. package/resources/scripts/{shell/git/bridge_switch_worktree.sh → server/services/git/switch_worktree_api.sh} +2 -2
  38. package/resources/scripts/server/services/git/worktree_api.sh +17 -0
  39. package/resources/scripts/{shell/bridge/utils.sh → server/services/json.sh} +23 -23
  40. package/resources/scripts/{shell/bridge → server/services/session}/tmux.sh +14 -14
  41. package/resources/scripts/server/session/tmux_api.sh +2812 -0
  42. package/resources/scripts/services.sh +6 -0
  43. package/resources/scripts/shell/AGENTS.md +63 -74
  44. package/resources/scripts/shell/build/dependencies.sh +33 -0
  45. package/resources/scripts/shell/build/install.sh +7 -0
  46. package/resources/scripts/shell/build/load.sh +10 -0
  47. package/resources/scripts/shell/build/logging.sh +6 -0
  48. package/resources/scripts/shell/build/rust.sh +18 -0
  49. package/resources/scripts/shell/build/services.sh +17 -0
  50. package/resources/scripts/shell/build/{build_usage.sh → usage.sh} +6 -6
  51. package/resources/scripts/shell/cli_load.sh +9 -0
  52. package/resources/scripts/shell/{gw_env_copy.sh → env_copy.sh} +11 -11
  53. package/resources/scripts/shell/env_copy_command.sh +2 -2
  54. package/resources/scripts/shell/git/checkout_worktree.sh +4 -4
  55. package/resources/scripts/shell/git/create_worktree.sh +2 -2
  56. package/resources/scripts/shell/git/delete_worktree.sh +1 -1
  57. package/resources/scripts/shell/git/merge.sh +1 -1
  58. package/resources/scripts/shell/git/repo.sh +1 -1
  59. package/resources/scripts/shell/git/worktree.sh +1 -1
  60. package/resources/scripts/shell/gwr/check-updates.sh +1 -1
  61. package/resources/scripts/shell/gwr_binary.sh +4 -4
  62. package/resources/scripts/shell/gwr_load.sh +1 -1
  63. package/resources/scripts/shell/gwr_services.sh +10 -0
  64. package/resources/scripts/shell/gwr_usage.sh +10 -10
  65. package/resources/scripts/shell/orchestra-command-hook.sh +15 -15
  66. package/resources/scripts/shell/orchestra-local.sh +6 -6
  67. package/resources/scripts/shell/tmux/new_session_command.sh +1 -1
  68. package/bin/gwr.js +0 -10
  69. package/resources/scripts/shell/build/build_bridge.sh +0 -17
  70. package/resources/scripts/shell/build/build_dependencies.sh +0 -33
  71. package/resources/scripts/shell/build/build_install.sh +0 -7
  72. package/resources/scripts/shell/build/build_load.sh +0 -10
  73. package/resources/scripts/shell/build/build_logging.sh +0 -6
  74. package/resources/scripts/shell/build/build_rust.sh +0 -18
  75. package/resources/scripts/shell/git/bridge_merge.sh +0 -12
  76. package/resources/scripts/shell/git/bridge_repo.sh +0 -12
  77. package/resources/scripts/shell/git/bridge_worktree.sh +0 -17
  78. package/resources/scripts/shell/gw_legacy_wrappers.sh +0 -7
  79. package/resources/scripts/shell/gw_load.sh +0 -10
  80. package/resources/scripts/shell/gwr_bridge.sh +0 -10
  81. /package/resources/scripts/shell/{gw_debug.sh → cli_debug.sh} +0 -0
  82. /package/resources/scripts/shell/{gw_err.sh → cli_err.sh} +0 -0
  83. /package/resources/scripts/shell/{gw_have_cmd.sh → cli_have_cmd.sh} +0 -0
  84. /package/resources/scripts/shell/{gw_info.sh → cli_info.sh} +0 -0
@@ -1,2807 +1,7 @@
1
1
  #!/bin/bash
2
2
 
3
- ###############################################################################
4
- # tmux.sh – Dedicated tmux session management API
5
- # ---------------------------------------------------------------------------
6
- # This script provides a clean API for all tmux session operations.
7
- # It can be sourced by other modules that need tmux functionality.
8
- ###############################################################################
3
+ # shellcheck shell=bash
9
4
 
10
- # Ensure we have access to core utilities
11
- if ! declare -f repo_root >/dev/null 2>&1 && ! declare -f git_repo_root >/dev/null 2>&1; then
12
- echo "Error: tmux.sh must be sourced from gw.sh or have core utilities available" >&2
13
- return 1 2>/dev/null || exit 1
14
- fi
15
-
16
- # Use git_repo_root if available, otherwise fallback to repo_root
17
- if declare -f git_repo_root >/dev/null 2>&1; then
18
- repo_root() { git_repo_root; }
19
- fi
20
-
21
- # --------------------------- Tmux Core API ----------------------------------
22
-
23
- # Session delimiter (hardcoded)
24
- # Note: tmux session names cannot contain ':'; use a safe delimiter
25
- ORCHESTRA_SESSION_DELIM="__"
26
-
27
- # Absolute directory that contains this script when sourced.
28
- _TMUX_API_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd -P)"
29
-
30
- # Helper: return delimiter
31
- _tmux_delim() { echo "$ORCHESTRA_SESSION_DELIM"; }
32
-
33
- # Helper: sanitize tmux session name for filesystem usage
34
- _orchestra_history_key() {
35
- local key="$1"
36
- key="${key//\//_}"
37
- key=$(echo "$key" | tr '[:space:]' '_')
38
- key=$(echo "$key" | tr -c '[:alnum:]_-' '_')
39
- while [[ "$key" == *"__"* ]]; do
40
- key="${key//__/_}"
41
- done
42
- key="${key##_}"
43
- key="${key%%_}"
44
- echo "$key"
45
- }
46
-
47
- _tmux_normalize_app_from_command() {
48
- local cmd="$1"
49
- cmd="${cmd#"${cmd%%[![:space:]]*}"}"
50
- cmd="${cmd%"${cmd##*[![:space:]]}"}"
51
- if [[ -z "$cmd" ]]; then
52
- return
53
- fi
54
-
55
- local tokens=()
56
- read -r -a tokens <<< "$cmd"
57
- if [[ ${#tokens[@]} -eq 0 ]]; then
58
- return
59
- fi
60
-
61
- local i=0
62
- while [[ $i -lt ${#tokens[@]} ]]; do
63
- local t="${tokens[$i]}"
64
- if [[ "$t" =~ ^[A-Za-z_][A-Za-z0-9_]*= ]]; then
65
- ((i++))
66
- continue
67
- fi
68
- case "$t" in
69
- sudo|env|command|nohup|time)
70
- ((i++))
71
- continue
72
- ;;
73
- esac
74
- break
75
- done
76
-
77
- if [[ $i -ge ${#tokens[@]} ]]; then
78
- return
79
- fi
80
-
81
- local base="${tokens[$i]}"
82
- base="${base##*/}"
83
- base="$(printf '%s' "$base" | tr '[:upper:]' '[:lower:]' | tr -c 'a-z0-9._:-' '_')"
84
- base="${base//__/_}"
85
- base="${base##_}"
86
- base="${base%%_}"
87
-
88
- case "$base" in
89
- python3|python3.*)
90
- base="python"
91
- ;;
92
- pip3|pip3.*)
93
- base="pip"
94
- ;;
95
- docker-compose|docker_compose)
96
- base="docker_compose"
97
- ;;
98
- nvim|nvim.app)
99
- base="nvim"
100
- ;;
101
- esac
102
-
103
- case "$base" in
104
- ""|bash|zsh|sh|fish|tmux|login|sudo|man|less|more|cat|tail|watch|source|export|set|alias|unalias|history|bindkey)
105
- return
106
- ;;
107
- esac
108
-
109
- printf '%s' "$base"
110
- }
111
-
112
- _tmux_truthy() {
113
- case "$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" in
114
- 1|yes|true|on)
115
- return 0
116
- ;;
117
- esac
118
- return 1
119
- }
120
-
121
- _tmux_is_known_tui_app() {
122
- local app
123
- app="$(_tmux_normalize_app_from_command "$1")"
124
- case "$app" in
125
- opencode|claude|vim|nvim|vi|lazygit|gitui|tig|top|htop|btop|k9s|fzf|yazi|ranger|nnn|less|man)
126
- return 0
127
- ;;
128
- esac
129
- return 1
130
- }
131
-
132
- _tmux_is_tui_context() {
133
- local pane_cmd="$1"
134
- local alternate_on="$2"
135
- local mouse_any_flag="$3"
136
- local pane_mode="$4"
137
- local window_name="$5"
138
- local pane_title="$6"
139
-
140
- if _tmux_truthy "$alternate_on" || _tmux_truthy "$mouse_any_flag"; then
141
- return 0
142
- fi
143
-
144
- if [[ -n "$pane_mode" ]]; then
145
- return 0
146
- fi
147
-
148
- if _tmux_is_known_tui_app "$pane_cmd"; then
149
- return 0
150
- fi
151
-
152
- local combined
153
- combined="$(printf '%s %s' "$window_name" "$pane_title" | tr '[:upper:]' '[:lower:]')"
154
- case "$combined" in
155
- *opencode*|*claude*|*vim*|*nvim*|*lazygit*|*gitui*|*tig*|*k9s*|*fzf*|*yazi*|*ranger*|*nnn*)
156
- return 0
157
- ;;
158
- esac
159
-
160
- return 1
161
- }
162
-
163
- # Helper: absolute path to the command hook script (if present)
164
- _orchestra_command_hook() {
165
- local hook=""
166
-
167
- # 1) Explicit install root set by wrappers (Homebrew/NPM)
168
- if [[ -n "${GW_ORCHESTRATOR_ROOT-}" ]]; then
169
- hook="$GW_ORCHESTRATOR_ROOT/shell/orchestra-command-hook.sh"
170
- if [[ -f "$hook" ]]; then
171
- echo "$hook"
172
- return
173
- fi
174
- fi
175
-
176
- # 2) Resolve relative to this script's install location
177
- local script_root
178
- script_root="$(cd "$_TMUX_API_DIR/.." && pwd -P)"
179
- hook="$script_root/shell/orchestra-command-hook.sh"
180
- if [[ -f "$hook" ]]; then
181
- echo "$hook"
182
- return
183
- fi
184
-
185
- # 3) Fallback to repo root for legacy/dev flows
186
- local root
187
- root="$(repo_root)"
188
- if [[ -n "$root" ]]; then
189
- hook="$root/shell/orchestra-command-hook.sh"
190
- if [[ -f "$hook" ]]; then
191
- echo "$hook"
192
- return
193
- fi
194
- fi
195
-
196
- echo ""
197
- }
198
-
199
- _orchestra_bridge_script() {
200
- echo "$(dirname "$_TMUX_API_DIR")/gw-bridge.sh"
201
- }
202
-
203
- # Source the command hook inside a tmux session to enable command history logging
204
- _tmux_source_command_hook() {
205
- local session="$1"
206
- local hook
207
- hook="$(_orchestra_command_hook)"
208
- if [[ -z "$hook" ]]; then
209
- return
210
- fi
211
- if ! tmux_session_exists "$session"; then
212
- return
213
- fi
214
- local bridge
215
- bridge="$(_orchestra_bridge_script)"
216
- local panes
217
- panes=$(tmux list-panes -t "$session" -F '#{pane_id} #{pane_current_command}' 2>/dev/null || echo "")
218
- if [[ -z "$panes" ]]; then
219
- return
220
- fi
221
- while IFS= read -r line; do
222
- [[ -z "$line" ]] && continue
223
- local pane_id pane_cmd
224
- pane_id="${line%% *}"
225
- pane_cmd="${line#* }"
226
- case "$pane_cmd" in
227
- bash|zsh)
228
- tmux send-keys -t "$pane_id" "export ORCHESTRA_BRIDGE_PATH='$bridge'; . '$hook'" C-m 2>/dev/null || true
229
- ;;
230
- *)
231
- ;;
232
- esac
233
- done <<< "$panes"
234
- }
235
-
236
- # Helper: orchestra prefix including delimiter
237
- _tmux_orch_prefix() { printf "orchestra%s" "$( _tmux_delim )"; }
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")
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
-
493
- _tmux_status_escape_text() {
494
- local text="$1"
495
- text="${text//\#/##}"
496
- printf '%s' "$text"
497
- }
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_menu_label() {
510
- local label="$1"
511
- local width="${2:-56}"
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 mode="${3:-visible}"
521
- local db_path active_file
522
- db_path="$(_tmux_session_registry_path)"
523
-
524
- [[ -f "$db_path" ]] || return 0
525
- have_cmd python3 || return 0
526
-
527
- active_file="$(mktemp)"
528
- tmux list-sessions -F '#{session_name}' > "$active_file" 2>/dev/null || {
529
- rm -f "$active_file"
530
- return 0
531
- }
532
-
533
- python3 - "$db_path" "$repo_root" "$current_session" "$active_file" "$mode" <<'PY'
534
- import sqlite3
535
- import sys
536
-
537
- db_path, repo_root, current_session, active_path, mode = sys.argv[1:6]
538
- with open(active_path, "r", encoding="utf-8") as handle:
539
- active = {line.strip() for line in handle if line.strip()}
540
-
541
- conn = sqlite3.connect(db_path)
542
- rows = conn.execute(
543
- """
544
- SELECT tmux_name, COALESCE(display_name, '')
545
- FROM sessions
546
- WHERE repo_root = ?1
547
- ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
548
- """,
549
- (repo_root,),
550
- ).fetchall()
551
-
552
- filtered = []
553
- seen = set()
554
- for name, display_name in rows:
555
- if name in active and name not in seen:
556
- filtered.append((name, display_name or ""))
557
- seen.add(name)
558
-
559
- if not filtered:
560
- raise SystemExit(0)
561
-
562
- max_tabs = 8
563
- if mode == "all" or len(filtered) <= max_tabs:
564
- visible = filtered
565
- prefix = suffix = False
566
- else:
567
- try:
568
- current_index = [name for name, _ in filtered].index(current_session)
569
- except ValueError:
570
- current_index = 0
571
- start = max(0, min(current_index - 2, len(filtered) - max_tabs))
572
- end = min(len(filtered), start + max_tabs)
573
- visible = filtered[start:end]
574
- prefix = start > 0
575
- suffix = end < len(filtered)
576
-
577
- if prefix:
578
- print("__ellipsis__\t")
579
- for name, display_name in visible:
580
- print(f"{name}\t{display_name}")
581
- if suffix:
582
- print("__ellipsis__\t")
583
- PY
584
- local status=$?
585
- rm -f "$active_file"
586
- return $status
587
- }
588
-
589
- _tmux_workspace_session_tabs() {
590
- local current_session="$1"
591
- local fallback_display_name="$2"
592
- local session_dir repo_root rows tabs name display_name tab_label escaped_tab_label divider active_style inactive_style muted_style reset_style
593
- local tab_max_width=24
594
- local active_label_max_width=22
595
-
596
- session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
597
- repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
598
- if [[ -n "$repo_root" ]]; then
599
- _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
600
- rows="$(_tmux_workspace_session_rows "$current_session" "$repo_root" 2>/dev/null || true)"
601
- else
602
- rows=""
603
- fi
604
-
605
- if [[ -z "$rows" ]]; then
606
- tab_label="$(_tmux_truncate_tab_label "$fallback_display_name" "$active_label_max_width")"
607
- escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
608
- printf '#[bg=#414868] #[fg=#ff9e64,bg=#414868,bold]● #[fg=#c0caf5,bg=#414868,bold]%s #[default]' "$escaped_tab_label"
609
- return
610
- fi
611
-
612
- # Tokyo Night palette: midnight footer, muted inactive tabs, orange active marker.
613
- active_style="#[fg=#c0caf5,bg=#414868,bold]"
614
- inactive_style="#[fg=#a9b1d6,bg=#24283b,nobold]"
615
- muted_style="#[fg=#565f89,bg=#1a1b26,nobold]"
616
- reset_style="#[default]"
617
- tabs=""
618
- divider="${muted_style}|${reset_style}"
619
- while IFS=$'\t' read -r name display_name; do
620
- [[ -n "$name" ]] || continue
621
- if [[ -n "$tabs" ]]; then
622
- tabs+="$divider"
623
- fi
624
- if [[ "$name" == "__ellipsis__" ]]; then
625
- tab_label="⋯"
626
- escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
627
- tabs+="${muted_style} ${escaped_tab_label} ${reset_style}"
628
- continue
629
- fi
630
-
631
- if [[ -z "$display_name" ]]; then
632
- display_name="$(tmux_format_session_display "$name" without-timestamp)"
633
- fi
634
- if [[ "$name" == "$current_session" ]]; then
635
- tab_label="$(_tmux_truncate_tab_label "$display_name" "$active_label_max_width")"
636
- escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
637
- tabs+="#[bg=#414868] #[fg=#ff9e64,bg=#414868,bold]● ${active_style}${escaped_tab_label} ${reset_style}"
638
- else
639
- tab_label="$(_tmux_truncate_tab_label "$display_name" "$tab_max_width")"
640
- escaped_tab_label="$(_tmux_status_escape_text "$tab_label")"
641
- tabs+="${inactive_style} ${escaped_tab_label} ${reset_style}"
642
- fi
643
- done <<< "$rows"
644
-
645
- printf '%s' "$tabs"
646
- }
647
-
648
- _tmux_current_session_label() {
649
- local session_display_name="$1"
650
- local label escaped_label
651
-
652
- label="$(_tmux_truncate_tab_label "$session_display_name" 28)"
653
- escaped_label="$(_tmux_status_escape_text "$label")"
654
- printf '#[fg=#7aa2f7,bg=#1a1b26,bold] %s #[fg=#565f89,bg=#1a1b26,nobold]│#[default]' "$escaped_label"
655
- }
656
-
657
- _tmux_orchestra_status_left() {
658
- local session_name="$1"
659
- local session_display_name="$3"
660
- printf '%s%s' \
661
- "$(_tmux_current_session_label "$session_display_name")" \
662
- "$(_tmux_workspace_session_tabs "$session_name" "$session_display_name")"
663
- }
664
-
665
- _tmux_orchestra_status_right() {
666
- printf '#[fg=#565f89,bg=#1a1b26]Ctrl+b,h for help#[default]'
667
- }
668
-
669
- _tmux_configure_orchestra_bindings() {
670
- local bridge
671
- bridge="$(_orchestra_bridge_script)"
672
- [[ -f "$bridge" ]] || return
673
-
674
- local quoted_bridge rename_command prompt_command next_command previous_command new_session_command list_command close_command close_prompt_command help_command
675
- printf -v quoted_bridge '%q' "$bridge"
676
- rename_command="$quoted_bridge manual-rename-session \\\"#{session_name}\\\" \\\"%%\\\" >/dev/null 2>&1"
677
- prompt_command="command-prompt -p 'Rename Orchestra session:' 'run-shell -b \"$rename_command\"'"
678
- next_command="$quoted_bridge cycle-workspace-session \\\"#{session_name}\\\" next \\\"#{client_tty}\\\" >/dev/null 2>&1"
679
- previous_command="$quoted_bridge cycle-workspace-session \\\"#{session_name}\\\" previous \\\"#{client_tty}\\\" >/dev/null 2>&1"
680
- new_session_command="$quoted_bridge create-workspace-session \\\"#{session_name}\\\" \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
681
- list_command="$quoted_bridge workspace-session-menu \\\"#{session_name}\\\" \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
682
- close_command="$quoted_bridge close-workspace-session \\\"#{session_name}\\\" \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
683
- close_prompt_command="confirm-before -p 'Close current Orchestra session? (y/n)' 'run-shell -b \"$close_command\"'"
684
- help_command="$quoted_bridge tmux-help-popup \\\"#{client_tty}\\\" >/dev/null 2>&1 || true"
685
-
686
- tmux bind-key -T prefix '?' if-shell -F '#{@orchestra_display_name}' \
687
- "run-shell -b \"$help_command\"" 'list-keys -N' >/dev/null 2>&1 || true
688
- tmux bind-key -T prefix h if-shell -F '#{@orchestra_display_name}' \
689
- "run-shell -b \"$help_command\"" 'refresh-client -S' >/dev/null 2>&1 || true
690
- tmux bind-key -T prefix n if-shell -F '#{@orchestra_display_name}' \
691
- "run-shell -b \"$new_session_command\"" 'next-window' >/dev/null 2>&1 || true
692
- tmux bind-key -T prefix l if-shell -F '#{@orchestra_display_name}' \
693
- "run-shell -b \"$list_command\"" 'last-window' >/dev/null 2>&1 || true
694
- tmux bind-key -T prefix q if-shell -F '#{@orchestra_display_name}' \
695
- "$close_prompt_command" 'display-panes' >/dev/null 2>&1 || true
696
- tmux bind-key -T prefix X if-shell -F '#{@orchestra_display_name}' \
697
- "$close_prompt_command" 'confirm-before -p "kill-session #S? (y/n)" kill-session' >/dev/null 2>&1 || true
698
- tmux bind-key -T prefix r if-shell -F '#{@orchestra_display_name}' \
699
- "$prompt_command" 'refresh-client -S' >/dev/null 2>&1 || true
700
- tmux bind-key -T prefix '>' if-shell -F '#{@orchestra_display_name}' \
701
- "run-shell -b \"$next_command\"" 'switch-client -n' >/dev/null 2>&1 || true
702
- tmux bind-key -T prefix '<' if-shell -F '#{@orchestra_display_name}' \
703
- "run-shell -b \"$previous_command\"" 'switch-client -p' >/dev/null 2>&1 || true
704
- tmux bind-key -r -T prefix Right if-shell -F '#{@orchestra_display_name}' \
705
- "run-shell -b \"$next_command\"" 'select-pane -R' >/dev/null 2>&1 || true
706
- tmux bind-key -r -T prefix Left if-shell -F '#{@orchestra_display_name}' \
707
- "run-shell -b \"$previous_command\"" 'select-pane -L' >/dev/null 2>&1 || true
708
- }
709
-
710
- _tmux_configure_orchestra_status() {
711
- local session_name="$1"
712
- local worktree_name="$2"
713
- local session_display_name="${3:-}"
714
- local status_left status_right
715
- if [[ -z "$session_display_name" ]]; then
716
- session_display_name="$(tmux_format_session_display "$session_name" without-timestamp)"
717
- fi
718
- status_left="$(_tmux_orchestra_status_left "$session_name" "$worktree_name" "$session_display_name")"
719
- status_right="$(_tmux_orchestra_status_right "$worktree_name")"
720
-
721
- tmux set-option -t "$session_name" @orchestra_display_name "$session_display_name" >/dev/null 2>&1 || true
722
- tmux set-option -t "$session_name" @orchestra_worktree_name "$worktree_name" >/dev/null 2>&1 || true
723
- tmux set-option -t "$session_name" status on >/dev/null 2>&1 || true
724
- tmux set-option -t "$session_name" status-position bottom >/dev/null 2>&1 || true
725
- tmux set-option -t "$session_name" status-style "fg=#c0caf5,bg=#1a1b26" >/dev/null 2>&1 || true
726
- tmux set-option -t "$session_name" status-left "$status_left" >/dev/null 2>&1 || true
727
- tmux set-option -t "$session_name" status-left-length 1000 >/dev/null 2>&1 || true
728
- tmux set-option -t "$session_name" status-right "$status_right" >/dev/null 2>&1 || true
729
- tmux set-option -t "$session_name" status-right-length 40 >/dev/null 2>&1 || true
730
- tmux set-option -t "$session_name" window-status-format "" >/dev/null 2>&1 || true
731
- tmux set-option -t "$session_name" window-status-current-format "" >/dev/null 2>&1 || true
732
- tmux set-option -t "$session_name" window-status-separator "" >/dev/null 2>&1 || true
733
- _tmux_configure_orchestra_bindings
734
- }
735
-
736
- _tmux_refresh_orchestra_session_status() {
737
- local session_name="$1"
738
- local session_dir branch_name worktree_name display_name old_pwd
739
-
740
- session_dir="$(tmux display-message -t "$session_name" -p '#{pane_current_path}' 2>/dev/null || echo "")"
741
- branch_name=""
742
- if [[ -n "$session_dir" && -d "$session_dir" ]]; then
743
- old_pwd="$PWD"
744
- cd "$session_dir" 2>/dev/null || true
745
- branch_name="$(git_current_branch 2>/dev/null || git rev-parse --abbrev-ref HEAD 2>/dev/null || true)"
746
- cd "$old_pwd" 2>/dev/null || true
747
- fi
748
-
749
- if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
750
- worktree_name="$branch_name"
751
- elif [[ -n "$session_dir" && -d "$session_dir" ]]; then
752
- worktree_name="$(basename "$session_dir")"
753
- else
754
- worktree_name="$branch_name"
755
- fi
756
-
757
- display_name="$(tmux show-option -t "$session_name" -qv @orchestra_display_name 2>/dev/null || true)"
758
- _tmux_configure_orchestra_status "$session_name" "$worktree_name" "$display_name"
759
- }
760
-
761
- # Helper: split a string by multi-char delimiter into bash array named by ref
762
- # Usage: _tmux_split_by_delim "string" "::" out_array_name
763
- _tmux_split_by_delim() {
764
- local _s="$1" _d="$2" _ref="$3"
765
- local _arr=()
766
- if [[ -z "$_d" ]]; then
767
- _arr=("$_s")
768
- else
769
- while :; do
770
- if [[ "$_s" == *"$_d"* ]]; then
771
- _arr+=("${_s%%"$_d"*}")
772
- _s="${_s#*"$_d"}"
773
- else
774
- _arr+=("$_s")
775
- break
776
- fi
777
- done
778
- fi
779
- # Use printf with %q to properly quote array elements for eval
780
- local _quoted=()
781
- local _item
782
- for _item in "${_arr[@]}"; do
783
- printf -v _q "%q" "$_item"
784
- _quoted+=("$_q")
785
- done
786
- eval "$_ref=( ${_quoted[*]} )"
787
- }
788
-
789
-
790
- # Check if tmux is available
791
- tmux_available() {
792
- have_cmd tmux
793
- }
794
-
795
- # Check if currently inside a tmux session
796
- tmux_inside_session() {
797
- [[ -n "${TMUX-}" ]]
798
- }
799
-
800
- # Generate random readable name for tmux sessions
801
- tmux_generate_readable_name() {
802
- local adjectives=(
803
- "swift" "brave" "clever" "gentle" "bright" "calm" "eager" "fierce" "happy" "kind"
804
- "lively" "noble" "proud" "quick" "smart" "wise" "bold" "cool" "daring" "epic"
805
- "fuzzy" "jolly" "lucky" "merry" "peppy" "rosy" "sunny" "zesty" "crisp" "fresh"
806
- )
807
- local animals=(
808
- "bear" "wolf" "fox" "eagle" "hawk" "lion" "tiger" "panda" "otter" "seal"
809
- "whale" "shark" "dolphin" "falcon" "raven" "deer" "moose" "lynx" "badger" "heron"
810
- "phoenix" "dragon" "griffin" "unicorn" "pegasus" "kraken" "sphinx" "chimera" "hydra" "basilisk"
811
- )
812
-
813
- local adj_idx=$((RANDOM % ${#adjectives[@]}))
814
- local animal_idx=$((RANDOM % ${#animals[@]}))
815
-
816
- echo "${adjectives[$adj_idx]}_${animals[$animal_idx]}"
817
- }
818
-
819
- # --------------------------- Session Management -----------------------------
820
-
821
- # Create a new tmux session
822
- # Usage: tmux_create_session <session_name> <working_directory>
823
- tmux_create_session() {
824
- local session_name="$1"
825
- local working_dir="$2"
826
-
827
- if ! tmux_available; then
828
- err "tmux not installed"
829
- return 1
830
- fi
831
-
832
- # Get repository info from the working directory context
833
- local repo_name=""
834
- local branch_name=""
835
- local repo_root=""
836
- local old_pwd="$PWD"
837
- local resolved_working_dir="$working_dir"
838
- if [[ -n "$working_dir" && -d "$working_dir" ]]; then
839
- resolved_working_dir="$(cd "$working_dir" 2>/dev/null && pwd -P || printf '%s' "$working_dir")"
840
- fi
841
-
842
- # Change to working directory to get accurate git info
843
- cd "$resolved_working_dir" 2>/dev/null || true
844
-
845
- if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
846
- repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
847
- branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
848
- repo_root="$(git_shared_root 2>/dev/null || git_repo_root 2>/dev/null || true)"
849
- fi
850
-
851
- # Restore original directory
852
- cd "$old_pwd" 2>/dev/null || true
853
-
854
- # Ensure orchestra prefix (configurable delimiter) on session name to mark origin
855
- local ORCH_PREFIX
856
- ORCH_PREFIX="$(_tmux_orch_prefix)"
857
- if [[ "$session_name" != ${ORCH_PREFIX}* ]]; then
858
- session_name="${ORCH_PREFIX}${session_name}"
859
- fi
860
-
861
- # Create session with custom Orchestra status configuration
862
- tmux new-session -Ad -s "$session_name" -c "$resolved_working_dir" >/dev/null 2>&1 || {
863
- err "Failed to create tmux session: $session_name"
864
- return 1
865
- }
866
-
867
- local worktree_name=""
868
- if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
869
- worktree_name="$branch_name"
870
- else
871
- worktree_name="$(basename "$resolved_working_dir")"
872
- fi
873
-
874
- if [[ -n "$repo_root" && -n "$branch_name" && "$branch_name" != "detached" ]]; then
875
- _tmux_registry_upsert_session "$repo_root" "$branch_name" "$resolved_working_dir" "$session_name" >/dev/null 2>&1 || true
876
- fi
877
-
878
- # Customize the default status bar to include Orchestra info on the left
879
- if [[ -n "$repo_name" ]]; then
880
- _tmux_configure_orchestra_status "$session_name" "$worktree_name"
881
- fi
882
-
883
- echo "$session_name"
884
- }
885
-
886
- # Generate a short hash from a path for unique session identification
887
- # Usage: tmux_path_hash <path>
888
- tmux_path_hash() {
889
- local path="$1"
890
- # Use MD5 hash of the path, take first 8 chars
891
- # Works on both macOS and Linux
892
- if command -v md5sum >/dev/null 2>&1; then
893
- echo -n "$path" | md5sum | cut -c1-8
894
- elif command -v md5 >/dev/null 2>&1; then
895
- echo -n "$path" | md5 -q | cut -c1-8
896
- else
897
- # Fallback: use cksum if neither md5 available
898
- echo -n "$path" | cksum | cut -d' ' -f1 | cut -c1-8
899
- fi
900
- }
901
-
902
- # Alias for backward compatibility
903
- tmux_repo_hash() {
904
- tmux_path_hash "$1"
905
- }
906
-
907
- # Ensure a session exists for slug+name in worktree; prints session name
908
- # Creates sessions with format: [worktreename]_[worktreetreehash]_[datetime]_[readable_name]
909
- # Usage: tmux_ensure_session <slug> <name> <worktree_path>
910
- tmux_ensure_session() {
911
- local slug="$1"
912
- local name="$2"
913
- local wt="$3"
914
- local d
915
- d="$(_tmux_delim)"
916
- local date_part time_part
917
- date_part="$(date +%Y%m%d)"
918
- time_part="$(date +%H%M%S)"
919
-
920
- # Use a repo-scoped hash to avoid cross-repo collisions
921
- # Hash the absolute worktree path (backward-compat listing supports old slug-hash)
922
- local worktree_hash
923
- worktree_hash="$(tmux_path_hash "$wt")"
924
-
925
- # If name is provided, use it; otherwise generate a random readable name with auto_ prefix
926
- if [[ -z "$name" || "$name" == "main" ]]; then
927
- name="auto_$(tmux_generate_readable_name)"
928
- fi
929
-
930
- local sess="${slug}${d}${worktree_hash}${d}${date_part}${d}${time_part}${d}${name}"
931
- tmux_create_session "$sess" "$wt"
932
- _tmux_source_command_hook "$sess"
933
- }
934
-
935
- # Create a new auto-named Orchestra session in the current session's worktree and switch to it.
936
- # Uses the same tmux_ensure_session path as the TUI tree view create action.
937
- # Usage: tmux_create_workspace_session <current_session> [client_tty]
938
- tmux_create_workspace_session() {
939
- local current_session="$1"
940
- local target_client="${2:-}"
941
- local session_dir branch slug new_session
942
-
943
- if ! tmux_available; then
944
- err "tmux not installed"
945
- return 1
946
- fi
947
- if [[ -z "$current_session" ]]; then
948
- err "tmux_create_workspace_session: current session required"
949
- return 1
950
- fi
951
-
952
- session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
953
- if [[ -z "$session_dir" || ! -d "$session_dir" ]]; then
954
- err "Unable to determine current worktree path"
955
- return 1
956
- fi
957
-
958
- branch="$(_tmux_branch_for_path "$session_dir" 2>/dev/null || true)"
959
- if [[ -z "$branch" || "$branch" == "detached" ]]; then
960
- err "Unable to determine current worktree branch"
961
- return 1
962
- fi
963
-
964
- slug="$(git_branch_to_slug "$branch")"
965
- new_session="$(tmux_ensure_session "$slug" "" "$session_dir")" || return 1
966
-
967
- if [[ -n "$target_client" ]]; then
968
- tmux switch-client -c "$target_client" -t "$new_session" >/dev/null 2>&1 || return 1
969
- else
970
- tmux switch-client -t "$new_session" >/dev/null 2>&1 || return 1
971
- fi
972
-
973
- printf '%s\n' "$new_session"
974
- }
975
-
976
- # Close the current Orchestra session after switching the client to the next one.
977
- # Usage: tmux_close_workspace_session <current_session> [client_tty]
978
- tmux_close_workspace_session() {
979
- local current_session="$1"
980
- local target_client="${2:-}"
981
- local target_session
982
-
983
- if ! tmux_available; then
984
- err "tmux not installed"
985
- return 1
986
- fi
987
- if [[ -z "$current_session" ]]; then
988
- err "tmux_close_workspace_session: current session required"
989
- return 1
990
- fi
991
-
992
- if ! target_session="$(tmux_workspace_cycle_target "$current_session" next)"; then
993
- tmux display-message -d 2500 "No other Orchestra sessions; current session kept" 2>/dev/null || true
994
- return 1
995
- fi
996
-
997
- if [[ -n "$target_client" ]]; then
998
- tmux switch-client -c "$target_client" -t "$target_session" >/dev/null 2>&1 || return 1
999
- else
1000
- tmux switch-client -t "$target_session" >/dev/null 2>&1 || return 1
1001
- fi
1002
-
1003
- tmux_kill_session "$current_session" >/dev/null 2>&1 || return 1
1004
- _tmux_refresh_orchestra_session_status "$target_session" >/dev/null 2>&1 || true
1005
- }
1006
-
1007
- # Check if a session exists
1008
- # Usage: tmux_session_exists <session_name>
1009
- tmux_session_exists() {
1010
- local session_name="$1"
1011
- tmux_available && tmux has-session -t "$session_name" 2>/dev/null
1012
- }
1013
-
1014
- # Kill/delete a tmux session
1015
- # Usage: tmux_kill_session <session_name>
1016
- tmux_kill_session() {
1017
- local session_name="$1"
1018
-
1019
- if ! tmux_available; then
1020
- err "tmux not installed"
1021
- return 1
1022
- fi
1023
-
1024
- tmux kill-session -t "$session_name" 2>/dev/null || {
1025
- err "Failed to kill session: $session_name"
1026
- return 1
1027
- }
1028
- _tmux_registry_remove_session "$session_name" >/dev/null 2>&1 || true
1029
- }
1030
-
1031
- # Attach or switch to session
1032
- # Usage: tmux_attach_session <session_name>
1033
- tmux_attach_session() {
1034
- local sess="$1"
1035
-
1036
- if ! tmux_available; then
1037
- err "tmux not installed"
1038
- return 1
1039
- fi
1040
-
1041
- _tmux_source_command_hook "$sess"
1042
-
1043
- # Get repository info for terminal title and banner update
1044
- local repo_name=""
1045
- local branch_name=""
1046
- local session_dir=""
1047
-
1048
- # Try to get the session's working directory
1049
- session_dir="$(tmux display-message -t "$sess" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1050
-
1051
- if [[ -n "$session_dir" ]] && [[ -d "$session_dir" ]]; then
1052
- # Get git info from session's directory
1053
- local old_pwd="$PWD"
1054
- cd "$session_dir" 2>/dev/null || true
1055
-
1056
- if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
1057
- repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
1058
- branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
1059
- fi
1060
-
1061
- cd "$old_pwd" 2>/dev/null || true
1062
- else
1063
- # Fallback to current directory
1064
- if command -v git >/dev/null 2>&1 && git rev-parse --git-dir >/dev/null 2>&1; then
1065
- repo_name="$(basename "$(git rev-parse --show-toplevel 2>/dev/null)" 2>/dev/null || echo "unknown")"
1066
- branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "detached")"
1067
- fi
1068
- fi
1069
-
1070
- # Set terminal window title (works in most terminal emulators)
1071
- if [[ -n "$repo_name" ]]; then
1072
- printf "\033]0;🎼 Orchestra: %s [%s]\007" "$repo_name" "$branch_name" >/dev/tty 2>/dev/null || true
1073
- fi
1074
-
1075
- # Show welcome message when attaching
1076
- if [[ -n "$repo_name" ]]; then
1077
- tmux display-message -t "$sess" -d 2000 \
1078
- "🎼 Orchestra Session | Repository: $repo_name | Branch: $branch_name" 2>/dev/null || true
1079
- fi
1080
-
1081
- _tmux_refresh_orchestra_session_status "$sess"
1082
-
1083
- if tmux_inside_session; then
1084
- tmux switch-client -t "$sess" >/dev/null 2>&1 || true
1085
- else
1086
- tmux attach -t "$sess" >/dev/null 2>&1 || true
1087
- fi
1088
- }
1089
-
1090
- # Rename a tmux session while preserving the worktree prefix, repo hash, and datetime
1091
- # Usage: tmux_rename_session <old_session_name> <new_name>
1092
- tmux_rename_session() {
1093
- local old_session="$1"
1094
- local new_name="$2"
1095
-
1096
- if ! tmux_available; then
1097
- err "tmux not installed"
1098
- return 1
1099
- fi
1100
-
1101
- local d ORCH_PREFIX
1102
- d="$(_tmux_delim)"
1103
- ORCH_PREFIX="$(_tmux_orch_prefix)"
1104
-
1105
- # Detect and strip orchestra prefix for parsing
1106
- local orch=""
1107
- local base="$old_session"
1108
- if [[ "$base" == ${ORCH_PREFIX}* ]]; then
1109
- orch="$ORCH_PREFIX"
1110
- base="${base#${ORCH_PREFIX}}"
1111
- fi
1112
-
1113
- # Split by current delimiter and scan from the right to tolerate delimiter inside segments
1114
- local parts
1115
- _tmux_split_by_delim "$base" "$d" parts
1116
-
1117
- local n=${#parts[@]}
1118
- local idx_time=-1 idx_date=-1 idx_hash=-1
1119
- local i
1120
- for (( i=n-1; i>=0; i-- )); do
1121
- if [[ ${parts[$i]} =~ ^[0-9]{6}$ ]]; then idx_time=$i; break; fi
1122
- done
1123
- if (( idx_time > 0 )) && [[ ${parts[$((idx_time-1))]} =~ ^[0-9]{8}$ ]]; then
1124
- idx_date=$((idx_time-1))
1125
- fi
1126
- if (( idx_date > 0 )) && [[ ${parts[$((idx_date-1))]} =~ ^[0-9a-f]{8}$ ]]; then
1127
- idx_hash=$((idx_date-1))
1128
- fi
1129
-
1130
- local prefix=""
1131
- if (( idx_time >= 0 && idx_date >= 0 )); then
1132
- # Build prefix: slug + d + [hash + d] + date + d + time + d
1133
- local upto=$idx_time
1134
- local j
1135
- for (( j=0; j<=upto; j++ )); do
1136
- if (( j > 0 )); then prefix+="$d"; fi
1137
- prefix+="${parts[$j]}"
1138
- done
1139
- prefix+="$d"
1140
-
1141
- # Debug logging for rename parsing
1142
- if [[ -n "${GW_DEBUG_RENAME-}" || -n "${DEBUG-}" ]]; then
1143
- >&2 echo "[orchestra] rename DEBUG: old='$old_session' base='$base' delim='$d'"
1144
- >&2 echo "[orchestra] parts: ${parts[*]}"
1145
- >&2 echo "[orchestra] idx_time=$idx_time idx_date=$idx_date idx_hash=$idx_hash"
1146
- >&2 echo "[orchestra] prefix='${orch}${prefix}' new_name='$new_name'"
1147
- fi
1148
- else
1149
- err "Invalid session format"
1150
- return 1
1151
- fi
1152
-
1153
- local new_session="${orch}${prefix}${new_name}"
1154
-
1155
- tmux rename-session -t "$old_session" "$new_session" 2>/dev/null || {
1156
- err "Failed to rename session"
1157
- return 1
1158
- }
1159
- _tmux_registry_rename_session "$old_session" "$new_session" "$(format_session_display_name "$new_name")" >/dev/null 2>&1 || true
1160
- local worktree_name
1161
- worktree_name="$(tmux show-option -t "$new_session" -qv @orchestra_worktree_name 2>/dev/null || true)"
1162
- if [[ -z "$worktree_name" ]]; then
1163
- local session_dir old_pwd branch_name
1164
- session_dir="$(tmux display-message -t "$new_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1165
- if [[ -n "$session_dir" && -d "$session_dir" ]]; then
1166
- old_pwd="$PWD"
1167
- cd "$session_dir" 2>/dev/null || true
1168
- branch_name="$(git rev-parse --abbrev-ref HEAD 2>/dev/null || echo "")"
1169
- cd "$old_pwd" 2>/dev/null || true
1170
- if [[ -n "$branch_name" && "$branch_name" != "detached" ]]; then
1171
- worktree_name="$branch_name"
1172
- else
1173
- worktree_name="$(basename "$session_dir")"
1174
- fi
1175
- fi
1176
- fi
1177
- _tmux_configure_orchestra_status "$new_session" "$worktree_name"
1178
- >&2 echo "✏️ Renamed session to: $new_name"
1179
- return 0
1180
- }
1181
-
1182
- # --------------------------- Session Discovery ------------------------------
1183
-
1184
- # Get tmux sessions for a slug prefix; prints session names sorted by creation time, oldest first
1185
- # Expected session format: [worktreename]_[worktreehash]_[datetime]_[readable_name]
1186
- # Usage: tmux_list_sessions_for_slug <slug> [worktree_path]
1187
- tmux_list_sessions_for_slug() {
1188
- local slug="$1"
1189
- local worktree_path="${2:-}"
1190
- tmux_available || return 0
1191
-
1192
- local d ORCH_PREFIX
1193
- d="$(_tmux_delim)"
1194
- ORCH_PREFIX="$(_tmux_orch_prefix)"
1195
-
1196
- # If worktree_path provided, match hash-based and no-hash prefixes
1197
- if [[ -n "$worktree_path" ]]; then
1198
- local hash_slug hash_path branch_name
1199
- hash_slug="$(tmux_path_hash "$slug")"
1200
- hash_path="$(tmux_path_hash "$worktree_path")"
1201
- branch_name="$(git_worktree_path_to_branch "$worktree_path" 2>/dev/null || true)"
1202
-
1203
- local p1_new="${ORCH_PREFIX}${slug}${d}${hash_slug}${d}"
1204
- local p2_new="${slug}${d}${hash_slug}${d}"
1205
- local p1_old="${ORCH_PREFIX}${slug}${d}${hash_path}${d}"
1206
- local p2_old="${slug}${d}${hash_path}${d}"
1207
- local p1_branch_hash=""
1208
- local p2_branch_hash=""
1209
- if [[ -n "$branch_name" ]]; then
1210
- p1_branch_hash="${ORCH_PREFIX}${branch_name}${d}${hash_path}${d}"
1211
- p2_branch_hash="${branch_name}${d}${hash_path}${d}"
1212
- fi
1213
-
1214
- # List sessions with any known prefix variant
1215
- tmux list-sessions -F '#{session_name}|||#{session_created}' 2>/dev/null \
1216
- | sed 's/|||/\t/g' \
1217
- | awk -v a="$p1_new" -v b="$p2_new" -v c="$p1_old" -v d="$p2_old" -v e="$p1_branch_hash" -v f="$p2_branch_hash" 'BEGIN{FS="\t"} (length(a)>0 && index($1, a)==1) || (length(b)>0 && index($1, b)==1) || (length(c)>0 && index($1, c)==1) || (length(d)>0 && index($1, d)==1) || (length(e)>0 && index($1, e)==1) || (length(f)>0 && index($1, f)==1) {print $1"\t"$2}' \
1218
- | sort -t $'\t' -k2,2n -k1,1f \
1219
- | awk -F '\t' '{print $1}' | awk '!seen[$0]++' || true
1220
- else
1221
- # New-format prefix matching without hash: orchestra__slug__... or slug__...
1222
- local prefix1="${ORCH_PREFIX}${slug}${d}"
1223
- local prefix2="${slug}${d}"
1224
- tmux list-sessions -F '#{session_name}|||#{session_created}' 2>/dev/null \
1225
- | sed 's/|||/\t/g' \
1226
- | awk -v p1="$prefix1" -v p2="$prefix2" 'BEGIN{FS="\t"} index($1, p1)==1 || index($1, p2)==1 {print $1"\t"$2}' \
1227
- | sort -t $'\t' -k2,2n -k1,1f \
1228
- | awk -F '\t' '{print $1}' || true
1229
- fi
1230
- }
1231
-
1232
- # List all tmux sessions
1233
- # Usage: tmux_list_all_sessions
1234
- tmux_list_all_sessions() {
1235
- tmux_available || return 0
1236
- tmux list-sessions -F '#{session_name}' 2>/dev/null || true
1237
- }
1238
-
1239
- # Find sessions matching a pattern
1240
- # Usage: tmux_find_session <pattern>
1241
- tmux_find_session() {
1242
- local pattern="$1"
1243
-
1244
- if ! tmux_available; then
1245
- return 1
1246
- fi
1247
-
1248
- # Try exact match first
1249
- if tmux_session_exists "$pattern"; then
1250
- echo "$pattern"
1251
- return 0
1252
- fi
1253
-
1254
- # Try pattern match
1255
- tmux_list_all_sessions | grep -E "$pattern" | head -1 || true
1256
- }
1257
-
1258
- # --------------------------- Session Information ----------------------------
1259
-
1260
- # Helper function to format session display names
1261
- # Input: session_content (e.g., "opencode_fixing_auth_bug" or "my_feature_work")
1262
- # Output: "Opencode Fixing Auth Bug" or "My Feature Work"
1263
- format_session_display_name() {
1264
- local session_content="$1"
1265
- local d
1266
- d="$(_tmux_delim)"
1267
- local ORCH_PREFIX
1268
- ORCH_PREFIX="$(_tmux_orch_prefix)"
1269
- if [[ "$session_content" == ${ORCH_PREFIX}* ]]; then
1270
- session_content="${session_content#${ORCH_PREFIX}}"
1271
- fi
1272
- if [[ "$session_content" == auto_* ]]; then
1273
- session_content="${session_content#auto_}"
1274
- fi
1275
- local description
1276
- description="$(echo "$session_content" | tr '_-' ' ')"
1277
- description="$(echo "$description" | awk '{for(i=1;i<=NF;i++) $i=toupper(substr($i,1,1)) tolower(substr($i,2));}1')"
1278
- echo "$description"
1279
- }
1280
-
1281
- _tmux_format_session_timestamp() {
1282
- local date_part="$1"
1283
- local time_part="$2"
1284
-
1285
- if [[ ! "$date_part" =~ ^[0-9]{8}$ || ! "$time_part" =~ ^[0-9]{6}$ ]]; then
1286
- printf '%s %s\n' "$date_part" "$time_part"
1287
- return
1288
- fi
1289
-
1290
- local month day hour minute ampm display_hour
1291
- month=$((10#${date_part:4:2}))
1292
- day=$((10#${date_part:6:2}))
1293
- hour=$((10#${time_part:0:2}))
1294
- minute="${time_part:2:2}"
1295
-
1296
- ampm="am"
1297
- display_hour="$hour"
1298
- if (( hour >= 12 )); then
1299
- ampm="pm"
1300
- if (( hour > 12 )); then
1301
- display_hour=$((hour - 12))
1302
- fi
1303
- fi
1304
- if (( hour == 0 )); then
1305
- display_hour=12
1306
- fi
1307
-
1308
- local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
1309
- local month_name="???"
1310
- if (( month > 0 && month <= 12 )); then
1311
- month_name="${month_names[$month]}"
1312
- fi
1313
-
1314
- printf '%s %s %s:%s%s\n' "$month_name" "$day" "$display_hour" "$minute" "$ampm"
1315
- }
1316
-
1317
- # Parse session name and format for display
1318
- # Input: session_name (format: worktreename_repohash_YYYYMMDD_HHMMSS_readable_name or old formats)
1319
- # Output: formatted display name
1320
- # Usage: tmux_format_session_display <session_name>
1321
- tmux_format_session_display() {
1322
- local session_name="$1"
1323
- local timestamp_mode="${2:-with-timestamp}"
1324
-
1325
- # Handle temporary renaming sessions first
1326
- if [[ "$session_name" =~ _renaming$ ]]; then
1327
- # Extract base name before _renaming suffix
1328
- local base_name="${session_name%_renaming}"
1329
- # Recursively process the base name to get proper display
1330
- tmux_format_session_display "$base_name" "$timestamp_mode"
1331
- return $?
1332
- fi
1333
-
1334
- local d ORCH_PREFIX
1335
- d="$(_tmux_delim)"
1336
- ORCH_PREFIX="$(_tmux_orch_prefix)"
1337
-
1338
- if [[ "$session_name" == ${ORCH_PREFIX}* ]]; then
1339
- session_name="${session_name#${ORCH_PREFIX}}"
1340
- elif [[ "$session_name" =~ ^orchestra_(.+)$ ]]; then
1341
- session_name="${BASH_REMATCH[1]}"
1342
- fi
1343
-
1344
- local parts
1345
- _tmux_split_by_delim "$session_name" "$d" parts
1346
- local idx_time=-1 idx_date=-1 i
1347
- for (( i=${#parts[@]}-1; i>=0; i-- )); do
1348
- if [[ ${parts[$i]} =~ ^[0-9]{6}$ ]] && (( i > 0 )) && [[ ${parts[$((i-1))]} =~ ^[0-9]{8}$ ]]; then
1349
- idx_time=$i
1350
- idx_date=$((i-1))
1351
- break
1352
- fi
1353
- done
1354
- if (( idx_time >= 0 && idx_date >= 0 )); then
1355
- local readable_name=""
1356
- local j
1357
- for (( j=idx_time+1; j<${#parts[@]}; j++ )); do
1358
- if [[ -n "$readable_name" ]]; then
1359
- readable_name+="$d"
1360
- fi
1361
- readable_name+="${parts[$j]}"
1362
- done
1363
- if [[ -n "$readable_name" ]]; then
1364
- local formatted_name timestamp
1365
- formatted_name="$(format_session_display_name "$readable_name")"
1366
- timestamp="$(_tmux_format_session_timestamp "${parts[$idx_date]}" "${parts[$idx_time]}")"
1367
- if [[ "$timestamp_mode" == "without-timestamp" ]]; then
1368
- echo "$formatted_name"
1369
- else
1370
- echo "${formatted_name} (${timestamp})"
1371
- fi
1372
- return 0
1373
- fi
1374
- fi
1375
-
1376
- # New format with repo hash: worktreename_repohash_YYYYMMDD_HHMMSS_readable_name
1377
- if [[ "$session_name" =~ ^[^_]+_[a-f0-9]{8}_([0-9]{8})_([0-9]{6})_(.+)$ ]]; then
1378
- local date_part="${BASH_REMATCH[1]}" # YYYYMMDD
1379
- local time_part="${BASH_REMATCH[2]}" # HHMMSS
1380
- local readable_name="${BASH_REMATCH[3]}" # readable_name
1381
-
1382
- # Parse date: YYYYMMDD -> Jul 21
1383
- local year="${date_part:0:4}"
1384
- local month="${date_part:4:2}"
1385
- local day="${date_part:6:2}"
1386
-
1387
- # Parse time: HHMMSS -> 12:30am
1388
- local hour="${time_part:0:2}"
1389
- local minute="${time_part:2:2}"
1390
-
1391
- # Convert to 12-hour format
1392
- local ampm="am"
1393
- local display_hour="$hour"
1394
- if [[ "$hour" -ge 12 ]]; then
1395
- ampm="pm"
1396
- if [[ "$hour" -gt 12 ]]; then
1397
- display_hour=$((hour - 12))
1398
- fi
1399
- fi
1400
- if [[ "$hour" == "00" ]]; then
1401
- display_hour="12"
1402
- fi
1403
-
1404
- # Remove leading zero from hour
1405
- display_hour="${display_hour#0}"
1406
-
1407
- # Convert month number to name
1408
- local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
1409
- local month_name="${month_names[${month#0}]}"
1410
-
1411
- # Remove leading zero from day
1412
- local display_day="${day#0}"
1413
-
1414
- # Format the readable name with proper formatting
1415
- local formatted_name="$(format_session_display_name "$readable_name")"
1416
-
1417
- if [[ "$timestamp_mode" == "without-timestamp" ]]; then
1418
- echo "$formatted_name"
1419
- else
1420
- echo "${formatted_name} (${month_name} ${display_day} ${display_hour}:${minute}${ampm})"
1421
- fi
1422
- # Fallback: Try format without repo hash: appname_description_YYYYMMDD_HHMMSS
1423
- elif [[ "$session_name" =~ ^(.*)_[0-9]{8}_[0-9]{6}$ ]]; then
1424
- local session_content="${BASH_REMATCH[1]}" # appname_description
1425
-
1426
- # Convert to proper display format
1427
- local formatted_name="$(format_session_display_name "$session_content")"
1428
- echo "$formatted_name"
1429
- else
1430
- # Check for old format without repo hash: worktreename_YYYYMMDD_HHMMSS_readable_name
1431
- if [[ "$session_name" =~ ^[^_]+_([0-9]{8})_([0-9]{6})_(.+)$ ]]; then
1432
- local date_part="${BASH_REMATCH[1]}" # YYYYMMDD
1433
- local time_part="${BASH_REMATCH[2]}" # HHMMSS
1434
- local readable_name="${BASH_REMATCH[3]}" # readable_name
1435
-
1436
- # Parse date: YYYYMMDD -> Jul 21
1437
- local year="${date_part:0:4}"
1438
- local month="${date_part:4:2}"
1439
- local day="${date_part:6:2}"
1440
-
1441
- # Parse time: HHMMSS -> 12:30am
1442
- local hour="${time_part:0:2}"
1443
- local minute="${time_part:2:2}"
1444
-
1445
- # Convert to 12-hour format
1446
- local ampm="am"
1447
- local display_hour="$hour"
1448
- if [[ "$hour" -ge 12 ]]; then
1449
- ampm="pm"
1450
- if [[ "$hour" -gt 12 ]]; then
1451
- display_hour=$((hour - 12))
1452
- fi
1453
- fi
1454
- if [[ "$hour" == "00" ]]; then
1455
- display_hour="12"
1456
- fi
1457
-
1458
- # Remove leading zero from hour
1459
- display_hour="${display_hour#0}"
1460
-
1461
- # Convert month number to name
1462
- local month_names=("" "Jan" "Feb" "Mar" "Apr" "May" "Jun" "Jul" "Aug" "Sep" "Oct" "Nov" "Dec")
1463
- local month_name="${month_names[${month#0}]}"
1464
-
1465
- # Remove leading zero from day
1466
- local display_day="${day#0}"
1467
-
1468
- # Format the readable name with proper formatting
1469
- local formatted_name="$(format_session_display_name "$readable_name")"
1470
-
1471
- if [[ "$timestamp_mode" == "without-timestamp" ]]; then
1472
- echo "$formatted_name"
1473
- else
1474
- echo "${formatted_name} (${month_name} ${display_day} ${display_hour}:${minute}${ampm})"
1475
- fi
1476
- else
1477
- # Fallback for sessions that don't match either format
1478
- echo "$session_name"
1479
- fi
1480
- fi
1481
- }
1482
-
1483
- # Get active pane id for a session (best effort)
1484
- # Usage: tmux_get_active_pane <session_name>
1485
- tmux_get_active_pane() {
1486
- local s="$1"
1487
- tmux_available || return 1
1488
-
1489
- # Find active window id
1490
- local win
1491
- win="$(tmux list-windows -t "$s" -F '#{window_active} #{window_id}' 2>/dev/null | awk '$1==1{print $2; exit}')" || true
1492
- [[ -z "$win" ]] && return 1
1493
-
1494
- # Find active pane id within that window
1495
- tmux list-panes -t "$win" -F '#{pane_active} #{pane_id}' 2>/dev/null | awk '$1==1{print $2; exit}' || true
1496
- }
1497
-
1498
- # Capture enhanced session preview showing current terminal view (bottom lines)
1499
- # Usage: tmux_session_preview <session_name>
1500
- tmux_session_preview() {
1501
- local s="$1"
1502
- tmux_available || { echo "(tmux not installed)"; return 0; }
1503
-
1504
- local pane
1505
- pane="$(tmux_get_active_pane "$s" || true)"
1506
- if [[ -z "$pane" ]]; then
1507
- echo "(no active pane found)"
1508
- return 0
1509
- fi
1510
-
1511
- # Always capture from the BOTTOM of the terminal buffer (last visible lines)
1512
- # -e flag preserves escape sequences for colors and formatting
1513
- # -p prints to stdout
1514
- # NO -S/-E flags means capture the current viewport (what's visible NOW)
1515
- # This ensures we always see the bottom/most recent output
1516
- local txt
1517
- txt="$(tmux capture-pane -e -p -t "$pane" 2>/dev/null)"
1518
-
1519
- if [[ -z "$txt" ]]; then
1520
- echo "(no output yet)"
1521
- return 0
1522
- fi
1523
-
1524
- # Check if the pane is idle (cursor at prompt, no running process)
1525
- local pane_cmd pane_mode
1526
- pane_cmd="$(tmux display-message -t "$pane" -p '#{pane_current_command}' 2>/dev/null || echo "")"
1527
- pane_mode="$(tmux display-message -t "$pane" -p '#{pane_mode}' 2>/dev/null || echo "")"
1528
-
1529
- # Detect idle state: shell running and not in copy mode
1530
- local is_idle="false"
1531
- case "$pane_cmd" in
1532
- bash|zsh|sh|fish|dash|ksh)
1533
- # Shell is running, check if we're at a prompt (not in copy mode)
1534
- if [[ "$pane_mode" == "" ]]; then
1535
- is_idle="true"
1536
- fi
1537
- ;;
1538
- esac
1539
-
1540
- # Optimized: Get terminal info in a single command instead of multiple calls
1541
- local term_info has_rgb
1542
- term_info="$(tmux show-environment -t "$s" TERM 2>/dev/null | cut -d= -f2 || echo "unknown")"
1543
-
1544
- # Fast check for RGB support without full grep
1545
- if tmux show-options -t "$s" -s terminal-overrides 2>/dev/null | grep -q "RGB"; then
1546
- has_rgb="true"
1547
- else
1548
- has_rgb="false"
1549
- fi
1550
-
1551
- # For ANSI-preserved preview with color mode info
1552
- # Add markers for the Rust parser to detect color capabilities and idle state
1553
- if [[ "$has_rgb" == "true" || "$term_info" == *"direct"* || "$term_info" == *"truecolor"* ]]; then
1554
- echo "<<<COLORMODE:RGB>>>"
1555
- elif [[ "$term_info" == *"256color"* ]]; then
1556
- echo "<<<COLORMODE:256>>>"
1557
- else
1558
- echo "<<<COLORMODE:16>>>"
1559
- fi
1560
-
1561
- # Add idle marker so Rust can stop polling when terminal is idle
1562
- if [[ "$is_idle" == "true" ]]; then
1563
- echo "<<<IDLE:true>>>"
1564
- else
1565
- echo "<<<IDLE:false>>>"
1566
- fi
1567
-
1568
- # If no content after processing, show placeholder
1569
- if [[ -z "$txt" ]]; then
1570
- echo "(session active, no visible output)"
1571
- else
1572
- echo "$txt"
1573
- fi
1574
- }
1575
-
1576
- # --------------------------- Advanced Operations ----------------------------
1577
-
1578
- # Send keys to a session and press Enter
1579
- # Usage: tmux_send_keys <session_name> <command...>
1580
- tmux_send_keys() {
1581
- local session_name="$1"; shift || true
1582
- local command_line="$*"
1583
- if ! tmux_available; then
1584
- err "tmux not installed"
1585
- return 1
1586
- fi
1587
- if [[ -z "$session_name" || -z "$command_line" ]]; then
1588
- err "tmux_send_keys: session and command required"
1589
- return 1
1590
- fi
1591
- tmux send-keys -t "$session_name" -l -- "$command_line" 2>/dev/null || return 1
1592
- tmux send-keys -t "$session_name" C-m 2>/dev/null || return 1
1593
- }
1594
-
1595
- # Show Orchestra tmux shortcuts in a popup modal.
1596
- # Usage: tmux_show_orchestra_help_popup [client_tty]
1597
- tmux_show_orchestra_help_popup() {
1598
- local target_client="${1:-}"
1599
- if ! tmux_available; then
1600
- err "tmux not installed"
1601
- return 1
1602
- fi
1603
-
1604
- local target_args=()
1605
- if [[ -n "$target_client" ]]; then
1606
- target_args=(-c "$target_client")
1607
- fi
1608
-
1609
- local popup_command
1610
- popup_command="$(cat <<'EOF'
1611
- bash -lc '
1612
- trap "exit 0" INT TERM
1613
- printf "%s\n" \
1614
- "Orchestra tmux shortcuts" \
1615
- "" \
1616
- "Ctrl+b, d Detach and return to Orchestra" \
1617
- "Ctrl+b, r Rename the current Orchestra session" \
1618
- "Ctrl+b, n New Orchestra session in this worktree" \
1619
- "Ctrl+b, q Close current session and switch next" \
1620
- "Ctrl+b, l List active sessions" \
1621
- "" \
1622
- "Ctrl+b, Left Previous Orchestra session in this workspace" \
1623
- "Ctrl+b, Right Next Orchestra session in this workspace" \
1624
- "Ctrl+b, < Previous Orchestra session in this workspace" \
1625
- "Ctrl+b, > Next Orchestra session in this workspace" \
1626
- "" \
1627
- "Ctrl+b, [ Copy/scroll mode" \
1628
- "Ctrl+b, h Show this help" \
1629
- "Ctrl+b, ? Show this help" \
1630
- "" \
1631
- "Press any key to close..."
1632
- IFS= read -rsn1 _ || true
1633
- exit 0
1634
- '
1635
- EOF
1636
- )"
1637
-
1638
- tmux display-popup "${target_args[@]}" \
1639
- -E \
1640
- -w 84 \
1641
- -h 22 \
1642
- -s "fg=#c0caf5,bg=#1a1b26" \
1643
- -S "fg=#7aa2f7,bg=#1a1b26" \
1644
- -T "Orchestra shortcuts" \
1645
- "$popup_command" >/dev/null 2>&1 || true
1646
- }
1647
-
1648
- # Show active Orchestra sessions in a native tmux menu for arrow-key selection.
1649
- # Usage: tmux_show_workspace_session_menu <current_session> [client_tty]
1650
- tmux_show_workspace_session_menu() {
1651
- local current_session="$1"
1652
- local target_client="${2:-}"
1653
- local session_dir repo_root rows name display_name label target_command
1654
- local menu_width=56
1655
- local min_menu_rows=10
1656
- local menu_row_count=0
1657
-
1658
- if ! tmux_available; then
1659
- err "tmux not installed"
1660
- return 1
1661
- fi
1662
- if [[ -z "$current_session" ]]; then
1663
- err "tmux_show_workspace_session_menu: current session required"
1664
- return 1
1665
- fi
1666
-
1667
- session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1668
- repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
1669
- if [[ -z "$repo_root" ]]; then
1670
- tmux display-message -d 2500 "Unable to determine Orchestra workspace" 2>/dev/null || true
1671
- return 1
1672
- fi
1673
-
1674
- _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
1675
- rows="$(_tmux_workspace_session_rows "$current_session" "$repo_root" all 2>/dev/null || true)"
1676
- if [[ -z "$rows" ]]; then
1677
- tmux display-message -d 2500 "No active Orchestra sessions in this workspace" 2>/dev/null || true
1678
- return 1
1679
- fi
1680
-
1681
- local target_args=()
1682
- if [[ -n "$target_client" ]]; then
1683
- target_args=(-c "$target_client")
1684
- fi
1685
-
1686
- local menu_items=()
1687
- while IFS=$'\t' read -r name display_name; do
1688
- [[ -n "$name" && "$name" != "__ellipsis__" ]] || continue
1689
- if [[ -z "$display_name" ]]; then
1690
- display_name="$(tmux_format_session_display "$name" without-timestamp)"
1691
- fi
1692
- label="$(_tmux_truncate_tab_label "$display_name" 42)"
1693
- if [[ "$name" == "$current_session" ]]; then
1694
- label="● $label"
1695
- else
1696
- label=" $label"
1697
- fi
1698
- label="$(_tmux_pad_menu_label "$label" "$menu_width")"
1699
- label="$(_tmux_status_escape_text "$label")"
1700
- target_command="switch-client -t \"$name\""
1701
- menu_items+=("$label" "" "$target_command")
1702
- menu_row_count=$((menu_row_count + 1))
1703
- done <<< "$rows"
1704
-
1705
- if [[ ${#menu_items[@]} -eq 0 ]]; then
1706
- tmux display-message -d 2500 "No active Orchestra sessions in this workspace" 2>/dev/null || true
1707
- return 1
1708
- fi
1709
-
1710
- while (( menu_row_count < min_menu_rows )); do
1711
- menu_items+=("$(_tmux_pad_menu_label "" "$menu_width")" "" "")
1712
- menu_row_count=$((menu_row_count + 1))
1713
- done
1714
-
1715
- tmux display-menu "${target_args[@]}" \
1716
- -s "fg=#c0caf5,bg=#1a1b26" \
1717
- -S "fg=#7aa2f7,bg=#1a1b26" \
1718
- -H "fg=#1a1b26,bg=#ff9e64,bold" \
1719
- -T "Orchestra sessions" \
1720
- "${menu_items[@]}" >/dev/null 2>&1 || true
1721
- }
1722
-
1723
- # Find the adjacent active Orchestra session registered for the current repo.
1724
- # Usage: tmux_workspace_cycle_target <current_session> <next|previous>
1725
- tmux_workspace_cycle_target() {
1726
- local current_session="$1"
1727
- local direction="$2"
1728
- local session_dir repo_root db_path active_file target query_status
1729
-
1730
- if ! tmux_available; then
1731
- err "tmux not installed"
1732
- return 1
1733
- fi
1734
- if [[ -z "$current_session" ]]; then
1735
- err "tmux_workspace_cycle_target: current session required"
1736
- return 1
1737
- fi
1738
- case "$direction" in
1739
- next|previous|prev) ;;
1740
- *)
1741
- err "tmux_workspace_cycle_target: direction must be next or previous"
1742
- return 1
1743
- ;;
1744
- esac
1745
-
1746
- db_path="$(_tmux_session_registry_path)"
1747
- have_cmd python3 || {
1748
- err "python3 is required to read the Orchestra session registry"
1749
- return 1
1750
- }
1751
-
1752
- active_file="$(mktemp)"
1753
- tmux list-sessions -F $'#{session_name}\t#{@orchestra_display_name}\t#{session_last_attached}\t#{session_activity}' > "$active_file" 2>/dev/null || {
1754
- rm -f "$active_file"
1755
- err "Unable to list tmux sessions"
1756
- return 1
1757
- }
1758
-
1759
- if [[ -f "$db_path" ]]; then
1760
- if target="$(_tmux_workspace_cycle_target_cached "$current_session" "$direction" "$db_path" "$active_file")"; then
1761
- query_status=0
1762
- else
1763
- query_status=$?
1764
- fi
1765
- if [[ $query_status -eq 0 && -n "$target" ]]; then
1766
- rm -f "$active_file"
1767
- printf '%s\n' "$target"
1768
- return 0
1769
- fi
1770
- if [[ $query_status -eq 3 ]]; then
1771
- rm -f "$active_file"
1772
- return 1
1773
- fi
1774
- fi
1775
-
1776
- _tmux_registry_upsert_current_session "$current_session" >/dev/null 2>&1 || true
1777
-
1778
- session_dir="$(tmux display-message -t "$current_session" -p '#{pane_current_path}' 2>/dev/null || echo "")"
1779
- repo_root="$(_tmux_shared_root_for_path "$session_dir" 2>/dev/null || true)"
1780
- if [[ -z "$repo_root" ]]; then
1781
- rm -f "$active_file"
1782
- err "Unable to determine Orchestra workspace for session"
1783
- return 1
1784
- fi
1785
- _tmux_registry_sync_active_workspace_sessions "$repo_root" >/dev/null 2>&1 || true
1786
-
1787
- if [[ ! -f "$db_path" ]]; then
1788
- rm -f "$active_file"
1789
- err "No Orchestra session registry found"
1790
- return 1
1791
- fi
1792
-
1793
- if target="$(python3 - "$db_path" "$repo_root" "$current_session" "$direction" "$active_file" <<'PY'
1794
- import sqlite3
1795
- import sys
1796
-
1797
- db_path, repo_root, current_session, direction, active_path = sys.argv[1:6]
1798
- with open(active_path, "r", encoding="utf-8") as handle:
1799
- active = {
1800
- line.rstrip("\n").split("\t", 1)[0].strip()
1801
- for line in handle
1802
- if line.rstrip("\n").split("\t", 1)[0].strip()
1803
- }
1804
-
1805
- conn = sqlite3.connect(db_path)
1806
- rows = conn.execute(
1807
- """
1808
- SELECT tmux_name
1809
- FROM sessions
1810
- WHERE repo_root = ?1
1811
- ORDER BY worktree_path COLLATE NOCASE, created_at, tmux_name COLLATE NOCASE
1812
- """,
1813
- (repo_root,),
1814
- ).fetchall()
1815
-
1816
- names = []
1817
- seen = set()
1818
- for (name,) in rows:
1819
- if name in active and name not in seen:
1820
- names.append(name)
1821
- seen.add(name)
1822
-
1823
- if len(names) < 2 or current_session not in seen:
1824
- raise SystemExit(0)
1825
-
1826
- index = names.index(current_session)
1827
- if direction == "next":
1828
- index = (index + 1) % len(names)
1829
- else:
1830
- index = (index - 1) % len(names)
1831
- print(names[index])
1832
- PY
1833
- )"; then
1834
- query_status=0
1835
- else
1836
- query_status=$?
1837
- fi
1838
- rm -f "$active_file"
1839
- if [[ $query_status -ne 0 ]]; then
1840
- err "Unable to query Orchestra session registry"
1841
- return 1
1842
- fi
1843
- [[ -n "$target" ]] || return 1
1844
- printf '%s\n' "$target"
1845
- }
1846
-
1847
- # Switch the current tmux client to an adjacent registered Orchestra session.
1848
- # Usage: tmux_cycle_workspace_session <current_session> <next|previous> [client_tty]
1849
- tmux_cycle_workspace_session() {
1850
- local current_session="$1"
1851
- local direction="$2"
1852
- local target_client="${3:-}"
1853
- local target_session
1854
-
1855
- if ! target_session="$(tmux_workspace_cycle_target "$current_session" "$direction")"; then
1856
- tmux display-message -d 2500 "No other Orchestra sessions in this workspace" 2>/dev/null || true
1857
- return 1
1858
- fi
1859
-
1860
- if [[ -n "$target_client" ]]; then
1861
- tmux switch-client -c "$target_client" -t "$target_session" >/dev/null 2>&1 || return 1
1862
- else
1863
- tmux switch-client -t "$target_session" >/dev/null 2>&1 || return 1
1864
- fi
1865
-
1866
- {
1867
- _tmux_refresh_orchestra_session_status "$target_session" >/dev/null 2>&1 || true
1868
- } &
1869
- }
1870
-
1871
- # Load .env file if it exists
1872
- # Usage: tmux_load_env_file [env_file_path]
1873
- tmux_load_env_file() {
1874
- local env_file="${1:-$PWD/.env}"
1875
- if [[ -f "$env_file" ]]; then
1876
- # Source the .env file, but only export ANTHROPIC_API_KEY
1877
- set -a # Auto-export variables
1878
- source "$env_file"
1879
- set +a # Turn off auto-export
1880
- fi
1881
- }
1882
-
1883
- # Load Anthropic API key from config file or fallback to .env files
1884
- tmux_load_anthropic_api_key() {
1885
- # First try to load from ~/.orchestra/config.json
1886
- local config_file="$HOME/.orchestra/config.json"
1887
- if [[ -f "$config_file" ]] && command -v jq >/dev/null 2>&1; then
1888
- local api_key
1889
- api_key="$(jq -r '.anthropic_api_key // empty' "$config_file" 2>/dev/null)"
1890
- if [[ -n "$api_key" ]] && [[ "$api_key" != "null" ]]; then
1891
- export ANTHROPIC_API_KEY="$api_key"
1892
- return 0
1893
- fi
1894
- fi
1895
-
1896
- # Fallback to .env file loading (existing logic)
1897
- tmux_load_env_file "$PWD/.env"
1898
-
1899
- # If still no API key, try repo root
1900
- if [[ -z "${ANTHROPIC_API_KEY-}" ]]; then
1901
- local root
1902
- root="$(repo_root)"
1903
- if [[ -n "$root" ]]; then
1904
- tmux_load_env_file "$root/.env"
1905
- fi
1906
- fi
1907
- }
1908
-
1909
- # Load OpenAI API key from config file or fallback to .env files
1910
- tmux_load_openai_api_key() {
1911
- local config_file="$HOME/.orchestra/config.json"
1912
- if [[ -f "$config_file" ]]; then
1913
- local api_key=""
1914
- if command -v jq >/dev/null 2>&1; then
1915
- api_key="$(jq -r '.openai_api_key // empty' "$config_file" 2>/dev/null)"
1916
- elif command -v python3 >/dev/null 2>&1; then
1917
- api_key="$(python3 - "$config_file" <<'PY'
1918
- import json
1919
- import sys
1920
- try:
1921
- data = json.load(open(sys.argv[1], 'r'))
1922
- except Exception:
1923
- data = {}
1924
- value = data.get('openai_api_key') or ''
1925
- print(value)
1926
- PY
1927
- )"
1928
- elif command -v node >/dev/null 2>&1; then
1929
- api_key="$(node - "$config_file" <<'NODE'
1930
- const fs = require('fs');
1931
- let value = '';
1932
- try {
1933
- const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1934
- value = data.openai_api_key || '';
1935
- } catch (err) {
1936
- value = '';
1937
- }
1938
- process.stdout.write(value);
1939
- NODE
1940
- )"
1941
- fi
1942
- if [[ -n "$api_key" ]] && [[ "$api_key" != "null" ]]; then
1943
- export OPENAI_API_KEY="$api_key"
1944
- return 0
1945
- fi
1946
- fi
1947
-
1948
- tmux_load_env_file "$PWD/.env"
1949
- if [[ -z "${OPENAI_API_KEY-}" ]]; then
1950
- local root
1951
- root="$(repo_root)"
1952
- if [[ -n "$root" ]]; then
1953
- tmux_load_env_file "$root/.env"
1954
- fi
1955
- fi
1956
- }
1957
-
1958
- # Load primary AI provider from config (defaults to anthropic)
1959
- tmux_load_ai_primary_provider() {
1960
- if [[ -n "${AI_PRIMARY_PROVIDER-}" ]]; then
1961
- return 0
1962
- fi
1963
- local config_file="$HOME/.orchestra/config.json"
1964
- local provider=""
1965
- if [[ -f "$config_file" ]]; then
1966
- if command -v jq >/dev/null 2>&1; then
1967
- provider="$(jq -r '.ai_primary_provider // empty' "$config_file" 2>/dev/null)"
1968
- elif command -v python3 >/dev/null 2>&1; then
1969
- provider="$(python3 - "$config_file" <<'PY'
1970
- import json
1971
- import sys
1972
- try:
1973
- data = json.load(open(sys.argv[1], 'r'))
1974
- except Exception:
1975
- data = {}
1976
- value = data.get('ai_primary_provider') or ''
1977
- print(value)
1978
- PY
1979
- )"
1980
- elif command -v node >/dev/null 2>&1; then
1981
- provider="$(node - "$config_file" <<'NODE'
1982
- const fs = require('fs');
1983
- let value = '';
1984
- try {
1985
- const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
1986
- value = data.ai_primary_provider || '';
1987
- } catch (err) {
1988
- value = '';
1989
- }
1990
- process.stdout.write(value);
1991
- NODE
1992
- )"
1993
- fi
1994
- fi
1995
- provider="$(printf '%s' "$provider" | tr '[:upper:]' '[:lower:]')"
1996
- if [[ "$provider" != "openai" && "$provider" != "anthropic" ]]; then
1997
- provider="anthropic"
1998
- fi
1999
- export AI_PRIMARY_PROVIDER="$provider"
2000
- }
2001
-
2002
- # Load OpenAI model from config (defaults to gpt-4o-mini)
2003
- tmux_load_openai_model() {
2004
- if [[ -n "${OPENAI_MODEL-}" ]]; then
2005
- return 0
2006
- fi
2007
- local config_file="$HOME/.orchestra/config.json"
2008
- local model=""
2009
- if [[ -f "$config_file" ]]; then
2010
- if command -v jq >/dev/null 2>&1; then
2011
- model="$(jq -r '.openai_model // empty' "$config_file" 2>/dev/null)"
2012
- elif command -v python3 >/dev/null 2>&1; then
2013
- model="$(python3 - "$config_file" <<'PY'
2014
- import json
2015
- import sys
2016
- try:
2017
- data = json.load(open(sys.argv[1], 'r'))
2018
- except Exception:
2019
- data = {}
2020
- value = data.get('openai_model') or ''
2021
- print(value)
2022
- PY
2023
- )"
2024
- elif command -v node >/dev/null 2>&1; then
2025
- model="$(node - "$config_file" <<'NODE'
2026
- const fs = require('fs');
2027
- let value = '';
2028
- try {
2029
- const data = JSON.parse(fs.readFileSync(process.argv[2], 'utf8'));
2030
- value = data.openai_model || '';
2031
- } catch (err) {
2032
- value = '';
2033
- }
2034
- process.stdout.write(value);
2035
- NODE
2036
- )"
2037
- fi
2038
- fi
2039
- if [[ -z "$model" || "$model" == "null" ]]; then
2040
- model="gpt-4o-mini"
2041
- fi
2042
- export OPENAI_MODEL="$model"
2043
- }
2044
-
2045
- # Generate a descriptive name for a tmux session using AI
2046
- # Usage: tmux_generate_ai_session_name <session_name>
2047
- tmux_generate_ai_session_name() {
2048
- local session="$1"
2049
-
2050
- # Load AI provider config and API keys
2051
- tmux_load_ai_primary_provider
2052
- tmux_load_openai_model
2053
- tmux_load_anthropic_api_key
2054
- tmux_load_openai_api_key
2055
-
2056
- local primary="${AI_PRIMARY_PROVIDER:-anthropic}"
2057
- primary="$(printf '%s' "$primary" | tr '[:upper:]' '[:lower:]')"
2058
- local provider=""
2059
- if [[ "$primary" == "openai" ]]; then
2060
- if [[ -n "${OPENAI_API_KEY-}" ]]; then
2061
- provider="openai"
2062
- elif [[ -n "${ANTHROPIC_API_KEY-}" ]]; then
2063
- provider="anthropic"
2064
- fi
2065
- else
2066
- if [[ -n "${ANTHROPIC_API_KEY-}" ]]; then
2067
- provider="anthropic"
2068
- elif [[ -n "${OPENAI_API_KEY-}" ]]; then
2069
- provider="openai"
2070
- fi
2071
- fi
2072
-
2073
- if [[ -z "$provider" ]]; then
2074
- err "No AI API key found in config file or .env file"
2075
- return 1
2076
- fi
2077
-
2078
- local openai_model="${OPENAI_MODEL:-gpt-4o-mini}"
2079
-
2080
- local pane_cmd pane_mode window_name pane_title alternate_on mouse_any_flag
2081
- pane_cmd="$(tmux display-message -t "$session" -p '#{pane_current_command}' 2>/dev/null || echo "")"
2082
- pane_mode="$(tmux display-message -t "$session" -p '#{pane_mode}' 2>/dev/null || echo "")"
2083
- window_name="$(tmux display-message -t "$session" -p '#{window_name}' 2>/dev/null || echo "")"
2084
- pane_title="$(tmux display-message -t "$session" -p '#{pane_title}' 2>/dev/null || echo "")"
2085
- alternate_on="$(tmux display-message -t "$session" -p '#{alternate_on}' 2>/dev/null || echo "0")"
2086
- mouse_any_flag="$(tmux display-message -t "$session" -p '#{mouse_any_flag}' 2>/dev/null || echo "0")"
2087
-
2088
- local history_temp=""
2089
- local history_arg=""
2090
- local history_dir="${ORCHESTRA_HISTORY_DIR:-$HOME/.orchestra/history}"
2091
- local pane_id
2092
- pane_id="$(tmux display-message -t "$session" -p '#{pane_id}' 2>/dev/null || echo "")"
2093
- local pane_key=""
2094
- if [[ -n "$pane_id" ]]; then
2095
- pane_key="$(_orchestra_history_key "$pane_id")"
2096
- fi
2097
- local session_key
2098
- session_key="$(_orchestra_history_key "$session")"
2099
-
2100
- local is_tui="false"
2101
- if _tmux_is_tui_context "$pane_cmd" "$alternate_on" "$mouse_any_flag" "$pane_mode" "$window_name" "$pane_title"; then
2102
- is_tui="true"
2103
- fi
2104
-
2105
- local content=""
2106
- local visible_content=""
2107
- if [[ "$is_tui" == "true" ]]; then
2108
- visible_content="$(tmux capture-pane -e -p -q -a -t "${pane_id:-$session}" 2>/dev/null || echo "")"
2109
- fi
2110
- local scrollback_content
2111
- scrollback_content="$(tmux capture-pane -t "$session" -p -S -200 2>/dev/null)" || {
2112
- err "Failed to capture tmux pane content"
2113
- return 1
2114
- }
2115
- if [[ -n "$visible_content" ]]; then
2116
- content="### Visible terminal view\n${visible_content}\n\n### Recent scrollback\n${scrollback_content}"
2117
- else
2118
- content="$scrollback_content"
2119
- fi
2120
-
2121
- local history_path=""
2122
- if [[ -n "$pane_key" && -f "$history_dir/$pane_key.log" ]]; then
2123
- history_path="$history_dir/$pane_key.log"
2124
- elif [[ -n "$session_key" && -f "$history_dir/$session_key.log" ]]; then
2125
- history_path="$history_dir/$session_key.log"
2126
- fi
2127
-
2128
- if [[ -n "$history_path" ]]; then
2129
- if [[ "$OSTYPE" == "darwin"* ]]; then
2130
- history_temp=$(mktemp -t gw_hist)
2131
- else
2132
- history_temp=$(mktemp)
2133
- fi
2134
- tail -n 50 "$history_path" > "$history_temp"
2135
- history_arg="$history_temp"
2136
- fi
2137
-
2138
- local metadata_block=""
2139
- metadata_block+="Session metadata:\n"
2140
- metadata_block+="- pane_current_command: ${pane_cmd:-unknown}\n"
2141
- metadata_block+="- window_name: ${window_name:-}\n"
2142
- metadata_block+="- pane_title: ${pane_title:-}\n"
2143
- metadata_block+="- pane_mode: ${pane_mode:-}\n"
2144
- metadata_block+="- alternate_screen_active: ${alternate_on:-0}\n"
2145
- metadata_block+="- mouse_mode_active: ${mouse_any_flag:-0}\n"
2146
- metadata_block+="- likely_tui_app: ${is_tui}\n"
2147
-
2148
- # If content is empty or too short, keep a placeholder
2149
- if [[ ${#content} -lt 10 ]]; then
2150
- content="(no terminal output captured)"
2151
- fi
2152
-
2153
- # Truncate content if too long (to stay within token limits)
2154
- if [[ ${#content} -gt 8000 ]]; then
2155
- content="${content: -8000}"
2156
- fi
2157
-
2158
- # Create a temporary file for the content to avoid escaping issues
2159
- # mktemp works differently on macOS vs Linux
2160
- local temp_file
2161
- if [[ "$OSTYPE" == "darwin"* ]]; then
2162
- temp_file=$(mktemp -t gw_tmp)
2163
- else
2164
- temp_file=$(mktemp)
2165
- fi
2166
- printf '%s' "$content" > "$temp_file"
2167
-
2168
- # Prepare the API request using Python for proper JSON encoding
2169
- local request_body=""
2170
- if have_cmd python3; then
2171
- # Use Python to safely build the request body and extract typed commands from the capture
2172
- request_payload=$(
2173
- python3 - "$temp_file" "$history_arg" "$provider" "$openai_model" "$pane_cmd" "$window_name" "$pane_title" "$pane_mode" "$alternate_on" "$mouse_any_flag" "$is_tui" 2>/dev/null <<'PYCODE'
2174
- import json
2175
- import re
2176
- import sys
2177
- from pathlib import Path
2178
-
2179
- temp_path = Path(sys.argv[1])
2180
- content = temp_path.read_text()
2181
- history_arg = sys.argv[2] if len(sys.argv) > 2 else ""
2182
- provider = sys.argv[3] if len(sys.argv) > 3 else "anthropic"
2183
- openai_model = sys.argv[4] if len(sys.argv) > 4 and sys.argv[4] else "gpt-4o-mini"
2184
- pane_cmd = sys.argv[5] if len(sys.argv) > 5 else ""
2185
- window_name = sys.argv[6] if len(sys.argv) > 6 else ""
2186
- pane_title = sys.argv[7] if len(sys.argv) > 7 else ""
2187
- pane_mode = sys.argv[8] if len(sys.argv) > 8 else ""
2188
- alternate_on = sys.argv[9] if len(sys.argv) > 9 else "0"
2189
- mouse_any_flag = sys.argv[10] if len(sys.argv) > 10 else "0"
2190
- is_tui = sys.argv[11] if len(sys.argv) > 11 else "false"
2191
-
2192
- lines = content.splitlines()
2193
- command_candidates = [] # Commands captured with high confidence
2194
- fallback_candidates = [] # All prompt-like lines (used if we fail to classify)
2195
-
2196
- history_commands = []
2197
- if history_arg:
2198
- hist_path = Path(history_arg)
2199
- if hist_path.exists():
2200
- for line in hist_path.read_text().splitlines():
2201
- line = line.strip()
2202
- if not line:
2203
- continue
2204
- if "\t" in line:
2205
- history_commands.append(line.split("\t", 1)[1])
2206
- else:
2207
- history_commands.append(line)
2208
- history_commands = history_commands[-50:]
2209
-
2210
- prompt_pattern = re.compile(r"^[A-Za-z0-9_.@~/-]+$")
2211
- prompt_sigil_pattern = re.compile(r"^(?P<prompt>[^\t]{0,120}?)(?P<sigil>[$#%])\s+(?P<cmd>.+)$")
2212
- simple_sigil_pattern = re.compile(r"^(?P<sigil>[$#%])\s+(?P<cmd>.+)$")
2213
- disallowed_prompts = {
2214
- "warning",
2215
- "error",
2216
- "fatal",
2217
- "hint",
2218
- "note",
2219
- "usage",
2220
- "info",
2221
- "debug",
2222
- "trace",
2223
- }
2224
-
2225
- disallowed_first_tokens = {
2226
- "warning",
2227
- "error",
2228
- "fatal",
2229
- "hint",
2230
- "note",
2231
- "usage",
2232
- "not",
2233
- "see",
2234
- "for",
2235
- "from",
2236
- "with",
2237
- "and",
2238
- "or",
2239
- "but",
2240
- "at",
2241
- "to",
2242
- "in",
2243
- "info",
2244
- "debug",
2245
- "trace",
2246
- }
2247
-
2248
- common_commands = {
2249
- "git",
2250
- "gh",
2251
- "npm",
2252
- "pnpm",
2253
- "yarn",
2254
- "node",
2255
- "npx",
2256
- "bun",
2257
- "cargo",
2258
- "go",
2259
- "python",
2260
- "python3",
2261
- "pip",
2262
- "pip3",
2263
- "poetry",
2264
- "pipenv",
2265
- "pytest",
2266
- "uvicorn",
2267
- "gunicorn",
2268
- "flask",
2269
- "django-admin",
2270
- "rails",
2271
- "bundle",
2272
- "rake",
2273
- "mix",
2274
- "make",
2275
- "cmake",
2276
- "gradle",
2277
- "mvn",
2278
- "ant",
2279
- "docker",
2280
- "docker-compose",
2281
- "kubectl",
2282
- "helm",
2283
- "terraform",
2284
- "ansible",
2285
- "ssh",
2286
- "scp",
2287
- "rsync",
2288
- "sftp",
2289
- "psql",
2290
- "mysql",
2291
- "mongo",
2292
- "redis-cli",
2293
- "sqlite3",
2294
- "composer",
2295
- "php",
2296
- "ruby",
2297
- "java",
2298
- "javac",
2299
- "deno",
2300
- "dotnet",
2301
- "msbuild",
2302
- "tsc",
2303
- "nx",
2304
- "lerna",
2305
- "eslint",
2306
- "prettier",
2307
- "ls",
2308
- "cd",
2309
- "pwd",
2310
- "cat",
2311
- "tail",
2312
- "head",
2313
- "less",
2314
- "more",
2315
- "grep",
2316
- "rg",
2317
- "fd",
2318
- "find",
2319
- "watch",
2320
- "code",
2321
- "open",
2322
- "vim",
2323
- "nvim",
2324
- "tmux",
2325
- "htop",
2326
- "top",
2327
- "brew",
2328
- "tox",
2329
- }
2330
-
2331
- allowed_prefixes = (
2332
- "./",
2333
- "../",
2334
- "~/",
2335
- "bin/",
2336
- "sbin/",
2337
- )
2338
-
2339
-
2340
- def clean_tokens(tokens):
2341
- while tokens and tokens[0] in {"$", "#", "%"}:
2342
- tokens = tokens[1:]
2343
- while tokens and (tokens[0].endswith("$") or tokens[0].endswith("#") or tokens[0].endswith("%")):
2344
- tokens = tokens[1:]
2345
- return tokens
2346
-
2347
-
2348
- def extract_prompt_and_command(raw_line):
2349
- stripped = raw_line.strip()
2350
- if not stripped:
2351
- return "", ""
2352
-
2353
- # Pattern 1: host:path:command (common bash/zsh prompts)
2354
- if ":" in stripped:
2355
- maybe_prompt, maybe_cmd = stripped.split(":", 1)
2356
- maybe_prompt = maybe_prompt.strip().rstrip("#$%")
2357
- maybe_cmd = maybe_cmd.strip()
2358
- if maybe_prompt and maybe_cmd and prompt_pattern.match(maybe_prompt):
2359
- prompt_lower = maybe_prompt.lower()
2360
- if prompt_lower not in disallowed_prompts:
2361
- return maybe_prompt, maybe_cmd
2362
-
2363
- # Pattern 2: prompt ending with $, #, % (supports prompts without ':')
2364
- match = prompt_sigil_pattern.match(stripped)
2365
- if match:
2366
- maybe_prompt = (match.group("prompt") or "").strip().rstrip("#$%")
2367
- maybe_cmd = (match.group("cmd") or "").strip()
2368
- if maybe_cmd:
2369
- if maybe_prompt and maybe_prompt.lower() in disallowed_prompts:
2370
- return "", ""
2371
- return (maybe_prompt if maybe_prompt else match.group("sigil")), maybe_cmd
2372
-
2373
- # Pattern 3: bare prompt lines like "$ git status"
2374
- match = simple_sigil_pattern.match(stripped)
2375
- if match:
2376
- maybe_cmd = (match.group("cmd") or "").strip()
2377
- if maybe_cmd:
2378
- return match.group("sigil"), maybe_cmd
2379
-
2380
- return "", ""
2381
-
2382
-
2383
- for raw_line in lines:
2384
- prompt_part, command_part = extract_prompt_and_command(raw_line)
2385
- if not command_part:
2386
- continue
2387
-
2388
- tokens = clean_tokens(command_part.split())
2389
- if not tokens:
2390
- continue
2391
-
2392
- first_token = tokens[0].lower()
2393
- if first_token == "sudo" and len(tokens) > 1:
2394
- first_token = tokens[1].lower()
2395
-
2396
- if first_token in disallowed_first_tokens:
2397
- continue
2398
-
2399
- normalized_command = " ".join(tokens)
2400
- formatted_line = f"{prompt_part}:{normalized_command}"
2401
- fallback_candidates.append(formatted_line)
2402
-
2403
- allowed = (
2404
- first_token in common_commands
2405
- or normalized_command.startswith(allowed_prefixes)
2406
- or any(
2407
- first_token.startswith(prefix)
2408
- for prefix in (
2409
- "git",
2410
- "npm",
2411
- "pnpm",
2412
- "yarn",
2413
- "node",
2414
- "npx",
2415
- "bun",
2416
- "cargo",
2417
- "python",
2418
- "pip",
2419
- "poetry",
2420
- "pytest",
2421
- "uvicorn",
2422
- "docker",
2423
- "kubectl",
2424
- "helm",
2425
- "terraform",
2426
- "ansible",
2427
- "ssh",
2428
- "scp",
2429
- "rsync",
2430
- "rails",
2431
- "bundle",
2432
- "rake",
2433
- "mix",
2434
- "psql",
2435
- "mysql",
2436
- "mongo",
2437
- "redis",
2438
- )
2439
- )
2440
- )
2441
-
2442
- if allowed:
2443
- command_candidates.append(formatted_line)
2444
-
2445
-
2446
- if history_commands:
2447
- command_candidates = history_commands
2448
- elif not command_candidates:
2449
- command_candidates = fallback_candidates
2450
-
2451
- extracted_commands = command_candidates
2452
- history_lines = [f"{idx + 1}. {line}" for idx, line in enumerate(extracted_commands)]
2453
- command_history = "\n".join(history_lines) if history_lines else "None detected."
2454
- last_command_line = extracted_commands[-1] if extracted_commands else ""
2455
- commands_detected = bool(extracted_commands)
2456
-
2457
- # Capture the first 10 lines of output as fallback context (joined by newline)
2458
- first_ten_lines = "\n".join(lines[:10]) if lines else ""
2459
-
2460
- description_guidance = """Description rules:
2461
- - Describe the primary activity in a short phrase (3-8 words).
2462
- - Do NOT include any app/tool name or prefix in the output.
2463
- - If git activity is visible, the description should reflect it (e.g., reviewing changes), but do not add 'git_'."""
2464
-
2465
- metadata_summary = "\n".join(
2466
- line
2467
- for line in [
2468
- f"pane_current_command: {pane_cmd}" if pane_cmd else "",
2469
- f"window_name: {window_name}" if window_name else "",
2470
- f"pane_title: {pane_title}" if pane_title else "",
2471
- f"pane_mode: {pane_mode}" if pane_mode else "",
2472
- f"alternate_screen_active: {alternate_on}",
2473
- f"mouse_mode_active: {mouse_any_flag}",
2474
- f"likely_tui_app: {is_tui}",
2475
- ]
2476
- if line
2477
- )
2478
-
2479
- prompt = f"""Analyze this terminal session and create a descriptive tmux session name based on what the user actually did.
2480
-
2481
- Terminal output (last capture):
2482
- {content}
2483
-
2484
- Tmux metadata (use as supporting hints, not as the primary source of truth):
2485
- {metadata_summary}
2486
-
2487
- Extracted command history (oldest to newest):
2488
- {command_history}
2489
-
2490
- Most recent command (anchor for description and summary):
2491
- {last_command_line if last_command_line else 'None detected'}
2492
-
2493
- Focus: produce a concise description of the activity. Follow these rules:
2494
- {description_guidance}
2495
-
2496
- {'No clear prompt/command lines were detected. Rely on the terminal output context.' if not commands_detected else ''}
2497
-
2498
- {'An interactive TUI is likely running. Prefer the visible screen content plus recent command history over generic app names.' if is_tui.lower() in {'1', 'true', 'yes', 'on'} else ''}
2499
-
2500
- {'First 10 lines of terminal output (useful for identifying running app/service):\n' + first_ten_lines if not commands_detected and first_ten_lines else ''}
2501
-
2502
- If no commands were detected, fall back to terminal output analysis but describe the main activity in the name. Identify the dominant application or process implied by the output (e.g., web server, test runner, build tool).
2503
-
2504
- Instructions:
2505
- 1. Produce a session name using lowercase letters, numbers, and underscores only.
2506
- 2. Maximum length: 100 characters.
2507
- 3. Do NOT include any app/tool name or prefix.
2508
- 4. Describe the concrete task or state in a short phrase.
2509
- 5. If git activity is visible anywhere, the description must reflect git activity.
2510
- 6. Respond with ONLY the final session name, nothing else."""
2511
-
2512
- if provider == "openai":
2513
- request = {
2514
- "model": openai_model,
2515
- "max_tokens": 100,
2516
- "temperature": 0.2,
2517
- "messages": [
2518
- {
2519
- "role": "user",
2520
- "content": prompt
2521
- }
2522
- ]
2523
- }
2524
- else:
2525
- request = {
2526
- "model": "claude-3-5-haiku-latest",
2527
- "max_tokens": 100,
2528
- "messages": [
2529
- {
2530
- "role": "user",
2531
- "content": prompt
2532
- }
2533
- ]
2534
- }
2535
-
2536
- print(json.dumps(request))
2537
- PYCODE
2538
- )
2539
- request_body="$request_payload"
2540
- fi
2541
- if [[ -z "$request_body" ]]; then
2542
- # Fallback: base64 encode the content to avoid escaping issues
2543
- local encoded_content
2544
- local fallback_file
2545
- if [[ "$OSTYPE" == "darwin"* ]]; then
2546
- fallback_file=$(mktemp -t gw_fb)
2547
- else
2548
- fallback_file=$(mktemp)
2549
- fi
2550
- {
2551
- printf '%b\n\n' "$metadata_block"
2552
- cat "$temp_file"
2553
- } > "$fallback_file"
2554
- encoded_content=$(base64 < "$fallback_file" | tr -d '\n')
2555
- rm -f "$fallback_file"
2556
- if [[ "$provider" == "openai" ]]; then
2557
- request_body=$(cat <<EOF
2558
- {
2559
- "model": "$openai_model",
2560
- "max_tokens": 100,
2561
- "temperature": 0.2,
2562
- "messages": [
2563
- {
2564
- "role": "user",
2565
- "content": "I'll send you base64-encoded terminal output. Please decode it, analyze it, and create a descriptive name for this tmux session.\n\nIMPORTANT: Use terminal metadata only as supporting hints. Prioritize the visible terminal content and recent command history.\n\nCRITICAL: Check for Git patterns FIRST:\n- Git commands: git status, diff, add, commit, push, pull, checkout, branch, merge, rebase, log, stash\n- Git output: 'On branch', 'Changes not staged', 'modified:', 'new file:', diff output (+/-/@@), commit hashes\n- If ANY Git patterns found, the description should reflect git activity (but do not add 'git_').\n\nDESCRIPTION ONLY: Return a short description of the activity.\n- Do NOT include the app/tool name or any prefix in the output.\n\nUse underscores only. Max 100 chars total.\n\nExamples:\n- reviewing_changes_before_commit\n- resolving_merge_conflicts\n- fixing_auth_bug\n- running_nextjs_dev_server\n\nBase64 terminal output:\n${encoded_content}\n\nRespond with ONLY the session name, nothing else."
2566
- }
2567
- ]
2568
- }
2569
- EOF
2570
- )
2571
- else
2572
- request_body=$(cat <<EOF
2573
- {
2574
- "model": "claude-3-5-haiku-latest",
2575
- "max_tokens": 100,
2576
- "messages": [
2577
- {
2578
- "role": "user",
2579
- "content": "I'll send you base64-encoded terminal output. Please decode it, analyze it, and create a descriptive name for this tmux session.\n\nIMPORTANT: Use terminal metadata only as supporting hints. Prioritize the visible terminal content and recent command history.\n\nCRITICAL: Check for Git patterns FIRST:\n- Git commands: git status, diff, add, commit, push, pull, checkout, branch, merge, rebase, log, stash\n- Git output: 'On branch', 'Changes not staged', 'modified:', 'new file:', diff output (+/-/@@), commit hashes\n- If ANY Git patterns found, the description should reflect git activity (but do not add 'git_').\n\nDESCRIPTION ONLY: Return a short description of the activity.\n- Do NOT include the app/tool name or any prefix in the output.\n\nUse underscores only. Max 100 chars total.\n\nExamples:\n- reviewing_changes_before_commit\n- resolving_merge_conflicts\n- fixing_auth_bug\n- running_nextjs_dev_server\n\nBase64 terminal output:\n${encoded_content}\n\nRespond with ONLY the session name, nothing else."
2580
- }
2581
- ]
2582
- }
2583
- EOF
2584
- )
2585
- fi
2586
- fi
2587
-
2588
- # Make the API call
2589
- local response
2590
- local new_name=""
2591
- if [[ "$provider" == "openai" ]]; then
2592
- # First attempt: OpenAI Responses API + Files API for structured app/description output.
2593
- # If this path fails, we fall back to chat completions.
2594
- local context_file=""
2595
- if [[ "$OSTYPE" == "darwin"* ]]; then
2596
- context_file=$(mktemp -t gw_ctx)
2597
- else
2598
- context_file=$(mktemp)
2599
- fi
2600
- {
2601
- printf '%b\n\n' "$metadata_block"
2602
- printf 'TMUX SESSION CAPTURE\n\n'
2603
- cat "$temp_file"
2604
- if [[ -n "$history_arg" && -f "$history_arg" ]]; then
2605
- printf '\n\nRECENT COMMAND HISTORY\n\n'
2606
- cat "$history_arg"
2607
- fi
2608
- } > "$context_file"
2609
-
2610
- local file_upload_resp openai_file_id
2611
- file_upload_resp=$(curl -s -X POST https://api.openai.com/v1/files \
2612
- --max-time 20 \
2613
- -H "Authorization: Bearer $OPENAI_API_KEY" \
2614
- -F purpose="user_data" \
2615
- -F "file=@$context_file;type=text/plain" 2>/dev/null)
2616
- rm -f "$context_file"
2617
-
2618
- openai_file_id=""
2619
- if have_cmd python3; then
2620
- openai_file_id="$(python3 -c 'import json,sys
2621
- try:
2622
- data = json.load(sys.stdin)
2623
- except Exception:
2624
- data = {}
2625
- print(data.get("id", ""))' <<<"$file_upload_resp" 2>/dev/null || true)"
2626
- fi
2627
-
2628
- if [[ -n "$openai_file_id" ]]; then
2629
- local responses_body responses_resp ai_desc
2630
- responses_body=$(python3 - "$openai_model" "$openai_file_id" <<'PYCODE'
2631
- import json
2632
- import sys
2633
-
2634
- model = sys.argv[1]
2635
- file_id = sys.argv[2]
2636
-
2637
- prompt = """Analyze the provided terminal session file and produce ONLY a short activity description.
2638
-
2639
- Return STRICT JSON in this exact shape:
2640
- {"description":"<short_snake_case_description>"}
2641
-
2642
- Rules:
2643
- - description: concise snake_case description of the activity
2644
- - do NOT include app/tool name or any prefix in the description
2645
- - do not include punctuation beyond underscores
2646
- - keep it short and specific
2647
- """
2648
-
2649
- req = {
2650
- "model": model,
2651
- "temperature": 0.2,
2652
- "max_output_tokens": 180,
2653
- "input": [
2654
- {
2655
- "role": "user",
2656
- "content": [
2657
- {"type": "input_text", "text": prompt},
2658
- {"type": "input_file", "file_id": file_id},
2659
- ],
2660
- }
2661
- ],
2662
- }
2663
- print(json.dumps(req))
2664
- PYCODE
2665
- )
2666
-
2667
- responses_resp=$(curl -s -X POST https://api.openai.com/v1/responses \
2668
- --max-time 20 \
2669
- -H "Content-Type: application/json" \
2670
- -H "Authorization: Bearer $OPENAI_API_KEY" \
2671
- -d "$responses_body" 2>/dev/null)
2672
-
2673
- # Best-effort cleanup of uploaded file
2674
- curl -s -X DELETE "https://api.openai.com/v1/files/$openai_file_id" \
2675
- -H "Authorization: Bearer $OPENAI_API_KEY" >/dev/null 2>&1 || true
2676
-
2677
- ai_desc="$(python3 -c 'import json
2678
- import re
2679
- import sys
2680
-
2681
- def clean(value):
2682
- value = (value or "").strip().lower()
2683
- value = re.sub(r"[^a-z0-9_ -]+", "", value)
2684
- value = value.replace(" ", "_")
2685
- value = re.sub(r"_+", "_", value).strip("_")
2686
- return value
2687
-
2688
- try:
2689
- data = json.load(sys.stdin)
2690
- except Exception:
2691
- print("")
2692
- raise SystemExit
2693
-
2694
- text = ""
2695
- if isinstance(data.get("output_text"), str):
2696
- text = data["output_text"]
2697
-
2698
- if not text:
2699
- for item in data.get("output", []):
2700
- if not isinstance(item, dict):
2701
- continue
2702
- if item.get("type") != "message":
2703
- continue
2704
- for content in item.get("content", []):
2705
- if not isinstance(content, dict):
2706
- continue
2707
- if isinstance(content.get("text"), str):
2708
- text += content["text"]
2709
-
2710
- obj = None
2711
- if text:
2712
- text = text.strip()
2713
- try:
2714
- obj = json.loads(text)
2715
- except Exception:
2716
- match = re.search(r"\{.*\}", text, re.S)
2717
- if match:
2718
- try:
2719
- obj = json.loads(match.group(0))
2720
- except Exception:
2721
- obj = None
2722
-
2723
- description = ""
2724
- if isinstance(obj, dict):
2725
- description = clean(str(obj.get("description", "")))
2726
-
2727
- if not description and text:
2728
- description = clean(text)
2729
-
2730
- print(description)' <<<"$responses_resp" 2>/dev/null || true)"
2731
-
2732
- if [[ -n "$ai_desc" ]]; then
2733
- new_name="$ai_desc"
2734
- fi
2735
- fi
2736
-
2737
- if [[ -z "$new_name" ]]; then
2738
- # Fallback: existing chat completions path
2739
- response=$(curl -s -X POST https://api.openai.com/v1/chat/completions \
2740
- --max-time 20 \
2741
- -H "Content-Type: application/json" \
2742
- -H "Authorization: Bearer $OPENAI_API_KEY" \
2743
- -d "$request_body" 2>&1) || {
2744
- err "Failed to call OpenAI API: $response"
2745
- return 1
2746
- }
2747
-
2748
- if echo "$response" | grep -q '"error"'; then
2749
- local error_msg=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('error', {}).get('message', 'Unknown error'))" 2>/dev/null || echo "API error")
2750
- err "API error: $error_msg"
2751
- return 1
2752
- fi
2753
-
2754
- new_name=$(echo "$response" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('choices', [{}])[0].get('message', {}).get('content', ''))" 2>/dev/null) || {
2755
- new_name=$(echo "$response" | grep -o '"content":"[^"]*"' | head -1 | sed 's/"content":"\([^"]*\)"/\1/')
2756
- }
2757
- fi
2758
- else
2759
- response=$(curl -s -X POST https://api.anthropic.com/v1/messages \
2760
- --max-time 20 \
2761
- -H "Content-Type: application/json" \
2762
- -H "x-api-key: $ANTHROPIC_API_KEY" \
2763
- -H "anthropic-version: 2023-06-01" \
2764
- -d "$request_body" 2>&1) || {
2765
- err "Failed to call Anthropic API: $response"
2766
- return 1
2767
- }
2768
-
2769
- if echo "$response" | grep -q '"error"'; then
2770
- local error_msg=$(echo "$response" | python3 -c "import sys, json; print(json.load(sys.stdin).get('error', {}).get('message', 'Unknown error'))" 2>/dev/null || echo "API error")
2771
- err "API error: $error_msg"
2772
- return 1
2773
- fi
2774
-
2775
- new_name=$(echo "$response" | python3 -c "import sys, json; data = json.load(sys.stdin); print(data.get('content', [{}])[0].get('text', ''))" 2>/dev/null) || {
2776
- new_name=$(echo "$response" | grep -o '"text":"[^"]*"' | head -1 | sed 's/"text":"\([^"]*\)"/\1/')
2777
- }
2778
- fi
2779
-
2780
- # Clean up temp files
2781
- rm -f "$temp_file"
2782
- if [[ -n "$history_temp" ]]; then
2783
- rm -f "$history_temp"
2784
- fi
2785
-
2786
- # Clean up the AI description (remove any spaces, special chars except underscore and dash)
2787
- new_name=$(echo "$new_name" | tr ' ' '_' | sed 's/[^a-zA-Z0-9_-]//g' | cut -c1-100)
2788
- if [[ -z "$new_name" ]]; then
2789
- err "Failed to generate name from AI response"
2790
- return 1
2791
- fi
2792
-
2793
- new_name="${new_name:0:100}"
2794
-
2795
- echo "$new_name"
2796
- }
2797
-
2798
- # Display tmux message
2799
- # Usage: tmux_display_message <message> [duration_ms]
2800
- tmux_display_message() {
2801
- local message="$1"
2802
- local duration="${2:-3000}"
2803
-
2804
- if tmux_available && tmux_inside_session; then
2805
- tmux display-message -d "$duration" "$message"
2806
- fi
2807
- }
5
+ _ORCHESTRA_API_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
6
+ # shellcheck source=/dev/null
7
+ source "$_ORCHESTRA_API_DIR/../server/session/tmux_api.sh"