tktop 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 (45) hide show
  1. tktop/__init__.py +6 -0
  2. tktop/adapter/__init__.py +0 -0
  3. tktop/adapter/claude.py +177 -0
  4. tktop/adapter/codex.py +265 -0
  5. tktop/adapter/factory.py +23 -0
  6. tktop/adapter/protocol.py +15 -0
  7. tktop/cli.py +33 -0
  8. tktop/config.py +237 -0
  9. tktop/llm/__init__.py +0 -0
  10. tktop/llm/anthropic_provider.py +34 -0
  11. tktop/llm/factory.py +24 -0
  12. tktop/llm/ollama.py +37 -0
  13. tktop/llm/openai_provider.py +44 -0
  14. tktop/llm/prompt.py +108 -0
  15. tktop/llm/protocol.py +10 -0
  16. tktop/llm/vertex.py +59 -0
  17. tktop/metrics/__init__.py +0 -0
  18. tktop/metrics/aggregator.py +67 -0
  19. tktop/metrics/drift.py +259 -0
  20. tktop/metrics/pricing.py +48 -0
  21. tktop/metrics/types.py +95 -0
  22. tktop/release.py +145 -0
  23. tktop/tui/__init__.py +0 -0
  24. tktop/tui/app.py +38 -0
  25. tktop/tui/screens/__init__.py +0 -0
  26. tktop/tui/screens/analysis.py +126 -0
  27. tktop/tui/screens/dashboard.py +189 -0
  28. tktop/tui/screens/help.py +82 -0
  29. tktop/tui/screens/history.py +173 -0
  30. tktop/tui/screens/overview.py +138 -0
  31. tktop/tui/screens/provider_picker.py +59 -0
  32. tktop/tui/screens/turn_detail.py +71 -0
  33. tktop/tui/styles.tcss +87 -0
  34. tktop/tui/widgets/__init__.py +0 -0
  35. tktop/tui/widgets/alert_panel.py +32 -0
  36. tktop/tui/widgets/cost_graph.py +120 -0
  37. tktop/tui/widgets/session_card.py +32 -0
  38. tktop/tui/widgets/token_bars.py +60 -0
  39. tktop/tui/widgets/token_graph.py +10 -0
  40. tktop/tui/widgets/tool_table.py +33 -0
  41. tktop-0.1.0.dist-info/METADATA +150 -0
  42. tktop-0.1.0.dist-info/RECORD +45 -0
  43. tktop-0.1.0.dist-info/WHEEL +4 -0
  44. tktop-0.1.0.dist-info/entry_points.txt +2 -0
  45. tktop-0.1.0.dist-info/licenses/LICENSE +21 -0
tktop/__init__.py ADDED
@@ -0,0 +1,6 @@
1
+ from importlib.metadata import PackageNotFoundError, version
2
+
3
+ try:
4
+ __version__ = version("tktop")
5
+ except PackageNotFoundError:
6
+ __version__ = "0.1.0"
File without changes
@@ -0,0 +1,177 @@
1
+ import json
2
+ from collections.abc import AsyncIterator
3
+ from datetime import UTC, datetime
4
+ from pathlib import Path
5
+
6
+ from tktop.metrics.types import (
7
+ SessionInfo,
8
+ TokenUsage,
9
+ ToolCall,
10
+ Turn,
11
+ )
12
+
13
+
14
+ class ClaudeCodeAdapter:
15
+ name = "claude-code"
16
+
17
+ def __init__(self, base_dir: str) -> None:
18
+ self.base_dir = Path(base_dir)
19
+
20
+ @classmethod
21
+ def is_available(cls, base_dir: str) -> bool:
22
+ return (Path(base_dir) / "sessions").exists()
23
+
24
+ async def discover(self) -> list[SessionInfo]:
25
+ sessions_dir = self.base_dir / "sessions"
26
+ if not sessions_dir.exists():
27
+ return []
28
+
29
+ sessions: list[SessionInfo] = []
30
+ for path in sessions_dir.glob("*.json"):
31
+ try:
32
+ data = json.loads(path.read_text())
33
+ except (json.JSONDecodeError, OSError):
34
+ continue
35
+
36
+ sessions.append(
37
+ SessionInfo(
38
+ id=data["sessionId"],
39
+ pid=data.get("pid", 0),
40
+ agent_type="claude-code",
41
+ project_path=data.get("cwd", ""),
42
+ model="",
43
+ status=data.get("status", "unknown"),
44
+ started_at=datetime.fromtimestamp(
45
+ data["startedAt"] / 1000, tz=UTC
46
+ ),
47
+ updated_at=datetime.fromtimestamp(
48
+ data.get("updatedAt", data["startedAt"]) / 1000,
49
+ tz=UTC,
50
+ ),
51
+ version=data.get("version", ""),
52
+ )
53
+ )
54
+
55
+ for session in sessions:
56
+ session.title = self._read_title(session.id)
57
+
58
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
59
+ return sessions
60
+
61
+ async def parse_transcript(self, session_id: str) -> list[Turn]:
62
+ transcript_path = self._find_transcript(session_id)
63
+ if transcript_path is None:
64
+ return []
65
+
66
+ turns: list[Turn] = []
67
+ turn_number = 0
68
+
69
+ for line in transcript_path.read_text().splitlines():
70
+ line = line.strip()
71
+ if not line:
72
+ continue
73
+
74
+ try:
75
+ entry = json.loads(line)
76
+ except json.JSONDecodeError:
77
+ continue
78
+
79
+ entry_type = entry.get("type")
80
+ if entry_type not in ("assistant", "user"):
81
+ continue
82
+
83
+ msg = entry.get("message", {})
84
+ if not isinstance(msg, dict):
85
+ continue
86
+
87
+ turn_number += 1
88
+ usage_data = msg.get("usage") or {}
89
+ content_blocks = msg.get("content", [])
90
+ if isinstance(content_blocks, str):
91
+ content_blocks = [{"type": "text", "text": content_blocks}]
92
+
93
+ tool_calls: list[ToolCall] = []
94
+ text_parts: list[str] = []
95
+
96
+ for block in content_blocks:
97
+ if not isinstance(block, dict):
98
+ continue
99
+ if block.get("type") == "tool_use":
100
+ tool_calls.append(
101
+ ToolCall(name=block.get("name", ""), id=block.get("id", ""))
102
+ )
103
+ elif block.get("type") == "text":
104
+ text_parts.append(block.get("text", ""))
105
+
106
+ preview = " ".join(text_parts)[:200]
107
+ timestamp_str = entry.get("timestamp", "")
108
+ try:
109
+ timestamp = datetime.fromisoformat(
110
+ timestamp_str.replace("Z", "+00:00")
111
+ )
112
+ except (ValueError, AttributeError):
113
+ timestamp = datetime.now(tz=UTC)
114
+
115
+ turns.append(
116
+ Turn(
117
+ number=turn_number,
118
+ timestamp=timestamp,
119
+ role=entry_type,
120
+ model=msg.get("model"),
121
+ usage=TokenUsage(
122
+ input_tokens=usage_data.get("input_tokens", 0),
123
+ output_tokens=usage_data.get("output_tokens", 0),
124
+ cache_creation_tokens=usage_data.get(
125
+ "cache_creation_input_tokens", 0
126
+ ),
127
+ cache_read_tokens=usage_data.get(
128
+ "cache_read_input_tokens", 0
129
+ ),
130
+ ),
131
+ tool_calls=tool_calls,
132
+ content_preview=preview,
133
+ )
134
+ )
135
+
136
+ return turns
137
+
138
+ def _find_transcript(self, session_id: str) -> Path | None:
139
+ projects_dir = self.base_dir / "projects"
140
+ if not projects_dir.exists():
141
+ return None
142
+
143
+ filename = f"{session_id}.jsonl"
144
+ for project_dir in projects_dir.iterdir():
145
+ if not project_dir.is_dir():
146
+ continue
147
+ candidate = project_dir / filename
148
+ if candidate.exists():
149
+ return candidate
150
+
151
+ return None
152
+
153
+ def _read_title(self, session_id: str) -> str:
154
+ transcript_path = self._find_transcript(session_id)
155
+ if transcript_path is None:
156
+ return ""
157
+
158
+ try:
159
+ with open(transcript_path) as f:
160
+ for line in f:
161
+ line = line.strip()
162
+ if not line:
163
+ continue
164
+ try:
165
+ entry = json.loads(line)
166
+ except json.JSONDecodeError:
167
+ continue
168
+ if entry.get("type") == "ai-title":
169
+ return entry.get("aiTitle", "")
170
+ except OSError:
171
+ pass
172
+
173
+ return ""
174
+
175
+ async def watch(self, session_id: str) -> AsyncIterator[Turn]:
176
+ raise NotImplementedError
177
+ yield # type: ignore[misc]
tktop/adapter/codex.py ADDED
@@ -0,0 +1,265 @@
1
+ import json
2
+ import tomllib
3
+ from collections.abc import AsyncIterator
4
+ from datetime import UTC, datetime
5
+ from pathlib import Path
6
+
7
+ from tktop.metrics.types import SessionInfo, TokenUsage, ToolCall, Turn
8
+
9
+
10
+ class CodexAdapter:
11
+ name = "codex"
12
+
13
+ def __init__(self, base_dir: str) -> None:
14
+ self.base_dir = Path(base_dir)
15
+ self.default_model = self._read_default_model()
16
+ self._session_index = self._read_session_index()
17
+
18
+ @classmethod
19
+ def is_available(cls, base_dir: str) -> bool:
20
+ root = Path(base_dir)
21
+ return root.exists() and (root / "sessions").exists()
22
+
23
+ async def discover(self) -> list[SessionInfo]:
24
+ sessions_dir = self.base_dir / "sessions"
25
+ if not sessions_dir.exists():
26
+ return []
27
+
28
+ sessions: list[SessionInfo] = []
29
+ seen: set[str] = set()
30
+
31
+ for path in sessions_dir.rglob("*.jsonl"):
32
+ meta = self._read_session_meta(path)
33
+ if meta is None:
34
+ continue
35
+
36
+ session_id = str(meta.get("id", "")).strip()
37
+ if not session_id or session_id in seen:
38
+ continue
39
+ seen.add(session_id)
40
+
41
+ index_entry = self._session_index.get(session_id, {})
42
+ started_at = self._parse_timestamp(meta.get("timestamp"))
43
+ if started_at is None:
44
+ started_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
45
+
46
+ updated_at = self._parse_timestamp(index_entry.get("updated_at"))
47
+ if updated_at is None:
48
+ updated_at = datetime.fromtimestamp(path.stat().st_mtime, tz=UTC)
49
+
50
+ sessions.append(
51
+ SessionInfo(
52
+ id=session_id,
53
+ pid=0,
54
+ agent_type="codex",
55
+ project_path=str(meta.get("cwd", "")),
56
+ model=self.default_model,
57
+ status="idle",
58
+ started_at=started_at,
59
+ updated_at=updated_at,
60
+ version=str(meta.get("cli_version", "")),
61
+ title=str(index_entry.get("thread_name", "")),
62
+ )
63
+ )
64
+
65
+ sessions.sort(key=lambda s: s.updated_at, reverse=True)
66
+ return sessions
67
+
68
+ async def parse_transcript(self, session_id: str) -> list[Turn]:
69
+ transcript_path = self._find_transcript(session_id)
70
+ if transcript_path is None:
71
+ return []
72
+
73
+ turns: list[Turn] = []
74
+ current_turn: Turn | None = None
75
+ last_assistant_turn: Turn | None = None
76
+ turn_number = 0
77
+
78
+ for raw_line in transcript_path.read_text().splitlines():
79
+ line = raw_line.strip()
80
+ if not line:
81
+ continue
82
+
83
+ try:
84
+ entry = json.loads(line)
85
+ except json.JSONDecodeError:
86
+ continue
87
+
88
+ payload = entry.get("payload", {})
89
+ if not isinstance(payload, dict):
90
+ continue
91
+
92
+ entry_type = entry.get("type")
93
+ payload_type = payload.get("type")
94
+
95
+ if entry_type == "event_msg" and payload_type == "token_count":
96
+ if last_assistant_turn is not None:
97
+ info = payload.get("info", {})
98
+ if isinstance(info, dict):
99
+ last = info.get("last_token_usage", {})
100
+ if isinstance(last, dict):
101
+ last_assistant_turn.usage = TokenUsage(
102
+ input_tokens=int(last.get("input_tokens", 0) or 0),
103
+ output_tokens=int(last.get("output_tokens", 0) or 0),
104
+ cache_creation_tokens=0,
105
+ cache_read_tokens=int(
106
+ last.get("cached_input_tokens", 0) or 0
107
+ ),
108
+ )
109
+ continue
110
+
111
+ if entry_type != "response_item":
112
+ continue
113
+
114
+ if payload_type == "message":
115
+ role = payload.get("role")
116
+ if role == "developer":
117
+ continue
118
+ if role not in ("user", "assistant"):
119
+ continue
120
+
121
+ if current_turn is not None:
122
+ turns.append(current_turn)
123
+
124
+ turn_number += 1
125
+ timestamp = self._parse_timestamp(
126
+ entry.get("timestamp") or payload.get("timestamp")
127
+ )
128
+ current_turn = Turn(
129
+ number=turn_number,
130
+ timestamp=timestamp,
131
+ role=role,
132
+ model=self.default_model if role == "assistant" else None,
133
+ usage=TokenUsage(),
134
+ tool_calls=[],
135
+ content_preview=self._extract_preview(payload),
136
+ )
137
+ if role == "assistant":
138
+ last_assistant_turn = current_turn
139
+ continue
140
+
141
+ if current_turn is None or current_turn.role != "assistant":
142
+ continue
143
+
144
+ if payload_type in ("function_call", "custom_tool_call"):
145
+ call_name = str(payload.get("name", ""))
146
+ call_id = str(
147
+ payload.get("call_id")
148
+ or payload.get("id")
149
+ or f"{current_turn.number}-{len(current_turn.tool_calls) + 1}"
150
+ )
151
+ current_turn.tool_calls.append(ToolCall(name=call_name, id=call_id))
152
+ continue
153
+
154
+ if payload_type == "function_call_output":
155
+ continue
156
+
157
+ if current_turn is not None:
158
+ turns.append(current_turn)
159
+
160
+ for turn in reversed(turns):
161
+ if turn.role == "assistant" and not turn.model:
162
+ turn.model = self.default_model or None
163
+ break
164
+
165
+ return turns
166
+
167
+ async def watch(self, session_id: str) -> AsyncIterator[Turn]:
168
+ raise NotImplementedError
169
+ yield # type: ignore[misc]
170
+
171
+ def _find_transcript(self, session_id: str) -> Path | None:
172
+ sessions_dir = self.base_dir / "sessions"
173
+ if not sessions_dir.exists():
174
+ return None
175
+
176
+ for path in sessions_dir.rglob(f"*{session_id}.jsonl"):
177
+ if path.is_file():
178
+ return path
179
+ return None
180
+
181
+ def _read_session_index(self) -> dict[str, dict[str, str]]:
182
+ index_path = self.base_dir / "session_index.jsonl"
183
+ if not index_path.exists():
184
+ return {}
185
+
186
+ index: dict[str, dict[str, str]] = {}
187
+ try:
188
+ for raw_line in index_path.read_text().splitlines():
189
+ line = raw_line.strip()
190
+ if not line:
191
+ continue
192
+ try:
193
+ entry = json.loads(line)
194
+ except json.JSONDecodeError:
195
+ continue
196
+ session_id = str(entry.get("id", "")).strip()
197
+ if not session_id:
198
+ continue
199
+ index[session_id] = {
200
+ "thread_name": str(entry.get("thread_name", "")),
201
+ "updated_at": str(entry.get("updated_at", "")),
202
+ }
203
+ except OSError:
204
+ return {}
205
+ return index
206
+
207
+ def _read_session_meta(self, path: Path) -> dict | None:
208
+ try:
209
+ with path.open() as f:
210
+ for raw_line in f:
211
+ line = raw_line.strip()
212
+ if not line:
213
+ continue
214
+ try:
215
+ entry = json.loads(line)
216
+ except json.JSONDecodeError:
217
+ continue
218
+ if entry.get("type") == "session_meta":
219
+ payload = entry.get("payload")
220
+ if isinstance(payload, dict):
221
+ return payload
222
+ return None
223
+ except OSError:
224
+ return None
225
+ return None
226
+
227
+ def _read_default_model(self) -> str:
228
+ config_path = self.base_dir / "config.toml"
229
+ if not config_path.exists():
230
+ return ""
231
+
232
+ try:
233
+ with config_path.open("rb") as f:
234
+ data = tomllib.load(f)
235
+ except (OSError, tomllib.TOMLDecodeError):
236
+ return ""
237
+
238
+ model = data.get("model")
239
+ return model if isinstance(model, str) else ""
240
+
241
+ @staticmethod
242
+ def _extract_preview(payload: dict) -> str:
243
+ parts: list[str] = []
244
+ for block in payload.get("content", []):
245
+ if not isinstance(block, dict):
246
+ continue
247
+ block_type = block.get("type")
248
+ if block_type == "input_text":
249
+ text = block.get("text", "")
250
+ elif block_type == "output_text":
251
+ text = block.get("text", "")
252
+ else:
253
+ continue
254
+ if text:
255
+ parts.append(str(text))
256
+ return " ".join(parts)[:200]
257
+
258
+ @staticmethod
259
+ def _parse_timestamp(value: object) -> datetime:
260
+ if isinstance(value, str):
261
+ try:
262
+ return datetime.fromisoformat(value.replace("Z", "+00:00"))
263
+ except ValueError:
264
+ pass
265
+ return datetime.now(tz=UTC)
@@ -0,0 +1,23 @@
1
+ from tktop.config import Config
2
+
3
+ from .claude import ClaudeCodeAdapter
4
+ from .codex import CodexAdapter
5
+ from .protocol import SessionAdapter
6
+
7
+
8
+ def create_adapter(cfg: Config) -> SessionAdapter:
9
+ choice = (cfg.session_adapter or "auto").strip().lower()
10
+
11
+ if choice == "claude":
12
+ return ClaudeCodeAdapter(cfg.claude_dir)
13
+ if choice == "codex":
14
+ return CodexAdapter(cfg.codex_dir)
15
+ if choice != "auto":
16
+ raise ValueError(f"unknown session adapter: {cfg.session_adapter}")
17
+
18
+ if ClaudeCodeAdapter.is_available(cfg.claude_dir):
19
+ return ClaudeCodeAdapter(cfg.claude_dir)
20
+ if CodexAdapter.is_available(cfg.codex_dir):
21
+ return CodexAdapter(cfg.codex_dir)
22
+
23
+ return ClaudeCodeAdapter(cfg.claude_dir)
@@ -0,0 +1,15 @@
1
+ from collections.abc import AsyncIterator
2
+ from typing import Protocol, runtime_checkable
3
+
4
+ from tktop.metrics.types import SessionInfo, Turn
5
+
6
+
7
+ @runtime_checkable
8
+ class SessionAdapter(Protocol):
9
+ name: str
10
+
11
+ async def discover(self) -> list[SessionInfo]: ...
12
+
13
+ async def parse_transcript(self, session_id: str) -> list[Turn]: ...
14
+
15
+ async def watch(self, session_id: str) -> AsyncIterator[Turn]: ...
tktop/cli.py ADDED
@@ -0,0 +1,33 @@
1
+ import json
2
+
3
+ import typer
4
+
5
+ from tktop.config import SETTINGS_FILE, get_resolved_config_as_dict, load_config
6
+ from tktop.tui.app import TktopApp
7
+
8
+ app = typer.Typer(help="tktop — token monitor for coding agents", invoke_without_command=True)
9
+ config_app = typer.Typer(help="Manage tktop configuration")
10
+ app.add_typer(config_app, name="config")
11
+
12
+
13
+ @app.callback(invoke_without_command=True)
14
+ def main(ctx: typer.Context) -> None:
15
+ """Launch the interactive token monitor."""
16
+ if ctx.invoked_subcommand is None:
17
+ config = load_config()
18
+ tktop = TktopApp(config=config)
19
+ tktop.run()
20
+
21
+
22
+ @config_app.command("show")
23
+ def config_show() -> None:
24
+ """Print resolved configuration as JSON."""
25
+ cfg = load_config()
26
+ result = get_resolved_config_as_dict(cfg)
27
+ typer.echo(json.dumps(result, indent=2))
28
+
29
+
30
+ @config_app.command("path")
31
+ def config_path() -> None:
32
+ """Print the settings file path."""
33
+ typer.echo(str(SETTINGS_FILE))