sessionview 0.2.0__tar.gz → 0.3.0__tar.gz

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 (46) hide show
  1. {sessionview-0.2.0 → sessionview-0.3.0}/PKG-INFO +10 -5
  2. {sessionview-0.2.0 → sessionview-0.3.0}/README.md +9 -4
  3. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview +32 -4
  4. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-frame +17 -4
  5. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-session +12 -6
  6. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-sidebar-toggle +20 -7
  7. {sessionview-0.2.0 → sessionview-0.3.0}/bin/tmux-sidebar +103 -21
  8. {sessionview-0.2.0 → sessionview-0.3.0}/config/README.md +4 -0
  9. {sessionview-0.2.0 → sessionview-0.3.0}/config/config.json +2 -0
  10. sessionview-0.3.0/lib/client.sh +57 -0
  11. {sessionview-0.2.0 → sessionview-0.3.0}/pyproject.toml +1 -1
  12. {sessionview-0.2.0 → sessionview-0.3.0}/src/sessionview/__init__.py +1 -1
  13. {sessionview-0.2.0 → sessionview-0.3.0}/.gitignore +0 -0
  14. {sessionview-0.2.0 → sessionview-0.3.0}/LICENSE +0 -0
  15. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-app-install +0 -0
  16. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-autostart +0 -0
  17. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-config +0 -0
  18. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-from +0 -0
  19. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-install +0 -0
  20. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-messages +0 -0
  21. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-newtab +0 -0
  22. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-send +0 -0
  23. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-terminal +0 -0
  24. {sessionview-0.2.0 → sessionview-0.3.0}/bin/sessionview-usermail +0 -0
  25. {sessionview-0.2.0 → sessionview-0.3.0}/bin/tmux-send-agent +0 -0
  26. {sessionview-0.2.0 → sessionview-0.3.0}/config/keys.md +0 -0
  27. {sessionview-0.2.0 → sessionview-0.3.0}/config/neomuttrc +0 -0
  28. {sessionview-0.2.0 → sessionview-0.3.0}/lib/python.sh +0 -0
  29. {sessionview-0.2.0 → sessionview-0.3.0}/lib/sessionview-config-screen +0 -0
  30. {sessionview-0.2.0 → sessionview-0.3.0}/lib/state.sh +0 -0
  31. {sessionview-0.2.0 → sessionview-0.3.0}/macos/SessionView.applescript +0 -0
  32. {sessionview-0.2.0 → sessionview-0.3.0}/macos/dreamcatcher.icns +0 -0
  33. {sessionview-0.2.0 → sessionview-0.3.0}/plugins/session/plugin.toml +0 -0
  34. {sessionview-0.2.0 → sessionview-0.3.0}/plugins/session/start-agent +0 -0
  35. {sessionview-0.2.0 → sessionview-0.3.0}/plugins/taskmaster/plugin.toml +0 -0
  36. {sessionview-0.2.0 → sessionview-0.3.0}/plugins/taskmaster/tasks-tab +0 -0
  37. {sessionview-0.2.0 → sessionview-0.3.0}/plugins/voicemode/plugin.toml +0 -0
  38. {sessionview-0.2.0 → sessionview-0.3.0}/plugins/voicemode/speak +0 -0
  39. {sessionview-0.2.0 → sessionview-0.3.0}/skills/tmux/SKILL.md +0 -0
  40. {sessionview-0.2.0 → sessionview-0.3.0}/skills/tmux/scripts/tmux-peek +0 -0
  41. {sessionview-0.2.0 → sessionview-0.3.0}/skills/tmux/scripts/tmux-show +0 -0
  42. {sessionview-0.2.0 → sessionview-0.3.0}/skills/tmux/scripts/tmux-type +0 -0
  43. {sessionview-0.2.0 → sessionview-0.3.0}/skills/tmux/scripts/tmux-where +0 -0
  44. {sessionview-0.2.0 → sessionview-0.3.0}/tmux/agent-menu.conf +0 -0
  45. {sessionview-0.2.0 → sessionview-0.3.0}/tmux/frame.conf +0 -0
  46. {sessionview-0.2.0 → sessionview-0.3.0}/tmux/tabs-top.conf +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: sessionview
3
- Version: 0.2.0
3
+ Version: 0.3.0
4
4
  Summary: A sidebar for tmux: your projects (sessions) and your agents, here and on remote hosts.
5
5
  Project-URL: Homepage, https://github.com/sessionkit/sessionview
6
6
  Project-URL: Repository, https://github.com/sessionkit/sessionview
@@ -58,7 +58,7 @@ neomutt and notmuch's index on your machine.
58
58
  sidebar's mail, the agent pane menu, and the session, taskmaster and
59
59
  voicemode plugins (each lists `message` in its `requires`). Mail is
60
60
  sent as you: `$USER`, unless `sessionview-config set mail_from NAME`
61
- (or `$SESSIONVIEW_FROM`) names your mail name; `sessionview-from`
61
+ (or `$SESSIONVIEW_FROM`) names your mail name; `sessionview from`
62
62
  prints the name in effect.
63
63
  - **neomutt** for the `messages` tab (`bin/sessionview-messages` runs
64
64
  `neomutt -R`; the tab exits 2 without it): `brew install neomutt`, built
@@ -287,9 +287,14 @@ and every key in
287
287
  error with its traceback). Follow it with
288
288
  `tail -f ~/.local/state/sessionview/sessionview.log` when a click
289
289
  seems to do nothing.
290
- - `~/.local/state/sessionview/sidebar-mouse.log`: one line per mouse
291
- click the sidebar receives.
292
- - Both sit under `$SESSIONVIEW_STATE` when it is set. Neither is rotated.
290
+ - **Levels:** `log_level` in your settings (or `$SESSIONVIEW_LOG_LEVEL`
291
+ for one run). `error`, the default, logs only what went wrong.
292
+ `timing` adds a line per click or key with its milliseconds, and each
293
+ command that took 50 ms or more, so a slow click names its slow part.
294
+ `debug` adds every command and every mouse event (what the old
295
+ `sidebar-mouse.log` held).
296
+ - It sits under `$SESSIONVIEW_STATE` when that is set. It is not rotated:
297
+ turn `timing` or `debug` on while you look, then back to `error`.
293
298
  - **Health:** `sessionview install --check`: exit 0 and `ready` when
294
299
  every requirement is present, exit 1 with each MISSING one named
295
300
  otherwise. It checks the machine, not a running sidebar.
@@ -41,7 +41,7 @@ neomutt and notmuch's index on your machine.
41
41
  sidebar's mail, the agent pane menu, and the session, taskmaster and
42
42
  voicemode plugins (each lists `message` in its `requires`). Mail is
43
43
  sent as you: `$USER`, unless `sessionview-config set mail_from NAME`
44
- (or `$SESSIONVIEW_FROM`) names your mail name; `sessionview-from`
44
+ (or `$SESSIONVIEW_FROM`) names your mail name; `sessionview from`
45
45
  prints the name in effect.
46
46
  - **neomutt** for the `messages` tab (`bin/sessionview-messages` runs
47
47
  `neomutt -R`; the tab exits 2 without it): `brew install neomutt`, built
@@ -270,9 +270,14 @@ and every key in
270
270
  error with its traceback). Follow it with
271
271
  `tail -f ~/.local/state/sessionview/sessionview.log` when a click
272
272
  seems to do nothing.
273
- - `~/.local/state/sessionview/sidebar-mouse.log`: one line per mouse
274
- click the sidebar receives.
275
- - Both sit under `$SESSIONVIEW_STATE` when it is set. Neither is rotated.
273
+ - **Levels:** `log_level` in your settings (or `$SESSIONVIEW_LOG_LEVEL`
274
+ for one run). `error`, the default, logs only what went wrong.
275
+ `timing` adds a line per click or key with its milliseconds, and each
276
+ command that took 50 ms or more, so a slow click names its slow part.
277
+ `debug` adds every command and every mouse event (what the old
278
+ `sidebar-mouse.log` held).
279
+ - It sits under `$SESSIONVIEW_STATE` when that is set. It is not rotated:
280
+ turn `timing` or `debug` on while you look, then back to `error`.
276
281
  - **Health:** `sessionview install --check`: exit 0 and `ready` when
277
282
  every requirement is present, exit 1 with each MISSING one named
278
283
  otherwise. It checks the machine, not a running sidebar.
@@ -5,13 +5,15 @@
5
5
  # sessionview COMMAND [ARGS]
6
6
  #
7
7
  # sessionview [SESSION] the frame: sidebar on the left, SESSION attached on
8
- # the right (default main). --sock names its tmux server;
9
- # --width, columns.
8
+ # the right (default main; a named SESSION must exist).
9
+ # --sock names its tmux server; --width, columns.
10
10
  # sessionview config settings: no verb opens the screen (get/set/list ...)
11
11
  # sessionview session NAME open a project session with its standard tabs
12
12
  # sessionview messages PROJECT a project's mail, read-only, in NeoMutt
13
13
  # sessionview install check requirements (--app: the macOS Dock app)
14
14
  # sessionview autostart --status which agent starts when sessionview opens
15
+ # sessionview from the name your mail is sent as (mail_from, else $USER)
16
+ # sessionview sidebar-toggle hide the sidebar, or show it again
15
17
  # sessionview sidebar the sidebar under its supervisor (the frame's left
16
18
  # pane runs this). In it: q reloads, Q quits.
17
19
  #
@@ -25,9 +27,11 @@
25
27
  # without the comment marks, so the help reads cleanly.
26
28
  SESSIONVIEW_ROOT="${SESSIONVIEW_ROOT:-$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/.." && pwd)}"
27
29
  SESSIONVIEW_STATE="${SESSIONVIEW_STATE:-$HOME/.local/state/sessionview}"; . "$SESSIONVIEW_ROOT/lib/state.sh"; . "$SESSIONVIEW_ROOT/lib/python.sh"; export SESSIONVIEW_ROOT SESSIONVIEW_STATE
30
+ # The header above is the usage: -h prints it, and so does an unknown word.
31
+ usage() { sed -n '2,22p' "$0" | sed 's/^# \{0,1\}//'; }
28
32
  case "${1:-}" in
29
- -h|--help|help) sed -n '2,20p' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
30
- config|session|messages|install|autostart|terminal|usermail)
33
+ -h|--help|help) usage; exit 0;;
34
+ config|session|messages|install|autostart|terminal|usermail|from|sidebar-toggle)
31
35
  sub="$1"; shift; exec "$SESSIONVIEW_ROOT/bin/sessionview-$sub" "$@";;
32
36
  sidebar)
33
37
  # The supervisor: q = exit 0 = reload (picks up an edited tmux-sidebar or
@@ -44,4 +48,28 @@ case "${1:-}" in
44
48
  done
45
49
  exit 0;;
46
50
  esac
51
+ # Anything else is the frame. A word that names no subcommand opens a frame
52
+ # ONLY on a session that already exists on the server the frame will use
53
+ # (--sock, else this tmux's own server when run inside one, else default):
54
+ # a typo or a probe word must not mean "open a frame on a new session named
55
+ # after it". Read-only check; on a miss, usage and exit 2, nothing touched.
56
+ # A new named session is `sessionview session NAME`. No word = main, as before.
57
+ word=""; sock=""; prev=""
58
+ for a in "$@"; do
59
+ case "$prev" in --sock) sock="$a"; prev=""; continue;; --width) prev=""; continue;; esac
60
+ case "$a" in --sock|--width) prev="$a";; -*) ;; *) word="$a";; esac
61
+ done
62
+ if [ -n "$word" ]; then
63
+ extra=""
64
+ if [ -z "$sock" ]; then
65
+ if [ -n "${TMUX:-}" ]; then sock="$(printf %s "${TMUX%%,*}")"; sock="${sock##*/}"; extra=1; else sock=default; fi
66
+ fi
67
+ if ! tmux -L "$sock" has-session -t "=$word" 2>/dev/null; then
68
+ echo "sessionview: no command or session named '$word' (on tmux server $sock); a new session is: sessionview session $word" >&2
69
+ usage >&2
70
+ exit 2
71
+ fi
72
+ # the frame looks where this checked: inside tmux, the caller's own server
73
+ [ -n "$extra" ] && set -- "$@" --sock "$sock"
74
+ fi
47
75
  exec "$SESSIONVIEW_ROOT/bin/sessionview-frame" "$@"
@@ -31,6 +31,13 @@ while [ $# -gt 0 ]; do case "$1" in
31
31
  -*) echo "sessionview-frame: unknown flag $1" >&2; exit 2;;
32
32
  *) SESSION="$1"; GIVEN=1;;
33
33
  esac; shift; done
34
+ # The frame takes over the terminal it runs in, so it needs one: a script or
35
+ # an agent's shell (stdin/stdout not a tty) is refused and nothing is touched.
36
+ # Every refusal is one line on stderr, exit 2.
37
+ refuse() { echo "sessionview: refused: $1" >&2; exit 2; }
38
+ if [ ! -t 0 ] || [ ! -t 1 ]; then
39
+ refuse "not a terminal: run it from a terminal (stdin and stdout must be a tty)"
40
+ fi
34
41
  # Already inside a tmux client? Attaching a frame here nests a client in a
35
42
  # pane: a terminal that auto-attaches tmux at login would run sessionview
36
43
  # inside itself, a loop of frames in frames. Better to deal with it, for
@@ -42,15 +49,21 @@ esac; shift; done
42
49
  # "run shell-command to replace the client"). The session the user was
43
50
  # in stays alive and becomes the frame's right pane unless SESSION was
44
51
  # given; its server becomes --sock. (-E was measured to do this.)
52
+ # "This client" is the CALLER's own, and only that: lib/client.sh proves it
53
+ # (this tty is $TMUX_PANE's, and exactly one client shows its session) or
54
+ # refuses. An untargeted `display -p '#{client_tty}'` once answered with
55
+ # another terminal's client on the same server, and the detach replaced it.
45
56
  if [ -n "${TMUX:-}" ]; then
46
- ctty="$(tmux display -p '#{client_tty}' 2>/dev/null)"
57
+ . "$SESSIONVIEW_ROOT/lib/client.sh"
58
+ sv_own_client || refuse "$SV_REFUSAL"
59
+ ctty="$SV_CLIENT"
47
60
  if tmux -L "$FRAME_SOCK" list-panes -a -F '#{pane_tty}' 2>/dev/null | grep -qx "$ctty"; then
48
61
  echo "sessionview: already inside a sessionview frame (client $ctty) - the sidebar is on the left; a frame in a frame is the loop" >&2
49
62
  exit 1
50
63
  fi
51
- here_sock="$(printf %s "$TMUX" | cut -d, -f1)"; here_sock="${here_sock##*/}"
64
+ here_path="${TMUX%%,*}"; here_sock="${here_path##*/}"
52
65
  if [ -z "$GIVEN" ]; then
53
- SESSION="$(tmux display -p '#{session_name}')"; SOCK="$here_sock"
66
+ SESSION="$SV_SESSION"; SOCK="$here_sock"
54
67
  fi
55
68
  q() { printf "'%s'" "$(printf %s "$1" | sed "s/'/'\\\\''/g")"; }
56
69
  # -E runs the command with the CLIENT's environment, not this shell's
@@ -60,7 +73,7 @@ if [ -n "${TMUX:-}" ]; then
60
73
  cmd="$cmd SESSIONVIEW_FRAME_SOCK=$(q "$FRAME_SOCK") TMUX="
61
74
  cmd="$cmd exec $(q "$0") $(q "$SESSION") --sock $(q "$SOCK")"
62
75
  [ -n "$WIDTH" ] && cmd="$cmd --width $(q "$WIDTH")"
63
- exec tmux detach-client -E "$cmd"
76
+ exec tmux -S "$here_path" detach-client -t "$ctty" -E "$cmd"
64
77
  fi
65
78
  if [ -z "$WIDTH" ]; then
66
79
  WIDTH="$("$SESSIONVIEW_ROOT/bin/sessionview-config" get width 2>/dev/null || true)"
@@ -38,7 +38,7 @@
38
38
  # shell the root - the fallback when nothing else applies; pin it
39
39
  # (auto: true) and every session gets one
40
40
  # {editor} is VISUAL, else EDITOR, else nvim; when it is nvim it LISTENS on the
41
- # socket show-me looks for (<socket dir>/nvim-show-pane-<pane id>).
41
+ # socket show-me looks for (<socket dir>/nvim-show-<server key>-pane-<pane number>).
42
42
  # Every window is a SHELL with the tool typed into it, so quitting nvim or
43
43
  # lazygit leaves a prompt in the right directory. An entry's `when` is a NAME
44
44
  # this script tests (always | has_repo | has_projdir | has_mail | fallback) and
@@ -207,6 +207,10 @@ show_socket_dir() { # show-me's get_socket_dir(), verbatim in spirit
207
207
  elif [ -n "${XDG_RUNTIME_DIR:-}" ] && [ -d "$XDG_RUNTIME_DIR" ]; then echo "${XDG_RUNTIME_DIR%/}"
208
208
  else mkdir -p "$HOME/.local/run" && chmod 700 "$HOME/.local/run"; echo "$HOME/.local/run"; fi
209
209
  }
210
+ show_server_key() { # show-me's show_server_key(): the cksum of a tmux server's socket path.
211
+ # Pane ids count per SERVER, so a socket named by pane id alone collides across servers.
212
+ printf '%s' "$1" | cksum | awk '{print $1}'
213
+ }
210
214
  say "session=$NAME socket=$SOCK store=$STORE root=$ROOT repo=${REPO:-NONE} projdir=${PROJDIR:-NONE} store_id=${STOREID:-$([ "$STOREMISS" = 1 ] && echo UNREGISTERED || echo NONE)} profile=$([ "$PLAIN" = 1 ] && echo off || echo on)$([ -n "$STOREVIEW" ] && echo " store_view=$STOREVIEW store_project=${STOREPROJ:-NONE} fresh=$FRESH")"
211
215
 
212
216
  # -- cascading config: built-in > host > store > project, all JSON (JSON
@@ -288,19 +292,21 @@ dir_of() { # dir_of CWD -> the path, or "" when that dir is unknown for this pr
288
292
  esac
289
293
  }
290
294
  editor_for() { # editor_for WINDOW -> what {editor} becomes there. nvim LISTENS on the socket
291
- # show-me looks for (<socket dir>/nvim-show-pane-<pane id>): a code window opened
292
- # with neovim has the socket running, so show-me can reach it;
295
+ # show-me looks for (<socket dir>/nvim-show-<server key>-pane-<pane number>): a code
296
+ # window opened with neovim has the socket running, so show-me can reach it;
293
297
  # any other editor is typed as it is. No window yet (--list-windows): the bare name.
294
- local pid
298
+ local pid key pane
295
299
  case "$(basename "${ED%% *}")" in
296
300
  nvim) [ -n "$1" ] || { printf '%s' "$ED"; return; }
297
- if [ "$PRINT" = 1 ]; then pid='<pane>'; else pid="$(tmux display-message -p -t "=$NAME:$1" '#{pane_id}')"; fi
301
+ if [ "$PRINT" = 1 ]; then pid='<pane>'; key='<server>'
302
+ else pane="$(tmux display-message -p -t "=$NAME:$1" '#{pane_id} #{socket_path}')"
303
+ pid="${pane%% *}"; key="$(show_server_key "${pane#* }")"; fi
298
304
  # and ROOTS at the window's cwd: LazyVim's <leader>e opens neo-tree at LazyVim.root(),
299
305
  # which walks up to .git - in the project window that is the STORE, not the project
300
306
  # (the project directory should be the root). -c, not --cmd: LazyVim's own
301
307
  # options.lua overwrites vim.g.root_spec at load.
302
308
  # Only nvims this script opens get it; the user's config is untouched.
303
- printf '%s --listen %s -c %s' "$ED" "$(printf %q "$(show_socket_dir)/nvim-show-pane-${pid#%}")" "$(printf %q 'lua vim.g.root_spec={"cwd"}')";;
309
+ printf '%s --listen %s -c %s' "$ED" "$(printf %q "$(show_socket_dir)/nvim-show-$key-pane-${pid#%}")" "$(printf %q 'lua vim.g.root_spec={"cwd"}')";;
304
310
  *) printf '%s' "$ED";;
305
311
  esac
306
312
  }
@@ -1,12 +1,16 @@
1
1
  #!/bin/sh
2
2
  # sessionview-sidebar-toggle - hide the sidebar, or show it again.
3
3
  #
4
- # sessionview-sidebar-toggle from INSIDE the inner tmux (the
5
- # tab-bar glyph, a key, a shell):
4
+ # sessionview sidebar-toggle from a shell INSIDE the inner tmux:
6
5
  # finds the frame by the client's tty
7
- # sessionview-sidebar-toggle --frame SOCK:SESSION name the frame (tests, the sidebar)
8
- # sessionview-sidebar-toggle --tty /dev/ttysNNN the clicking client (the tab-bar glyph)
9
- # sessionview-sidebar-toggle --hide | --show one direction only (default: toggle)
6
+ # sessionview sidebar-toggle --hide | --show one direction only (default: toggle)
7
+ # sessionview sidebar-toggle --frame SOCK:SESSION name the frame (tests, the sidebar)
8
+ # sessionview sidebar-toggle --tty /dev/ttysNNN the clicking client (the tab-bar glyph)
9
+ #
10
+ # The tab-bar glyph and the tmux bindings run this file by path
11
+ # ($SESSIONVIEW_ROOT/bin/sessionview-sidebar-toggle); from a shell, the
12
+ # `sessionview` front door runs it, so it works from a package install too.
13
+ # -h prints the usage above.
10
14
  #
11
15
  # On a small laptop screen the sidebar needs a way to hide and show again, a
12
16
  # little glyph. That is table stakes, not icing: a sidebar that cannot get out
@@ -32,14 +36,23 @@ while [ $# -gt 0 ]; do
32
36
  --tty) TTY="$2"; shift 2;; # the clicking client's tty, from the tab-bar binding
33
37
  --hide) MODE="hide"; shift;;
34
38
  --show) MODE="show"; shift;;
39
+ -h|--help) sed -n '2,8p' "$0" | sed 's/^# \{0,1\}//'; exit 0;;
35
40
  *) echo "sessionview-sidebar-toggle: unknown argument '$1'" >&2; exit 2;;
36
41
  esac
37
42
  done
38
43
  if [ -n "$FRAME" ]; then
39
44
  FSOCK="${FRAME%%:*}"; F="${FRAME#*:}"
40
45
  else
41
- tty="${TTY:-$(tmux display-message -p '#{client_tty}' 2>/dev/null || true)}"
42
- [ -n "$tty" ] || { echo "sessionview-sidebar-toggle: not inside tmux and no --frame" >&2; exit 1; }
46
+ tty="$TTY"
47
+ if [ -z "$tty" ]; then
48
+ # no --tty: the CALLER's own client, proven (lib/client.sh) or refused. An
49
+ # untargeted `display -p '#{client_tty}'` answers with another terminal's
50
+ # client when this session has none, or has two - and toggled THAT frame.
51
+ SESSIONVIEW_ROOT="${SESSIONVIEW_ROOT:-$(cd "$(dirname "$(readlink -f "$0" 2>/dev/null || echo "$0")")/.." && pwd)}"
52
+ . "$SESSIONVIEW_ROOT/lib/client.sh"
53
+ sv_own_client || { echo "sessionview-sidebar-toggle: refused: $SV_REFUSAL" >&2; exit 2; }
54
+ tty="$SV_CLIENT"
55
+ fi
43
56
  F="$(tmux -L "$FSOCK" list-panes -a -F '#{pane_tty} #{session_name}' 2>/dev/null | awk -v t="$tty" '$1 == t {print $2; exit}')"
44
57
  [ -n "$F" ] || { echo "sessionview-sidebar-toggle: no frame shows this client ($tty) on server $FSOCK" >&2; exit 1; }
45
58
  fi
@@ -162,16 +162,34 @@ _migrate_state()
162
162
  os.makedirs(SESSIONVIEW_STATE, exist_ok=True)
163
163
  def state(name): return os.path.join(SESSIONVIEW_STATE, name)
164
164
 
165
- def log_err(where, text):
166
- """ONE log for everything that goes wrong: ~/.local/state/sessionview/sessionview.log,
167
- every error in a single log. A
168
- line a person can read: time, socket, where, what. Never raises."""
165
+ LOG_LEVELS = ("error", "timing", "debug")
166
+ LOG_LEVEL = "error" # set from `log_level` once the config is read (below CFG)
167
+
168
+ def log_at(level):
169
+ """True when LEVEL's lines are written. Each level holds the ones before
170
+ it: errors always, timing at timing or debug, debug at debug only."""
171
+ return LOG_LEVELS.index(level) <= LOG_LEVELS.index(LOG_LEVEL)
172
+
173
+ def log(level, where, text):
174
+ """ONE log for everything: ~/.local/state/sessionview/sessionview.log.
175
+ A line a person can read: time, socket, where, what. An error line is
176
+ exactly as it always was; a timing or debug line names its level after
177
+ the socket (`sock=X timing run: ...`), so the errors stay easy to grep.
178
+ Written only when `log_level` lets LEVEL through. Never raises."""
179
+ if not log_at(level):
180
+ return
181
+ tag = "" if level == "error" else level + " "
182
+ sock = globals().get("SOCK", "-") # a line written before SOCK is known still lands
169
183
  try:
170
184
  with open(state("sessionview.log"), "a") as lg:
171
- lg.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} sock={SOCK} {where}: {text}\n")
185
+ lg.write(f"{time.strftime('%Y-%m-%d %H:%M:%S')} sock={sock} {tag}{where}: {text}\n")
172
186
  except Exception:
173
187
  pass
174
188
 
189
+ def log_err(where, text):
190
+ """An error line in the one log: written at every level."""
191
+ log("error", where, text)
192
+
175
193
  def run_checked(cmd, where):
176
194
  """subprocess.run that LOGS a non-zero exit with its stderr instead of
177
195
  swallowing it; returns the CompletedProcess. The + popup's new-window
@@ -249,6 +267,40 @@ def load_config():
249
267
  cfg["refresh"] = float(os.environ["SIDEBAR_REFRESH"])
250
268
  return cfg
251
269
  CFG = load_config()
270
+
271
+ def log_level():
272
+ """`log_level` from the config, $SESSIONVIEW_LOG_LEVEL over it (for one
273
+ run). error (the default) logs only what went wrong; timing adds a line
274
+ per click or key with its milliseconds, and each command that took
275
+ TIMING_MIN_MS or more; debug adds every command and every mouse event.
276
+ An unknown word is error, and the log says so once."""
277
+ raw = os.environ.get("SESSIONVIEW_LOG_LEVEL") or CFG.get("log_level") or "error"
278
+ v = str(raw).strip().lower()
279
+ if v in LOG_LEVELS:
280
+ return v
281
+ log_err("log_level", f"unknown level {raw!r}: using error (one of {', '.join(LOG_LEVELS)})")
282
+ return "error"
283
+ LOG_LEVEL = log_level()
284
+
285
+ TIMING_MIN_MS = 50 # timing: a command this slow or slower gets its line
286
+ _RUN = subprocess.run
287
+ def _timed_run(*args, **kw):
288
+ """subprocess.run, timed: the command and its milliseconds go to the log
289
+ (timing when it took TIMING_MIN_MS or more, else debug). Installed only
290
+ when the level is timing or debug, so at error it costs nothing. Every
291
+ tm, tmux, git and sessionview-session call goes through here, the
292
+ worker threads' too: the slow one in a slow click is named."""
293
+ t0 = time.monotonic()
294
+ try:
295
+ return _RUN(*args, **kw)
296
+ finally:
297
+ ms = (time.monotonic() - t0) * 1000
298
+ cmd = args[0] if args else kw.get("args", "")
299
+ if isinstance(cmd, (list, tuple)):
300
+ cmd = " ".join(str(c) for c in cmd)
301
+ log("timing" if ms >= TIMING_MIN_MS else "debug", "run", f"{ms:.0f}ms {cmd}"[:300])
302
+ if LOG_LEVEL != "error":
303
+ subprocess.run = _timed_run
252
304
  def wants_context(fmt):
253
305
  """A format that shows context, or the model (the scan is where the model
254
306
  id comes from), pays the transcript read; nothing else does."""
@@ -2204,8 +2256,8 @@ def client_where():
2204
2256
  only, while the pin sat on a store's server attached to demo
2205
2257
  (measured), so the sessionview client was never found and a click on any
2206
2258
  other store's session was refused as view-only, when a click on a
2207
- tmux session should simply work. Falls back to the most recently
2208
- active client on the plain socket. Cached 5s; a move clears it."""
2259
+ tmux session should simply work. No pin found = no client (never the
2260
+ most recently active one: another terminal). Cached 5s; a move clears it."""
2209
2261
  now = time.time()
2210
2262
  if _CLIENT.get("t") and now - _CLIENT["t"] < 5:
2211
2263
  return _CLIENT["v"]
@@ -2217,8 +2269,12 @@ def client_where():
2217
2269
  # one of the user's other terminals: a click on
2218
2270
  # demo switched a different window, focusing it in
2219
2271
  # another terminal.
2220
- me = sh(["tmux", "display-message", "-p", "#{client_tty}"]).strip()
2221
- v = (attached_socket(), me)
2272
+ # And even INSIDE the popup an untargeted display is not its client:
2273
+ # measured, it answered with the MORE RECENTLY ACTIVE client, not
2274
+ # the one showing the popup. A popup has no pane ($TMUX_PANE is
2275
+ # empty), so its client is the ONE client on $TMUX's session; none
2276
+ # or two = no client known, and a click moves nothing.
2277
+ v = (attached_socket(), popup_client())
2222
2278
  else:
2223
2279
  pin = os.environ.get("SESSIONVIEW_PIN") or state("frame.tty") # sessionview-frame: its own pin
2224
2280
  tty_ = open(pin).read().strip() if os.path.exists(pin) else ""
@@ -2231,10 +2287,10 @@ def client_where():
2231
2287
  break
2232
2288
  if found:
2233
2289
  v = (found, tty_)
2234
- else:
2235
- out = sh(["tmux", "-L", "default", "list-clients", "-F", "#{client_activity}\t#{client_tty}"])
2236
- rows = sorted((l.split("\t") for l in out.splitlines() if "\t" in l), reverse=True)
2237
- v = ("default", rows[0][1] if rows else "")
2290
+ # No pin found: NO client, never "the most recently active client on
2291
+ # the plain socket" - that is whichever terminal was used last,
2292
+ # someone else's, and a click would switch it. With no tty known
2293
+ # move_client moves nothing (a frame recreates its own right pane).
2238
2294
  _CLIENT.update(t=now, v=v)
2239
2295
  return v
2240
2296
 
@@ -2306,6 +2362,18 @@ def frame_attach(frame, sock, session):
2306
2362
  f.write(tty_)
2307
2363
  _CLIENT.clear()
2308
2364
 
2365
+ def popup_client():
2366
+ """The tty of the client showing this popup: the ONE client attached to
2367
+ $TMUX's session (socket,pid,session-number), or "" when there are none
2368
+ or several - several cannot be told apart from here, and a guess moves
2369
+ another terminal."""
2370
+ parts = os.environ.get("TMUX", "").split(",")
2371
+ if len(parts) < 3 or not parts[0] or not parts[2].isdigit():
2372
+ return ""
2373
+ out = sh(["tmux", "-S", parts[0], "list-clients", "-t", "$" + parts[2], "-F", "#{client_tty}"])
2374
+ ttys = out.split()
2375
+ return ttys[0] if len(ttys) == 1 else ""
2376
+
2309
2377
  def move_client(sock, session):
2310
2378
  """Show SESSION (on server SOCK) in the sessionview client. Same server:
2311
2379
  switch-client. Another server: `detach-client -E` re-execs the client
@@ -3106,6 +3174,12 @@ def fit():
3106
3174
  next open is the width chosen. The sidebar once
3107
3175
  undid every drag on the next redraw.
3108
3176
 
3177
+ Only while a client is attached to the frame. Before the first attach the
3178
+ pane changes inside the same window too: the frame's own split and its
3179
+ resize to the configured width. Nobody can drag a border in a session
3180
+ with no client, so those are put back, never remembered (a new user's
3181
+ first open once wrote width 100, half the window the frame starts in).
3182
+
3109
3183
  A drag below MIN_DRAG is a slip and is put back, not remembered. With
3110
3184
  `fit: auto` the wish itself moves (see wish()); the pane follows it."""
3111
3185
  global WIDTH
@@ -3119,10 +3193,13 @@ def fit():
3119
3193
  WIDTH = max(8, min(wish(), cols))
3120
3194
  return
3121
3195
  fsock = frame.split(":", 1)[0]
3122
- win = sh(["tmux", "-L", fsock, "display-message", "-p", "-t", pane,
3123
- "#{window_width}"]).strip()
3196
+ # one call: the window's width, and whether a client is attached to the frame
3197
+ w = sh(["tmux", "-L", fsock, "display-message", "-p", "-t", pane,
3198
+ "#{window_width} #{session_attached}"]).split()
3199
+ win = w[0] if w else ""
3200
+ attached = len(w) > 1 and w[1] != "0"
3124
3201
  known = _FIT["pane"]
3125
- dragged = (win and win == _FIT["win"] and known is not None and cols != known)
3202
+ dragged = (attached and win and win == _FIT["win"] and known is not None and cols != known)
3126
3203
  if dragged and cols >= MIN_DRAG:
3127
3204
  CFG["width"] = cols # the drag is the wish
3128
3205
  if str(CFG.get("fit", "fixed")) in ("auto", "narrow", "focus"):
@@ -3154,6 +3231,7 @@ def draw(lines):
3154
3231
  sys.stdout.write("\033[H\033[2J" + "\r\n".join(lines) + "\r")
3155
3232
  sys.stdout.flush()
3156
3233
 
3234
+ INPUT = [""] # what the input being handled was, for its timing line
3157
3235
  def main():
3158
3236
  global GROUPED, MENU, PROMPT, WRITE_TABS, FILTER, ANCHOR
3159
3237
  if "--pick" in sys.argv: # the picker alone, in a popup
@@ -3211,6 +3289,9 @@ def main():
3211
3289
  if not r:
3212
3290
  lines, targets = render(); draw(lines); continue
3213
3291
  buf += os.read(fd, 64).decode(errors="ignore")
3292
+ t_in = time.monotonic()
3293
+ keys_in = MOUSE_RE.sub("", buf).replace("\x1b[O", "").replace("\x1b[I", "")
3294
+ INPUT[0] = f"key {keys_in!r}" if keys_in else ""
3214
3295
  if "\x1b[O" in buf: # focus LEFT the sidebar: a click in the main section
3215
3296
  buf = buf.replace("\x1b[O", "")
3216
3297
  FOCUS["in"] = False
@@ -3249,12 +3330,11 @@ def main():
3249
3330
  int(m.group(3)), m.group(4))
3250
3331
  if kind != "M":
3251
3332
  continue
3252
- try: # evidence for "my click opened the menu": what button arrived
3253
- with open(state("sidebar-mouse.log"), "a") as lg:
3254
- lg.write(f"{time.strftime('%H:%M:%S')} btn={btn} x={x} y={y} target={targets.get(y - 1)}\n"[:200])
3255
- except OSError:
3256
- pass
3333
+ # evidence for "my click opened the menu": what button arrived
3334
+ log("debug", "mouse", f"btn={btn} x={x} y={y} target={targets.get(y - 1)}"[:200])
3257
3335
  t = targets.get(y - 1)
3336
+ if t:
3337
+ INPUT[0] = (f"click{'' if btn == 0 else f' btn={btn}'} {t[0]} {t[1]}")[:120]
3258
3338
  ANCHOR = y - 1 # a menu this click opens sits beside this row
3259
3339
  if MENU is not None and not (t and t[0] in ("menu-item", "menu-close")):
3260
3340
  # A click OUTSIDE an open menu closes it and is consumed -
@@ -3338,6 +3418,8 @@ def main():
3338
3418
  project_menu(current_session())
3339
3419
  ANCHOR = None # a keyboard open has no row: centred
3340
3420
  lines, targets = render(); draw(lines)
3421
+ if INPUT[0]: # a click or a key: how long until you saw the answer
3422
+ log("timing", "input", f"{(time.monotonic() - t_in) * 1000:.0f}ms {INPUT[0]}")
3341
3423
  buf = buf[buf.rfind("\x1b"):] if buf.count("\x1b") and not MOUSE_RE.search(buf[buf.rfind("\x1b"):]) else ""
3342
3424
  finally:
3343
3425
  tab_clear() # no tab-bar glyph outlives us
@@ -140,6 +140,10 @@ labels.
140
140
  tmux call (a new tab that never appeared), a `--window` that refused,
141
141
  an uncaught exception, each a line with time, socket, where, what.
142
142
  `tail -f` it when something silently does nothing.
143
+ - `log_level` - what that ONE file records: `error` (the default), `timing`
144
+ (a line per click or key with its ms, and each command of 50 ms or more)
145
+ or `debug` (every command, every mouse event). `$SESSIONVIEW_LOG_LEVEL`
146
+ overrides it for one run.
143
147
  - `window_pins` - your pins over that list, `{"name": true|false}`,
144
148
  written by ^p / right-click in the new-tab picker. A delta, so the list
145
149
  keeps flowing to you.
@@ -9,6 +9,8 @@
9
9
  "width_min": 20,
10
10
  "width_max": 60,
11
11
  "refresh": 2,
12
+ "_log_level": "What goes in the ONE log, ~/.local/state/sessionview/sessionview.log. error: only what went wrong. timing: also a line per click or key with its milliseconds (from the input arriving to the redraw), and each command that took 50 ms or more. debug: also every command and every mouse event (the old sidebar-mouse.log). $SESSIONVIEW_LOG_LEVEL overrides it for one run. The log is not rotated: leave timing or debug on only while you look.",
13
+ "log_level": "error",
12
14
  "project_two_line": false,
13
15
  "project_expand_selected": false,
14
16
  "project_gap": 0,
@@ -0,0 +1,57 @@
1
+ # lib/client.sh - sourced by an entry script that acts on "the tmux client
2
+ # this was run from": sessionview-frame's hand-over (detach-client -E) and
3
+ # sessionview-sidebar-toggle without --tty. One rule, in one place.
4
+ #
5
+ # sv_own_client sets SV_CLIENT (that client's tty) and SV_SESSION (the
6
+ # session that client shows: the caller's) and returns 0, or sets SV_REFUSAL (one neutral
7
+ # line saying why) and returns 1. It reads tmux and changes nothing.
8
+ #
9
+ # An untargeted `tmux display -p '#{client_tty}'` is NOT the caller's client.
10
+ # tmux answers with the most recently active client of the caller's session,
11
+ # and when that session has no client attached, of the WHOLE server: some
12
+ # other terminal. Even `display -t "$TMUX_PANE"` falls back that way. So:
13
+ # 1. stdin and stdout are terminals, and `tty` names one. A script or an
14
+ # agent's shell that inherited TMUX and TMUX_PANE has neither.
15
+ # 2. that tty IS the pane's: `#{pane_tty}` of $TMUX_PANE. A process in a
16
+ # pane has the pane's pty as its terminal (never the client's), so this
17
+ # proves the caller is the process in that pane.
18
+ # 3. exactly one client is on a session that holds the pane's window. Not
19
+ # only the session `display -t "$TMUX_PANE"` names: a window can be in
20
+ # several sessions (a session group, `link-window`), and tmux names the
21
+ # most recently active of them, so a count over that one session alone
22
+ # handed over another terminal's client. None: no terminal shows the
23
+ # pane. More than one: no way to tell which terminal is the caller's,
24
+ # so refuse rather than guess.
25
+ sv_own_client() {
26
+ SV_CLIENT=""; SV_SESSION=""; SV_REFUSAL=""
27
+ if [ ! -t 0 ] || [ ! -t 1 ]; then
28
+ SV_REFUSAL="not a terminal: run it from a terminal (stdin and stdout must be a tty)"; return 1
29
+ fi
30
+ _sv_own="$(tty 2>/dev/null)" || { SV_REFUSAL="not a terminal: run it from a terminal (no controlling tty)"; return 1; }
31
+ if [ -z "${TMUX:-}" ] || [ -z "${TMUX_PANE:-}" ]; then
32
+ SV_REFUSAL="not inside a tmux pane (TMUX or TMUX_PANE is empty)"; return 1
33
+ fi
34
+ _sv_sock="${TMUX%%,*}"
35
+ _sv_tab="$(printf '\t')"
36
+ _sv_info="$(tmux -S "$_sv_sock" display-message -p -t "$TMUX_PANE" "#{pane_tty}$_sv_tab#{window_id}$_sv_tab#{session_name}" 2>/dev/null)" || _sv_info=""
37
+ _sv_ptty="${_sv_info%%"$_sv_tab"*}"; _sv_rest="${_sv_info#*"$_sv_tab"}"
38
+ _sv_wid="${_sv_rest%%"$_sv_tab"*}"; _sv_name="${_sv_rest#*"$_sv_tab"}"
39
+ if [ -z "$_sv_info" ] || [ "$_sv_ptty" != "$_sv_own" ]; then
40
+ SV_REFUSAL="not this pane's terminal: $_sv_own is not the tty of pane $TMUX_PANE"; return 1
41
+ fi
42
+ # every session that holds this pane's window, then the clients on those
43
+ _sv_sids="$(tmux -S "$_sv_sock" list-windows -a -F "#{window_id}$_sv_tab#{session_id}" 2>/dev/null \
44
+ | awk -F "$_sv_tab" -v w="$_sv_wid" '$1 == w {printf "%s ", $2}')" || _sv_sids=""
45
+ _sv_clients="$(tmux -S "$_sv_sock" list-clients -F "#{session_id}$_sv_tab#{client_tty}$_sv_tab#{session_name}" 2>/dev/null \
46
+ | awk -F "$_sv_tab" -v s="$_sv_sids" 'BEGIN {n = split(s, a, " "); for (i = 1; i <= n; i++) ok[a[i]] = 1}
47
+ ($1 in ok) {print $2 "\t" $3}')" || _sv_clients=""
48
+ _sv_n="$(printf '%s\n' "$_sv_clients" | grep -c . || true)"
49
+ if [ "$_sv_n" -eq 0 ]; then
50
+ SV_REFUSAL="no terminal is attached to this session ($_sv_name)"; return 1
51
+ fi
52
+ if [ "$_sv_n" -gt 1 ]; then
53
+ SV_REFUSAL="this session ($_sv_name) is open in more than one terminal (detach the others, or run it outside tmux)"; return 1
54
+ fi
55
+ SV_CLIENT="${_sv_clients%%"$_sv_tab"*}"; SV_SESSION="${_sv_clients#*"$_sv_tab"}"
56
+ return 0
57
+ }
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
4
4
 
5
5
  [project]
6
6
  name = "sessionview"
7
- version = "0.2.0"
7
+ version = "0.3.0"
8
8
  description = "A sidebar for tmux: your projects (sessions) and your agents, here and on remote hosts."
9
9
  readme = "README.md"
10
10
  requires-python = ">=3.11,<3.15"
@@ -10,7 +10,7 @@ import os
10
10
  import sys
11
11
  from pathlib import Path
12
12
 
13
- __version__ = "0.2.0"
13
+ __version__ = "0.3.0"
14
14
 
15
15
  DATA = Path(__file__).resolve().parent / "_data"
16
16
  PUBLIC = ("sessionview", "sessionview-config", "sessionview-session", "sessionview-messages")
File without changes
File without changes
File without changes
File without changes
File without changes
File without changes