tabmancer 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.
Files changed (57) hide show
  1. tabmancer/__init__.py +3 -0
  2. tabmancer/__main__.py +3 -0
  3. tabmancer/accounts.py +119 -0
  4. tabmancer/agy.py +107 -0
  5. tabmancer/app.py +2875 -0
  6. tabmancer/background.py +300 -0
  7. tabmancer/bitart.py +816 -0
  8. tabmancer/bitmoves.py +442 -0
  9. tabmancer/blocks.py +115 -0
  10. tabmancer/cleanup.py +311 -0
  11. tabmancer/cli.py +431 -0
  12. tabmancer/clis.py +65 -0
  13. tabmancer/clisettings.py +75 -0
  14. tabmancer/codex.py +196 -0
  15. tabmancer/config.py +50 -0
  16. tabmancer/context.py +45 -0
  17. tabmancer/demo.py +391 -0
  18. tabmancer/dialogs.py +36 -0
  19. tabmancer/doctor.py +252 -0
  20. tabmancer/focus.py +195 -0
  21. tabmancer/gitstate.py +86 -0
  22. tabmancer/handover.py +168 -0
  23. tabmancer/handoverview.py +84 -0
  24. tabmancer/keysview.py +134 -0
  25. tabmancer/launch.py +309 -0
  26. tabmancer/marks.py +51 -0
  27. tabmancer/mascot.py +836 -0
  28. tabmancer/newsessions.py +265 -0
  29. tabmancer/rename.py +38 -0
  30. tabmancer/report.py +154 -0
  31. tabmancer/safework.py +64 -0
  32. tabmancer/search.py +137 -0
  33. tabmancer/sessions.py +686 -0
  34. tabmancer/shortcuts.py +161 -0
  35. tabmancer/shutdowns.py +164 -0
  36. tabmancer/sixel.py +481 -0
  37. tabmancer/stats.py +166 -0
  38. tabmancer/statsview.py +490 -0
  39. tabmancer/summarize.py +139 -0
  40. tabmancer/teams.py +538 -0
  41. tabmancer/teamtree.py +271 -0
  42. tabmancer/teamview.py +1002 -0
  43. tabmancer/theme.py +105 -0
  44. tabmancer/toast.py +89 -0
  45. tabmancer/tokens.py +498 -0
  46. tabmancer/typein.py +140 -0
  47. tabmancer/usage.py +294 -0
  48. tabmancer/viewmodel.py +406 -0
  49. tabmancer/widgets.py +453 -0
  50. tabmancer/workspaces.py +116 -0
  51. tabmancer-0.1.0.dist-info/METADATA +147 -0
  52. tabmancer-0.1.0.dist-info/RECORD +57 -0
  53. tabmancer-0.1.0.dist-info/WHEEL +4 -0
  54. tabmancer-0.1.0.dist-info/entry_points.txt +5 -0
  55. tabmancer-0.1.0.dist-info/licenses/LICENSE +202 -0
  56. tabmancer-0.1.0.dist-info/licenses/LICENSE-BIT +415 -0
  57. tabmancer-0.1.0.dist-info/licenses/NOTICE +11 -0
tabmancer/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """tabmancer: a terminal dashboard for Claude Code sessions."""
2
+
3
+ __version__ = "0.1.0"
tabmancer/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ main()
tabmancer/accounts.py ADDED
@@ -0,0 +1,119 @@
1
+ """The accounts the coding CLIs are signed in with, read from their own files, never over the network:
2
+ shown in the dashboard's top bar, beside the plan limits they are about.
3
+
4
+ Claude Code keeps the account in .claude.json, in CLAUDE_CONFIG_DIR when that is set, in your home
5
+ folder otherwise: oauthAccount, with its email and the organisation's plan. /login writes it again,
6
+ so a switch to another account shows at the next look. Codex keeps its sign-in in auth.json under
7
+ CODEX_HOME (~/.codex): with ChatGPT, an id token whose middle part says the email and the plan (read
8
+ for showing only: nothing is checked or sent); otherwise an API key, which has no account to show.
9
+ Antigravity keeps its sign-in in the system's credential store, which tabmancer does not read.
10
+ """
11
+
12
+ from __future__ import annotations
13
+
14
+ import base64
15
+ import json
16
+ import os
17
+ from dataclasses import dataclass
18
+ from pathlib import Path
19
+
20
+ from . import codex
21
+
22
+
23
+ @dataclass(frozen=True)
24
+ class Account:
25
+ cli: str # "claude" or "codex"
26
+ email: str # what it is signed in as; for an API key, "API key"
27
+ plan: str = "" # "Max 5x", "Pro", "free"...
28
+
29
+
30
+ PLANS = {"claude_max": "Max", "claude_pro": "Pro", "claude_team": "Team", "claude_enterprise": "Enterprise"}
31
+
32
+
33
+ def claude_file() -> Path:
34
+ """Where Claude Code keeps its settings and account: in CLAUDE_CONFIG_DIR when set (a second
35
+ account's folder, say), and then only there; in the home folder otherwise."""
36
+ folder = os.environ.get("CLAUDE_CONFIG_DIR")
37
+ return Path(folder) / ".claude.json" if folder else Path.home() / ".claude.json"
38
+
39
+
40
+ _read_before: dict[Path, tuple[tuple[int, int], dict]] = {}
41
+
42
+
43
+ def _read(path: Path) -> dict:
44
+ """A JSON file as a dict ({} when it is not one), read again only when it changed: .claude.json
45
+ holds a lot besides the account, and the dashboard looks every few seconds."""
46
+ try:
47
+ stat = path.stat()
48
+ stamp = (stat.st_mtime_ns, stat.st_size)
49
+ if path in _read_before and _read_before[path][0] == stamp:
50
+ return _read_before[path][1]
51
+ found = json.loads(path.read_text(encoding="utf-8"))
52
+ except (OSError, ValueError):
53
+ return {}
54
+ found = found if isinstance(found, dict) else {}
55
+ _read_before[path] = (stamp, found)
56
+ return found
57
+
58
+
59
+ def claude() -> Account | None:
60
+ account = _read(claude_file()).get("oauthAccount")
61
+ if not isinstance(account, dict) or not account.get("emailAddress"):
62
+ return None
63
+ plan = PLANS.get(str(account.get("organizationType") or ""), "")
64
+ tier = str(account.get("organizationRateLimitTier") or "")
65
+ for size in ("20x", "5x"): # Max comes in two sizes, said only by the rate limit tier
66
+ if plan == "Max" and tier.endswith(size):
67
+ plan = f"Max {size}"
68
+ return Account("claude", str(account["emailAddress"]), plan)
69
+
70
+
71
+ def _claims(token: str) -> dict:
72
+ """The middle part of a JSON web token, as it says it: for showing, nothing is verified."""
73
+ try:
74
+ middle = token.split(".")[1]
75
+ found = json.loads(base64.urlsafe_b64decode(middle + "=" * (-len(middle) % 4)))
76
+ except (IndexError, ValueError):
77
+ return {}
78
+ return found if isinstance(found, dict) else {}
79
+
80
+
81
+ def codex_account() -> Account | None:
82
+ auth = _read(codex.home() / "auth.json")
83
+ if not auth:
84
+ return None
85
+ tokens = auth.get("tokens") if isinstance(auth.get("tokens"), dict) else {}
86
+ claims = _claims(str(tokens.get("id_token") or ""))
87
+ if claims.get("email"):
88
+ openai = claims.get("https://api.openai.com/auth")
89
+ plan = str((openai if isinstance(openai, dict) else {}).get("chatgpt_plan_type") or "")
90
+ return Account("codex", str(claims["email"]), plan)
91
+ if auth.get("OPENAI_API_KEY"):
92
+ return Account("codex", "API key")
93
+ return None
94
+
95
+
96
+ def signed_in() -> list[Account]:
97
+ """Claude Code's account, then Codex's when its sessions are listed (turned on with ,)."""
98
+ from . import clis
99
+
100
+ found = [claude()]
101
+ if "codex" in clis.enabled():
102
+ found.append(codex_account())
103
+ return [account for account in found if account is not None]
104
+
105
+
106
+ def words(account: Account, room: int) -> str:
107
+ """The account in at most room cells: the email with its plan; the email whole, without the
108
+ plan (the email tells accounts apart); the email cut in the middle; "" when not even that fits."""
109
+ plan = f" · {account.plan}" if account.plan else ""
110
+ email = account.email
111
+ if len(email) + len(plan) <= room:
112
+ return email + plan
113
+ if len(email) <= room:
114
+ return email
115
+ name, at, host = email.partition("@")
116
+ keep = room - len(at + host) - 1 # what is left of the name, and its "…"
117
+ if at and keep >= 3:
118
+ return f"{name[:keep]}…{at}{host}"
119
+ return email[:room - 1] + "…" if room >= 8 else ""
tabmancer/agy.py ADDED
@@ -0,0 +1,107 @@
1
+ """Antigravity CLI (agy) sessions, from its conversation index.
2
+
3
+ agy keeps an index of its conversations in
4
+ ~/.gemini/antigravity-cli/conversation_summaries.db (SQLite): title, folder, times and
5
+ run state. The conversations themselves are protobuf blobs, so prompts and replies
6
+ cannot be read; a session shows its title, folder and when it was last used.
7
+ The database is opened read-only and never written.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import json
13
+ import sqlite3
14
+ from datetime import datetime, timedelta, timezone
15
+ from pathlib import Path
16
+ from urllib.parse import unquote, urlparse
17
+
18
+ from .sessions import Exit, Session, Status, _shorten
19
+
20
+ INDEX = "conversation_summaries.db"
21
+
22
+
23
+ def home() -> Path:
24
+ return Path.home() / ".gemini" / "antigravity-cli"
25
+
26
+
27
+ def _when(value) -> datetime | None:
28
+ """'2026-10-16 11:57:27.4323262+00:00' -> local time; Go writes 7 digits of fraction."""
29
+ if not isinstance(value, str) or not value:
30
+ return None
31
+ text = value.strip().replace("T", " ")
32
+ zone = ""
33
+ for sign in ("+", "-"):
34
+ cut = text.rfind(sign)
35
+ if cut > 10:
36
+ text, zone = text[:cut], text[cut:]
37
+ break
38
+ if "." in text:
39
+ whole, fraction = text.split(".", 1)
40
+ text = f"{whole}.{fraction[:6]}"
41
+ try:
42
+ moment = datetime.fromisoformat(text + zone)
43
+ except ValueError:
44
+ return None
45
+ if moment.tzinfo is None:
46
+ moment = moment.replace(tzinfo=timezone.utc)
47
+ if moment.year < 1980:
48
+ return None # Go's zero time: never set
49
+ try:
50
+ return moment.astimezone().replace(tzinfo=None)
51
+ except (OverflowError, OSError, ValueError):
52
+ return None
53
+
54
+
55
+ def _folder(uris) -> str:
56
+ try:
57
+ first = (json.loads(uris) if isinstance(uris, str) and uris else [None])[0]
58
+ except (ValueError, IndexError, TypeError):
59
+ return ""
60
+ if not isinstance(first, str) or not first.startswith("file:"):
61
+ return ""
62
+ path = unquote(urlparse(first).path)
63
+ if len(path) > 2 and path[0] == "/" and path[2] == ":": # /C:/Users -> C:/Users
64
+ path = path[1:]
65
+ return str(Path(path))
66
+
67
+
68
+ def _rows() -> list[dict]:
69
+ path = home() / INDEX
70
+ if not path.exists():
71
+ return []
72
+ try:
73
+ con = sqlite3.connect(f"file:{path.as_posix()}?mode=ro", uri=True, timeout=2)
74
+ except sqlite3.Error:
75
+ return []
76
+ try:
77
+ con.row_factory = sqlite3.Row
78
+ rows = con.execute("SELECT * FROM conversation_summaries").fetchall()
79
+ return [dict(row) for row in rows]
80
+ except sqlite3.Error:
81
+ return []
82
+ finally:
83
+ con.close()
84
+
85
+
86
+ def find(days: float = 14) -> list[Session]:
87
+ since = datetime.now() - timedelta(days=days)
88
+ sessions = []
89
+ for row in _rows():
90
+ sid = str(row.get("conversation_id") or "")
91
+ last = _when(row.get("last_modified_time"))
92
+ if not sid or last is None or last < since or row.get("nesting_depth"):
93
+ continue # nested ones are subagents of another conversation
94
+ title = str(row.get("title") or row.get("preview") or "").strip()
95
+ preview = str(row.get("preview") or "").strip()
96
+ status = str(row.get("status") or "")
97
+ session = Session(session_id=sid, path=home() / "conversations" / f"{sid}.db", cli="agy",
98
+ cwd=_folder(row.get("workspace_uris")), title=_shorten(title or "(untitled)", 80),
99
+ first_prompt=_shorten(preview or title, 300), entrypoint="cli",
100
+ last_active=last,
101
+ exit=Exit.UNKNOWN, size=int(row.get("step_count") or 0))
102
+ if row.get("killed") or row.get("not_fully_idle"):
103
+ session.status = Status.CUT_OFF # stopped while it was still working
104
+ elif status.endswith("IDLE"):
105
+ session.status = Status.DONE
106
+ sessions.append(session)
107
+ return sessions