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.
- tktop/__init__.py +6 -0
- tktop/adapter/__init__.py +0 -0
- tktop/adapter/claude.py +177 -0
- tktop/adapter/codex.py +265 -0
- tktop/adapter/factory.py +23 -0
- tktop/adapter/protocol.py +15 -0
- tktop/cli.py +33 -0
- tktop/config.py +237 -0
- tktop/llm/__init__.py +0 -0
- tktop/llm/anthropic_provider.py +34 -0
- tktop/llm/factory.py +24 -0
- tktop/llm/ollama.py +37 -0
- tktop/llm/openai_provider.py +44 -0
- tktop/llm/prompt.py +108 -0
- tktop/llm/protocol.py +10 -0
- tktop/llm/vertex.py +59 -0
- tktop/metrics/__init__.py +0 -0
- tktop/metrics/aggregator.py +67 -0
- tktop/metrics/drift.py +259 -0
- tktop/metrics/pricing.py +48 -0
- tktop/metrics/types.py +95 -0
- tktop/release.py +145 -0
- tktop/tui/__init__.py +0 -0
- tktop/tui/app.py +38 -0
- tktop/tui/screens/__init__.py +0 -0
- tktop/tui/screens/analysis.py +126 -0
- tktop/tui/screens/dashboard.py +189 -0
- tktop/tui/screens/help.py +82 -0
- tktop/tui/screens/history.py +173 -0
- tktop/tui/screens/overview.py +138 -0
- tktop/tui/screens/provider_picker.py +59 -0
- tktop/tui/screens/turn_detail.py +71 -0
- tktop/tui/styles.tcss +87 -0
- tktop/tui/widgets/__init__.py +0 -0
- tktop/tui/widgets/alert_panel.py +32 -0
- tktop/tui/widgets/cost_graph.py +120 -0
- tktop/tui/widgets/session_card.py +32 -0
- tktop/tui/widgets/token_bars.py +60 -0
- tktop/tui/widgets/token_graph.py +10 -0
- tktop/tui/widgets/tool_table.py +33 -0
- tktop-0.1.0.dist-info/METADATA +150 -0
- tktop-0.1.0.dist-info/RECORD +45 -0
- tktop-0.1.0.dist-info/WHEEL +4 -0
- tktop-0.1.0.dist-info/entry_points.txt +2 -0
- tktop-0.1.0.dist-info/licenses/LICENSE +21 -0
tktop/__init__.py
ADDED
|
File without changes
|
tktop/adapter/claude.py
ADDED
|
@@ -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)
|
tktop/adapter/factory.py
ADDED
|
@@ -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))
|