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.
- tabmancer/__init__.py +3 -0
- tabmancer/__main__.py +3 -0
- tabmancer/accounts.py +119 -0
- tabmancer/agy.py +107 -0
- tabmancer/app.py +2875 -0
- tabmancer/background.py +300 -0
- tabmancer/bitart.py +816 -0
- tabmancer/bitmoves.py +442 -0
- tabmancer/blocks.py +115 -0
- tabmancer/cleanup.py +311 -0
- tabmancer/cli.py +431 -0
- tabmancer/clis.py +65 -0
- tabmancer/clisettings.py +75 -0
- tabmancer/codex.py +196 -0
- tabmancer/config.py +50 -0
- tabmancer/context.py +45 -0
- tabmancer/demo.py +391 -0
- tabmancer/dialogs.py +36 -0
- tabmancer/doctor.py +252 -0
- tabmancer/focus.py +195 -0
- tabmancer/gitstate.py +86 -0
- tabmancer/handover.py +168 -0
- tabmancer/handoverview.py +84 -0
- tabmancer/keysview.py +134 -0
- tabmancer/launch.py +309 -0
- tabmancer/marks.py +51 -0
- tabmancer/mascot.py +836 -0
- tabmancer/newsessions.py +265 -0
- tabmancer/rename.py +38 -0
- tabmancer/report.py +154 -0
- tabmancer/safework.py +64 -0
- tabmancer/search.py +137 -0
- tabmancer/sessions.py +686 -0
- tabmancer/shortcuts.py +161 -0
- tabmancer/shutdowns.py +164 -0
- tabmancer/sixel.py +481 -0
- tabmancer/stats.py +166 -0
- tabmancer/statsview.py +490 -0
- tabmancer/summarize.py +139 -0
- tabmancer/teams.py +538 -0
- tabmancer/teamtree.py +271 -0
- tabmancer/teamview.py +1002 -0
- tabmancer/theme.py +105 -0
- tabmancer/toast.py +89 -0
- tabmancer/tokens.py +498 -0
- tabmancer/typein.py +140 -0
- tabmancer/usage.py +294 -0
- tabmancer/viewmodel.py +406 -0
- tabmancer/widgets.py +453 -0
- tabmancer/workspaces.py +116 -0
- tabmancer-0.1.0.dist-info/METADATA +147 -0
- tabmancer-0.1.0.dist-info/RECORD +57 -0
- tabmancer-0.1.0.dist-info/WHEEL +4 -0
- tabmancer-0.1.0.dist-info/entry_points.txt +5 -0
- tabmancer-0.1.0.dist-info/licenses/LICENSE +202 -0
- tabmancer-0.1.0.dist-info/licenses/LICENSE-BIT +415 -0
- tabmancer-0.1.0.dist-info/licenses/NOTICE +11 -0
tabmancer/__init__.py
ADDED
tabmancer/__main__.py
ADDED
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
|