tmux-deck 0.1.0__py3-none-any.whl
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.
tmux_deck/__init__.py
ADDED
tmux_deck/__main__.py
ADDED
tmux_deck/app.py
ADDED
|
@@ -0,0 +1,401 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""tmux-deck — a sleek interactive grid of your tmux sessions.
|
|
3
|
+
|
|
4
|
+
Each session is a card showing what the project is up to: git branch,
|
|
5
|
+
dirty/unpushed state, last commit, and what's running right now.
|
|
6
|
+
Navigate with arrows or mouse, Enter to attach, 1-9 to jump.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
__version__ = "0.1.0"
|
|
10
|
+
|
|
11
|
+
import math
|
|
12
|
+
import os
|
|
13
|
+
import subprocess
|
|
14
|
+
import sys
|
|
15
|
+
import time
|
|
16
|
+
|
|
17
|
+
os.environ.setdefault("ESCDELAY", "25") # snappy Esc
|
|
18
|
+
import curses
|
|
19
|
+
|
|
20
|
+
SEP = "\x1f"
|
|
21
|
+
CARD_H = 6 # title border + 4 content lines + bottom border
|
|
22
|
+
ROW_GAP = 1
|
|
23
|
+
COL_GAP = 2
|
|
24
|
+
MIN_CARD_W = 36
|
|
25
|
+
MAX_CARD_W = 50
|
|
26
|
+
|
|
27
|
+
HOME = os.path.expanduser("~")
|
|
28
|
+
|
|
29
|
+
A = {} # attribute palette, filled by init_colors()
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# ── data ────────────────────────────────────────────────────────────────────
|
|
33
|
+
|
|
34
|
+
def sh(args):
|
|
35
|
+
return subprocess.run(args, capture_output=True, text=True)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def out(args):
|
|
39
|
+
return sh(args).stdout
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def short_when(s):
|
|
43
|
+
"""Compress git's '%cr' style dates: '2 hours ago' -> '2h ago'."""
|
|
44
|
+
for long, short in (
|
|
45
|
+
("seconds", "s"), ("second", "s"),
|
|
46
|
+
("minutes", "m"), ("minute", "m"),
|
|
47
|
+
("hours", "h"), ("hour", "h"),
|
|
48
|
+
("days", "d"), ("day", "d"),
|
|
49
|
+
("weeks", "w"), ("week", "w"),
|
|
50
|
+
("months", "mo"), ("month", "mo"),
|
|
51
|
+
("years", "y"), ("year", "y"),
|
|
52
|
+
):
|
|
53
|
+
s = s.replace(" " + long, short).replace(long, short)
|
|
54
|
+
return s
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def git_info(path):
|
|
58
|
+
if not path or not os.path.isdir(path):
|
|
59
|
+
return None
|
|
60
|
+
|
|
61
|
+
def g(*a):
|
|
62
|
+
return sh(["git", "-C", path, *a])
|
|
63
|
+
|
|
64
|
+
r = g("rev-parse", "--abbrev-ref", "HEAD")
|
|
65
|
+
if r.returncode != 0:
|
|
66
|
+
return None
|
|
67
|
+
branch = r.stdout.strip()
|
|
68
|
+
|
|
69
|
+
dirty = len([l for l in g("status", "--porcelain").stdout.splitlines() if l.strip()])
|
|
70
|
+
|
|
71
|
+
log = g("log", "-1", "--format=%s" + SEP + "%cr").stdout.strip()
|
|
72
|
+
subject, when = (log.split(SEP) + ["", ""])[:2]
|
|
73
|
+
|
|
74
|
+
ab = ""
|
|
75
|
+
r2 = g("rev-list", "--left-right", "--count", "@{u}...HEAD")
|
|
76
|
+
if r2.returncode == 0 and r2.stdout.split():
|
|
77
|
+
behind, ahead = r2.stdout.split()
|
|
78
|
+
bits = []
|
|
79
|
+
if int(ahead):
|
|
80
|
+
bits.append("↑" + ahead)
|
|
81
|
+
if int(behind):
|
|
82
|
+
bits.append("↓" + behind)
|
|
83
|
+
ab = " ".join(bits)
|
|
84
|
+
|
|
85
|
+
return {
|
|
86
|
+
"branch": branch,
|
|
87
|
+
"dirty": dirty,
|
|
88
|
+
"subject": subject,
|
|
89
|
+
"when": short_when(when),
|
|
90
|
+
"ab": ab,
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
def tilde(path):
|
|
95
|
+
return "~" + path[len(HOME):] if path.startswith(HOME) else path
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def ell(text, n):
|
|
99
|
+
if n <= 0:
|
|
100
|
+
return ""
|
|
101
|
+
return text if len(text) <= n else text[: max(0, n - 1)] + "…"
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def get_sessions():
|
|
105
|
+
fmt = SEP.join(
|
|
106
|
+
[
|
|
107
|
+
"#{session_name}",
|
|
108
|
+
"#{session_windows}",
|
|
109
|
+
"#{session_attached}",
|
|
110
|
+
"#{session_activity}",
|
|
111
|
+
"#{session_group}",
|
|
112
|
+
]
|
|
113
|
+
)
|
|
114
|
+
sessions = []
|
|
115
|
+
for line in out(["tmux", "list-sessions", "-F", fmt]).splitlines():
|
|
116
|
+
name, windows, attached, activity, group = line.split(SEP)
|
|
117
|
+
target = f"={name}:"
|
|
118
|
+
winfo = out(
|
|
119
|
+
[
|
|
120
|
+
"tmux",
|
|
121
|
+
"display-message",
|
|
122
|
+
"-p",
|
|
123
|
+
"-t",
|
|
124
|
+
target,
|
|
125
|
+
SEP.join(
|
|
126
|
+
["#{window_name}", "#{pane_current_command}", "#{pane_current_path}"]
|
|
127
|
+
),
|
|
128
|
+
]
|
|
129
|
+
).strip()
|
|
130
|
+
wname, cmd, path = (winfo.split(SEP) + ["", "", ""])[:3]
|
|
131
|
+
|
|
132
|
+
tail = ""
|
|
133
|
+
for l in reversed(out(["tmux", "capture-pane", "-p", "-t", target]).splitlines()):
|
|
134
|
+
if l.strip():
|
|
135
|
+
tail = " ".join(l.split())
|
|
136
|
+
break
|
|
137
|
+
|
|
138
|
+
sessions.append(
|
|
139
|
+
{
|
|
140
|
+
"name": name,
|
|
141
|
+
"windows": int(windows or 0),
|
|
142
|
+
"attached": int(attached or 0) > 0,
|
|
143
|
+
"activity": int(activity or 0),
|
|
144
|
+
"group": group,
|
|
145
|
+
"wname": wname,
|
|
146
|
+
"cmd": cmd,
|
|
147
|
+
"path": path,
|
|
148
|
+
"git": git_info(path),
|
|
149
|
+
"tail": tail,
|
|
150
|
+
}
|
|
151
|
+
)
|
|
152
|
+
return sessions
|
|
153
|
+
|
|
154
|
+
|
|
155
|
+
def rel_time(ts):
|
|
156
|
+
d = max(0, int(time.time()) - ts)
|
|
157
|
+
if d < 60:
|
|
158
|
+
return f"{d}s ago"
|
|
159
|
+
if d < 3600:
|
|
160
|
+
return f"{d // 60}m ago"
|
|
161
|
+
if d < 86400:
|
|
162
|
+
return f"{d // 3600}h ago"
|
|
163
|
+
return f"{d // 86400}d ago"
|
|
164
|
+
|
|
165
|
+
|
|
166
|
+
# ── drawing ─────────────────────────────────────────────────────────────────
|
|
167
|
+
|
|
168
|
+
def init_colors():
|
|
169
|
+
curses.use_default_colors()
|
|
170
|
+
ext = curses.has_colors() and curses.COLORS >= 256
|
|
171
|
+
|
|
172
|
+
def mk(pid, ext_fg, base_fg, extra=0):
|
|
173
|
+
curses.init_pair(pid, ext_fg if ext else base_fg, -1)
|
|
174
|
+
return curses.color_pair(pid) | extra
|
|
175
|
+
|
|
176
|
+
A["accent"] = mk(1, 45, curses.COLOR_CYAN) # selection
|
|
177
|
+
A["green"] = mk(2, 114, curses.COLOR_GREEN) # clean / attached
|
|
178
|
+
A["yellow"] = mk(3, 179, curses.COLOR_YELLOW) # dirty
|
|
179
|
+
A["text"] = mk(4, 252, -1) # primary text
|
|
180
|
+
A["grey"] = mk(5, 246, -1, 0 if ext else curses.A_DIM) # secondary
|
|
181
|
+
A["dim"] = mk(6, 241, -1, 0 if ext else curses.A_DIM) # tertiary
|
|
182
|
+
A["border"] = mk(7, 238, -1, 0 if ext else curses.A_DIM) # card frame
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def draw_card(scr, s, idx, y, x, w, selected, scr_h):
|
|
186
|
+
border = (A["accent"] | curses.A_BOLD) if selected else A["border"]
|
|
187
|
+
|
|
188
|
+
def put(dy, col, text, attr=0):
|
|
189
|
+
yy = y + dy
|
|
190
|
+
if 0 <= yy < scr_h and 0 <= col < w:
|
|
191
|
+
try:
|
|
192
|
+
scr.addnstr(yy, x + col, text, w - col, attr)
|
|
193
|
+
except curses.error:
|
|
194
|
+
pass
|
|
195
|
+
|
|
196
|
+
# top border carries the title: ╭╴name ●╶──────╮
|
|
197
|
+
put(0, 0, "╭╴", border)
|
|
198
|
+
title = ell(s["name"], w - 12)
|
|
199
|
+
name_attr = curses.A_BOLD | (A["accent"] if selected else A["text"])
|
|
200
|
+
put(0, 2, title, name_attr)
|
|
201
|
+
col = 2 + len(title)
|
|
202
|
+
if s["attached"]:
|
|
203
|
+
put(0, col, " ●", A["green"])
|
|
204
|
+
col += 2
|
|
205
|
+
put(0, col, "╶" + "─" * max(0, w - 2 - col) + "╮", border)
|
|
206
|
+
|
|
207
|
+
# side walls
|
|
208
|
+
for dy in range(1, CARD_H - 1):
|
|
209
|
+
put(dy, 0, "│", border)
|
|
210
|
+
put(dy, w - 1, "│", border)
|
|
211
|
+
|
|
212
|
+
# bottom border carries the quick-jump digit: ╰────╴3╶─╯
|
|
213
|
+
if idx < 9:
|
|
214
|
+
put(CARD_H - 1, 0, "╰" + "─" * (w - 6) + "╴", border)
|
|
215
|
+
put(CARD_H - 1, w - 4, str(idx + 1), A["dim"])
|
|
216
|
+
put(CARD_H - 1, w - 3, "╶─╯", border)
|
|
217
|
+
else:
|
|
218
|
+
put(CARD_H - 1, 0, "╰" + "─" * (w - 2) + "╯", border)
|
|
219
|
+
|
|
220
|
+
left = 3
|
|
221
|
+
iw = w - left - 2
|
|
222
|
+
|
|
223
|
+
def puti(dy, text, attr=0, col=0):
|
|
224
|
+
if col < iw:
|
|
225
|
+
put(dy, left + col, ell(text, iw - col), attr)
|
|
226
|
+
|
|
227
|
+
# 1 — windows · activity · group
|
|
228
|
+
meta = f"{s['windows']} win · {rel_time(s['activity'])}"
|
|
229
|
+
if s["group"]:
|
|
230
|
+
meta += f" · ⧉ {s['group']}"
|
|
231
|
+
puti(1, meta, A["grey"])
|
|
232
|
+
|
|
233
|
+
# 2 — git branch state, or the working directory
|
|
234
|
+
g = s["git"]
|
|
235
|
+
if g:
|
|
236
|
+
suffix = ""
|
|
237
|
+
if g["dirty"]:
|
|
238
|
+
suffix += f" ±{g['dirty']}"
|
|
239
|
+
if g["ab"]:
|
|
240
|
+
suffix += f" {g['ab']}"
|
|
241
|
+
state = ell(f"⎇ {g['branch']}", iw - len(suffix)) + suffix
|
|
242
|
+
puti(2, state, A["yellow"] if g["dirty"] else A["green"])
|
|
243
|
+
else:
|
|
244
|
+
puti(2, tilde(s["path"]), A["grey"])
|
|
245
|
+
|
|
246
|
+
# 3 — last commit as the project's "latest update", age right-aligned
|
|
247
|
+
if g and g["subject"]:
|
|
248
|
+
when = g["when"]
|
|
249
|
+
wcol = max(0, iw - len(when))
|
|
250
|
+
puti(3, ell(g["subject"], wcol - 1), A["text"])
|
|
251
|
+
puti(3, when, A["dim"], wcol)
|
|
252
|
+
else:
|
|
253
|
+
puti(3, s["tail"], A["dim"])
|
|
254
|
+
|
|
255
|
+
# 4 — what's running right now
|
|
256
|
+
running = f"▸ {s['wname']}"
|
|
257
|
+
if s["cmd"] and s["cmd"] != s["wname"]:
|
|
258
|
+
running += f" · {s['cmd']}"
|
|
259
|
+
puti(4, running, A["grey"])
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
def draw_chrome(scr, n, w, h):
|
|
263
|
+
try:
|
|
264
|
+
scr.addnstr(0, 2, "tmux deck", min(w - 3, 10), curses.A_BOLD | A["text"])
|
|
265
|
+
count = f"· {n} session{'s' if n != 1 else ''}"
|
|
266
|
+
scr.addnstr(0, 12, count, max(0, w - 14), A["grey"])
|
|
267
|
+
except curses.error:
|
|
268
|
+
pass
|
|
269
|
+
hints = "←↑↓→ move ↵ attach 1-9 jump x kill r refresh q quit"
|
|
270
|
+
try:
|
|
271
|
+
scr.addnstr(h - 1, max(0, (w - len(hints)) // 2), hints, w - 1, A["dim"])
|
|
272
|
+
except curses.error:
|
|
273
|
+
pass
|
|
274
|
+
|
|
275
|
+
|
|
276
|
+
def confirm(scr, msg):
|
|
277
|
+
h, w = scr.getmaxyx()
|
|
278
|
+
try:
|
|
279
|
+
scr.addnstr(h - 1, 0, msg.center(w - 1), w - 1, A["yellow"] | curses.A_REVERSE)
|
|
280
|
+
except curses.error:
|
|
281
|
+
pass
|
|
282
|
+
scr.refresh()
|
|
283
|
+
return scr.getch() in (ord("y"), ord("Y"))
|
|
284
|
+
|
|
285
|
+
|
|
286
|
+
# ── main loop ───────────────────────────────────────────────────────────────
|
|
287
|
+
|
|
288
|
+
def run(scr):
|
|
289
|
+
curses.curs_set(0)
|
|
290
|
+
init_colors()
|
|
291
|
+
curses.mousemask(curses.ALL_MOUSE_EVENTS)
|
|
292
|
+
curses.mouseinterval(200)
|
|
293
|
+
scr.keypad(True)
|
|
294
|
+
|
|
295
|
+
sessions = get_sessions()
|
|
296
|
+
sel, off = 0, 0
|
|
297
|
+
stride_y = CARD_H + ROW_GAP
|
|
298
|
+
|
|
299
|
+
while True:
|
|
300
|
+
if not sessions:
|
|
301
|
+
return None
|
|
302
|
+
sel = max(0, min(sel, len(sessions) - 1))
|
|
303
|
+
|
|
304
|
+
h, w = scr.getmaxyx()
|
|
305
|
+
n = len(sessions)
|
|
306
|
+
ncols = max(1, min((w - 2 + COL_GAP) // (MIN_CARD_W + COL_GAP), n))
|
|
307
|
+
card_w = min(MAX_CARD_W, (w - 2 - (ncols - 1) * COL_GAP) // ncols)
|
|
308
|
+
grid_w = ncols * card_w + (ncols - 1) * COL_GAP
|
|
309
|
+
x0 = max(1, (w - grid_w) // 2)
|
|
310
|
+
y0 = 2
|
|
311
|
+
nrows = math.ceil(n / ncols)
|
|
312
|
+
view_h = h - 1 - y0
|
|
313
|
+
|
|
314
|
+
sel_top = (sel // ncols) * stride_y
|
|
315
|
+
if sel_top < off:
|
|
316
|
+
off = sel_top
|
|
317
|
+
if sel_top + CARD_H > off + view_h:
|
|
318
|
+
off = sel_top + CARD_H - view_h
|
|
319
|
+
off = max(0, min(off, max(0, nrows * stride_y - ROW_GAP - view_h)))
|
|
320
|
+
|
|
321
|
+
scr.erase()
|
|
322
|
+
draw_chrome(scr, n, w, h)
|
|
323
|
+
|
|
324
|
+
boxes = []
|
|
325
|
+
for i, s in enumerate(sessions):
|
|
326
|
+
r, c = divmod(i, ncols)
|
|
327
|
+
y = y0 + r * stride_y - off
|
|
328
|
+
x = x0 + c * (card_w + COL_GAP)
|
|
329
|
+
boxes.append((y, x, CARD_H, card_w))
|
|
330
|
+
if y + CARD_H > y0 - 1 and y < h - 1:
|
|
331
|
+
draw_card(scr, s, i, y, x, card_w, i == sel, h - 1)
|
|
332
|
+
scr.refresh()
|
|
333
|
+
|
|
334
|
+
ch = scr.getch()
|
|
335
|
+
if ch in (ord("q"), 27):
|
|
336
|
+
return None
|
|
337
|
+
elif ch in (curses.KEY_ENTER, 10, 13):
|
|
338
|
+
return sessions[sel]["name"]
|
|
339
|
+
elif ord("1") <= ch <= ord("9"):
|
|
340
|
+
i = ch - ord("1")
|
|
341
|
+
if i < n:
|
|
342
|
+
return sessions[i]["name"]
|
|
343
|
+
elif ch == curses.KEY_LEFT:
|
|
344
|
+
sel = max(0, sel - 1)
|
|
345
|
+
elif ch == curses.KEY_RIGHT:
|
|
346
|
+
sel = min(n - 1, sel + 1)
|
|
347
|
+
elif ch == curses.KEY_UP:
|
|
348
|
+
if sel - ncols >= 0:
|
|
349
|
+
sel -= ncols
|
|
350
|
+
elif ch == curses.KEY_DOWN:
|
|
351
|
+
if sel + ncols < n:
|
|
352
|
+
sel += ncols
|
|
353
|
+
elif ch == ord("r"):
|
|
354
|
+
sessions = get_sessions()
|
|
355
|
+
elif ch == ord("x"):
|
|
356
|
+
name = sessions[sel]["name"]
|
|
357
|
+
if confirm(scr, f"kill session '{name}'? y/N"):
|
|
358
|
+
sh(["tmux", "kill-session", "-t", f"={name}"])
|
|
359
|
+
sessions = get_sessions()
|
|
360
|
+
elif ch == curses.KEY_MOUSE:
|
|
361
|
+
try:
|
|
362
|
+
_, mx, my, _, bstate = curses.getmouse()
|
|
363
|
+
except curses.error:
|
|
364
|
+
continue
|
|
365
|
+
hit = None
|
|
366
|
+
for i, (by, bx, bh, bw) in enumerate(boxes):
|
|
367
|
+
if by <= my < by + bh and bx <= mx < bx + bw:
|
|
368
|
+
hit = i
|
|
369
|
+
break
|
|
370
|
+
if hit is None:
|
|
371
|
+
continue
|
|
372
|
+
if bstate & curses.BUTTON1_DOUBLE_CLICKED:
|
|
373
|
+
return sessions[hit]["name"]
|
|
374
|
+
if bstate & (curses.BUTTON1_CLICKED | curses.BUTTON1_PRESSED):
|
|
375
|
+
if hit == sel:
|
|
376
|
+
return sessions[hit]["name"]
|
|
377
|
+
sel = hit
|
|
378
|
+
|
|
379
|
+
|
|
380
|
+
def main():
|
|
381
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("--version", "-V"):
|
|
382
|
+
print(f"tmux-deck {__version__}")
|
|
383
|
+
return
|
|
384
|
+
if len(sys.argv) > 1 and sys.argv[1] in ("--help", "-h"):
|
|
385
|
+
print(__doc__.strip())
|
|
386
|
+
print("\nusage: tmux-deck [--version]")
|
|
387
|
+
return
|
|
388
|
+
if not out(["tmux", "list-sessions", "-F", "#{session_name}"]).strip():
|
|
389
|
+
print("No tmux sessions are running.", file=sys.stderr)
|
|
390
|
+
sys.exit(1)
|
|
391
|
+
name = curses.wrapper(run)
|
|
392
|
+
if not name:
|
|
393
|
+
sys.exit(0)
|
|
394
|
+
if os.environ.get("TMUX"):
|
|
395
|
+
os.execvp("tmux", ["tmux", "switch-client", "-t", f"={name}"])
|
|
396
|
+
else:
|
|
397
|
+
os.execvp("tmux", ["tmux", "attach-session", "-t", f"={name}"])
|
|
398
|
+
|
|
399
|
+
|
|
400
|
+
if __name__ == "__main__":
|
|
401
|
+
main()
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: tmux-deck
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: A sleek interactive grid of your tmux sessions — project status at a glance, jump in with a keystroke.
|
|
5
|
+
Project-URL: Homepage, https://github.com/bdepyzy/tmux-deck
|
|
6
|
+
Project-URL: Issues, https://github.com/bdepyzy/tmux-deck/issues
|
|
7
|
+
Author-email: bdepyzy <obo.dan87@gmail.com>
|
|
8
|
+
License-Expression: MIT
|
|
9
|
+
License-File: LICENSE
|
|
10
|
+
Keywords: curses,dashboard,sessions,terminal,tmux,tui
|
|
11
|
+
Classifier: Development Status :: 4 - Beta
|
|
12
|
+
Classifier: Environment :: Console :: Curses
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: Operating System :: POSIX
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Topic :: Terminals
|
|
17
|
+
Classifier: Topic :: Utilities
|
|
18
|
+
Requires-Python: >=3.9
|
|
19
|
+
Description-Content-Type: text/markdown
|
|
20
|
+
|
|
21
|
+
# tmux-deck
|
|
22
|
+
|
|
23
|
+
A sleek interactive deck of your tmux sessions. Each session is a card that
|
|
24
|
+
tells you what the project is actually up to — no LLMs, just your environment:
|
|
25
|
+
|
|
26
|
+
```
|
|
27
|
+
tmux deck · 4 sessions
|
|
28
|
+
|
|
29
|
+
╭╴api-server ●╶─────────────────────╮ ╭╴blog╶─────────────────────────────╮
|
|
30
|
+
│ 5 win · 3m ago │ │ 2 win · 2h ago │
|
|
31
|
+
│ ⎇ feat/rate-limiting ±3 ↑1 │ │ ⎇ main │
|
|
32
|
+
│ Add sliding-window li… 20h ago │ │ publish: zero-downtime d… 3d ago│
|
|
33
|
+
│ ▸ server · vim │ │ ▸ blog · zsh │
|
|
34
|
+
╰───────────────────────────────╴1╶─╯ ╰───────────────────────────────╴2╶─╯
|
|
35
|
+
|
|
36
|
+
╭╴dotfiles╶─────────────────────────╮ ╭╴scratch╶──────────────────────────╮
|
|
37
|
+
│ 3 win · 1d ago │ │ 1 win · 40s ago │
|
|
38
|
+
│ ⎇ main ±12 │ │ ~/tmp │
|
|
39
|
+
│ waybar: battery module… 2w ago │ │ $ python bench.py --runs 50 │
|
|
40
|
+
│ ▸ hypr · zsh │ │ ▸ scratch · python │
|
|
41
|
+
╰───────────────────────────────╴3╶─╯ ╰───────────────────────────────╴4╶─╯
|
|
42
|
+
|
|
43
|
+
←↑↓→ move ↵ attach 1-9 jump x kill r refresh q quit
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
Each card shows:
|
|
47
|
+
|
|
48
|
+
- **session name**, with a green `●` when attached
|
|
49
|
+
- **windows · last activity**, and the session group if any
|
|
50
|
+
- **git status** of the active pane's directory — branch, `±N` dirty files,
|
|
51
|
+
`↑N`/`↓N` unpushed/unpulled commits (yellow when dirty, green when clean)
|
|
52
|
+
- **last commit subject + age** — the project's latest update, straight from
|
|
53
|
+
`git log` (falls back to the last line of pane output outside a repo)
|
|
54
|
+
- **what's running** — active window name and current command
|
|
55
|
+
|
|
56
|
+
## Install
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
pipx install tmux-deck # or: uv tool install tmux-deck
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Requires Python ≥ 3.9 and tmux. No Python dependencies — it's a single
|
|
63
|
+
stdlib-curses file.
|
|
64
|
+
|
|
65
|
+
## Use
|
|
66
|
+
|
|
67
|
+
```sh
|
|
68
|
+
tmux-deck
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
| key | action |
|
|
72
|
+
| --- | --- |
|
|
73
|
+
| `←↑↓→` / mouse | move between cards |
|
|
74
|
+
| `Enter` / click selected / double-click | attach (uses `switch-client` inside tmux) |
|
|
75
|
+
| `1`–`9` | jump straight into that session |
|
|
76
|
+
| `x` | kill selected session (confirms) |
|
|
77
|
+
| `r` | refresh |
|
|
78
|
+
| `q` / `Esc` | quit |
|
|
79
|
+
|
|
80
|
+
Handy tmux binding — the deck as a centered popup on `prefix + S`:
|
|
81
|
+
|
|
82
|
+
```tmux
|
|
83
|
+
bind S display-popup -E -w 90% -h 80% tmux-deck
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## License
|
|
87
|
+
|
|
88
|
+
MIT
|
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
tmux_deck/__init__.py,sha256=TFUzWUC_bxGXyTp5QA9tKNdOjAtiotFXIbIxo-NpZBQ,79
|
|
2
|
+
tmux_deck/__main__.py,sha256=wCePiOfM6muFcxFvPgWIiNhSbFxCZiPYOpPm7munn9A,39
|
|
3
|
+
tmux_deck/app.py,sha256=ssVEYHtvR3MhZkEP8rsVBqtdji7TPQCsExfEtOGav5c,12720
|
|
4
|
+
tmux_deck-0.1.0.dist-info/METADATA,sha256=eYUGUGCXgmnPR9-q7hWnlX8q8rSWodQ11u_lLNBrqU8,3786
|
|
5
|
+
tmux_deck-0.1.0.dist-info/WHEEL,sha256=lCkmxWfQsSc9CfIClYeavTdQeEX2toPqufh9gI35EQA,87
|
|
6
|
+
tmux_deck-0.1.0.dist-info/entry_points.txt,sha256=qETvWyjIT2HFpEyZITEuJPQHscqHUAox_scUjGkAW_g,49
|
|
7
|
+
tmux_deck-0.1.0.dist-info/licenses/LICENSE,sha256=tuGuqc80wOKCPacM2oI0orqI7alaLZkF6exJkp-bHoY,1064
|
|
8
|
+
tmux_deck-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 bdepyzy
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|