tktop-tui 0.3.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_tui/__init__.py ADDED
@@ -0,0 +1,72 @@
1
+ """tktop-tui: Python launcher for the tktop TUI.
2
+
3
+ `pip install tktop-tui` gives you the `tktop` command. The wheel bundles the
4
+ TypeScript TUI (src/ + bin/); on first run it is staged into a user cache dir
5
+ (~/.local/share/tktop/<version>) where `bun install` fetches its JavaScript
6
+ dependencies, then the TUI is exec'd under Bun. Read-only site-packages are
7
+ never touched.
8
+ """
9
+
10
+ import json
11
+ import os
12
+ import shutil
13
+ import subprocess
14
+ import sys
15
+ from importlib.resources import as_file, files
16
+ from pathlib import Path
17
+
18
+ VERSION = "0.3.0"
19
+ HOMEPAGE = "https://github.com/AlexKay28/tktop"
20
+
21
+
22
+ def find_bun() -> str | None:
23
+ """Locate bun on PATH or in its default install location."""
24
+ found = shutil.which("bun")
25
+ if found:
26
+ return found
27
+ default = Path.home() / ".bun" / "bin" / "bun"
28
+ return str(default) if default.is_file() and os.access(default, os.X_OK) else None
29
+
30
+
31
+ def stage_tui() -> Path:
32
+ """Copy the bundled TUI into a per-version cache dir (once per version)."""
33
+ base = Path.home() / ".local" / "share" / "tktop" / VERSION
34
+ marker = base / ".installed"
35
+ if marker.is_file():
36
+ return base
37
+ if base.exists():
38
+ shutil.rmtree(base)
39
+ base.parent.mkdir(parents=True, exist_ok=True)
40
+ src = files("tktop_tui").joinpath("tui")
41
+ with as_file(src) as src_path:
42
+ shutil.copytree(src_path, base)
43
+ marker.write_text("staged\n")
44
+ return base
45
+
46
+
47
+ def main() -> None:
48
+ bun = find_bun()
49
+ if not bun:
50
+ print("tktop: bun is required but not installed.", file=sys.stderr)
51
+ print(" install it with: curl -fsSL https://bun.sh/install | bash", file=sys.stderr)
52
+ print(f" project: {HOMEPAGE}", file=sys.stderr)
53
+ raise SystemExit(1)
54
+
55
+ tui = stage_tui()
56
+
57
+ # fetch JS dependencies once per staged version (needs network on first run)
58
+ if not (tui / "node_modules").is_dir():
59
+ pkg = json.loads((tui / "package.json").read_text())
60
+ pkg.pop("devDependencies", None) # @types/bun is not needed at runtime
61
+ pkg.get("scripts", {}).pop("prepublishOnly", None)
62
+ (tui / "package.json").write_text(json.dumps(pkg))
63
+ r = subprocess.run([bun, "install", "--production"], cwd=tui)
64
+ if r.returncode != 0:
65
+ print("tktop: bun install failed (network reachable?).", file=sys.stderr)
66
+ raise SystemExit(1)
67
+
68
+ os.execv(bun, [bun, "run", str(tui / "src" / "index.ts"), *sys.argv[1:]])
69
+
70
+
71
+ if __name__ == "__main__":
72
+ main()
tktop_tui/tui/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 AlexKay28
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.
@@ -0,0 +1,16 @@
1
+ #!/usr/bin/env bash
2
+ # tktop launcher — runs the TUI via Bun.
3
+ # Resolves the repo root from this script's own location, so the shim works
4
+ # from ~/.local/bin (symlinked anywhere) and survives repo moves.
5
+ set -eu
6
+
7
+ REPO_ROOT="$(cd "$(dirname "$(readlink -f "$0")")/.." && pwd)"
8
+ export PATH="$HOME/.bun/bin:$PATH"
9
+
10
+ if ! command -v bun >/dev/null 2>&1; then
11
+ echo "tktop: bun is required but not installed." >&2
12
+ echo " install it with: curl -fsSL https://bun.sh/install | bash" >&2
13
+ exit 1
14
+ fi
15
+
16
+ exec bun run "$REPO_ROOT/src/index.ts" "$@"
@@ -0,0 +1,47 @@
1
+ {
2
+ "name": "tktop",
3
+ "version": "0.3.0",
4
+ "type": "module",
5
+ "description": "Live htop-style TUI dashboard for AI token consumption across harnesses (OpenCode, Claude Code, Codex, generic JSONL)",
6
+ "keywords": [
7
+ "cli",
8
+ "tui",
9
+ "tokens",
10
+ "llm",
11
+ "ai",
12
+ "monitoring",
13
+ "opentui",
14
+ "htop"
15
+ ],
16
+ "license": "MIT",
17
+ "repository": {
18
+ "type": "git",
19
+ "url": "git+ssh://git@github.com/AlexKay28/tktop.git"
20
+ },
21
+ "bin": {
22
+ "tktop": "bin/tktop"
23
+ },
24
+ "files": [
25
+ "bin/",
26
+ "src/",
27
+ "docs/",
28
+ "README.md",
29
+ "LICENSE"
30
+ ],
31
+ "engines": {
32
+ "bun": ">=1.1.0"
33
+ },
34
+ "scripts": {
35
+ "start": "bun run src/index.ts",
36
+ "tktop": "bun run src/index.ts",
37
+ "dump": "bun run src/dump.ts",
38
+ "typecheck": "bun check",
39
+ "prepublishOnly": "bun run typecheck"
40
+ },
41
+ "dependencies": {
42
+ "@opentui/core": "^0.8.1"
43
+ },
44
+ "devDependencies": {
45
+ "@types/bun": "latest"
46
+ }
47
+ }
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Claude Code adapter — additive parsing of JSONL files under ~/.claude/projects.
3
+ * Auto-activates when ~/.claude exists. See docs/ADR-003 for caveats.
4
+ */
5
+ import { existsSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import type { Adapter, SessionInfo, TokenCounts } from "../types"
8
+ import { JsonlTail } from "./jsonl-tail"
9
+
10
+ interface Acc {
11
+ id: string
12
+ title: string
13
+ directory: string
14
+ model: string
15
+ tokens: TokenCounts
16
+ cost: number
17
+ timeCreated: number
18
+ timeUpdated: number
19
+ }
20
+
21
+ function newAcc(id: string): Acc {
22
+ return {
23
+ id,
24
+ title: "",
25
+ directory: "",
26
+ model: "-",
27
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
28
+ cost: 0,
29
+ timeCreated: 0,
30
+ timeUpdated: 0,
31
+ }
32
+ }
33
+
34
+ export class ClaudeCodeAdapter implements Adapter {
35
+ readonly harness = "claude-code"
36
+ lastError: string | null = null
37
+
38
+ private root: string
39
+ private tail: JsonlTail
40
+ private accs = new Map<string, Acc>()
41
+ private started = false
42
+
43
+ constructor(root?: string) {
44
+ this.root = root ?? `${homedir()}/.claude/projects`
45
+ this.tail = new JsonlTail(`${this.root}/**/*.jsonl`)
46
+ }
47
+
48
+ available(): boolean {
49
+ return existsSync(this.root)
50
+ }
51
+
52
+ private sid(obj: any, path: string): string {
53
+ if (obj.sessionId) return String(obj.sessionId)
54
+ const m = path.match(/([0-9a-f-]{36})(?:\.jsonl)?$/i)
55
+ if (m?.[1]) return m[1]
56
+ return path
57
+ }
58
+
59
+ poll(): SessionInfo[] {
60
+ try {
61
+ if (!this.started) {
62
+ this.tail.refreshFiles()
63
+ this.started = true
64
+ } else if (this.accs.size === 0 || Math.random() < 0.05) {
65
+ // occasionally re-scan for new project files
66
+ this.tail.refreshFiles()
67
+ }
68
+
69
+ this.tail.read((obj, path) => this.onLine(obj, path))
70
+
71
+ const out: SessionInfo[] = []
72
+ for (const [sid, a] of this.accs) {
73
+ out.push({
74
+ key: `${this.harness}:${sid}`,
75
+ harness: this.harness,
76
+ id: sid,
77
+ title: a.title || (a.directory ? a.directory.split("/").filter(Boolean).pop() || sid.slice(0, 12) : sid.slice(0, 12)),
78
+ directory: a.directory,
79
+ model: a.model || "-",
80
+ archived: false,
81
+ tokens: a.tokens,
82
+ cost: a.cost,
83
+ timeCreated: a.timeCreated || a.timeUpdated,
84
+ timeUpdated: a.timeUpdated,
85
+ })
86
+ }
87
+ this.lastError = null
88
+ return out
89
+ } catch (e) {
90
+ this.lastError = (e as Error).message
91
+ return []
92
+ }
93
+ }
94
+
95
+ private onLine(obj: any, path: string): void {
96
+ if (!obj || typeof obj !== "object") return
97
+ const sid = this.sid(obj, path)
98
+ let acc = this.accs.get(sid)
99
+ if (!acc) {
100
+ acc = newAcc(sid)
101
+ this.accs.set(sid, acc)
102
+ }
103
+
104
+ const ts = obj.timestamp ? (typeof obj.timestamp === "number" ? obj.timestamp : Date.parse(obj.timestamp)) : 0
105
+ if (ts > acc.timeUpdated) acc.timeUpdated = ts
106
+ if (acc.timeCreated === 0 || (ts > 0 && ts < acc.timeCreated)) acc.timeCreated = ts
107
+ if (obj.cwd) acc.directory = String(obj.cwd)
108
+
109
+ if (obj.type === "summary" && obj.summary) {
110
+ if (!acc.title) acc.title = String(obj.summary)
111
+ return
112
+ }
113
+
114
+ const msg = obj.message
115
+ if (!msg || typeof msg !== "object") return
116
+
117
+ if (obj.type === "user" && !acc.title) {
118
+ const c = msg.content
119
+ const text =
120
+ typeof c === "string"
121
+ ? c
122
+ : Array.isArray(c)
123
+ ? (c.find((p: any) => p?.type === "text")?.text ?? "")
124
+ : ""
125
+ if (text) acc.title = text.replace(/\s+/g, " ").slice(0, 80)
126
+ return
127
+ }
128
+
129
+ if (obj.type === "assistant") {
130
+ if (msg.model) acc.model = String(msg.model)
131
+ const u = msg.usage
132
+ if (u && typeof u === "object") {
133
+ acc.tokens.input += u.input_tokens ?? 0
134
+ acc.tokens.output += u.output_tokens ?? 0
135
+ acc.tokens.cacheRead += u.cache_read_input_tokens ?? 0
136
+ acc.tokens.cacheWrite += u.cache_creation_input_tokens ?? 0
137
+ // some history formats fold reasoning into output; record if present
138
+ acc.tokens.reasoning += u.reasoning_tokens ?? 0
139
+ }
140
+ if (typeof obj.costUSD === "number") acc.cost += obj.costUSD
141
+ }
142
+ }
143
+ }
@@ -0,0 +1,131 @@
1
+ /**
2
+ * Codex CLI adapter — cumulative token_count events in
3
+ * ~/.codex/sessions JSONL files ("latest" semantics). See docs/ADR-003.
4
+ */
5
+ import { existsSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import type { Adapter, SessionInfo, TokenCounts } from "../types"
8
+ import { JsonlTail } from "./jsonl-tail"
9
+
10
+ interface Acc {
11
+ id: string
12
+ title: string
13
+ directory: string
14
+ model: string
15
+ tokens: TokenCounts
16
+ cost: number
17
+ timeCreated: number
18
+ timeUpdated: number
19
+ }
20
+
21
+ function newAcc(id: string): Acc {
22
+ return {
23
+ id,
24
+ title: "",
25
+ directory: "",
26
+ model: "-",
27
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
28
+ cost: 0,
29
+ timeCreated: 0,
30
+ timeUpdated: 0,
31
+ }
32
+ }
33
+
34
+ export class CodexAdapter implements Adapter {
35
+ readonly harness = "codex"
36
+ lastError: string | null = null
37
+
38
+ private root: string
39
+ private tail: JsonlTail
40
+ private accs = new Map<string, Acc>()
41
+ private started = false
42
+
43
+ constructor(root?: string) {
44
+ this.root = root ?? `${homedir()}/.codex/sessions`
45
+ this.tail = new JsonlTail(`${this.root}/**/*.jsonl`)
46
+ }
47
+
48
+ available(): boolean {
49
+ return existsSync(this.root)
50
+ }
51
+
52
+ poll(): SessionInfo[] {
53
+ try {
54
+ if (!this.started) {
55
+ this.tail.refreshFiles()
56
+ this.started = true
57
+ } else if (Math.random() < 0.05) {
58
+ this.tail.refreshFiles()
59
+ }
60
+ this.tail.read((obj, path) => this.onLine(obj, path))
61
+
62
+ const out: SessionInfo[] = []
63
+ for (const [sid, a] of this.accs) {
64
+ out.push({
65
+ key: `${this.harness}:${sid}`,
66
+ harness: this.harness,
67
+ id: sid,
68
+ title: a.title || sid.slice(0, 12),
69
+ directory: a.directory,
70
+ model: a.model || "-",
71
+ archived: false,
72
+ tokens: { ...a.tokens },
73
+ cost: a.cost,
74
+ timeCreated: a.timeCreated || a.timeUpdated,
75
+ timeUpdated: a.timeUpdated,
76
+ })
77
+ }
78
+ this.lastError = null
79
+ return out
80
+ } catch (e) {
81
+ this.lastError = (e as Error).message
82
+ return []
83
+ }
84
+ }
85
+
86
+ private onLine(obj: any, path: string): void {
87
+ if (!obj || typeof obj !== "object") return
88
+ const payload = obj.payload
89
+
90
+ // session id: prefer payload.session_meta.id, else file stem
91
+ let sid: string | null = null
92
+ if (payload?.type === "session_meta" && payload.id) sid = String(payload.id)
93
+ if (!sid) {
94
+ const m = path.match(/([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\.jsonl$/i)
95
+ if (m?.[1]) sid = m[1]
96
+ }
97
+ if (!sid) return
98
+
99
+ let acc = this.accs.get(sid)
100
+ if (!acc) {
101
+ acc = newAcc(sid)
102
+ this.accs.set(sid, acc)
103
+ }
104
+
105
+ const ts = obj.timestamp ? (typeof obj.timestamp === "number" ? obj.timestamp : Date.parse(obj.timestamp)) : 0
106
+ if (ts > acc.timeUpdated) acc.timeUpdated = ts
107
+ if (acc.timeCreated === 0 || (ts > 0 && ts < acc.timeCreated)) acc.timeCreated = ts
108
+
109
+ const p = payload ?? {}
110
+ if (p.cwd) acc.directory = String(p.cwd)
111
+ if (p.model) acc.model = String(p.model)
112
+
113
+ if (p.type === "token_count") {
114
+ const info = p.info ?? p
115
+ // cumulative totals: overwrite, never sum
116
+ const total = info.total_token_usage ?? info.totalTokenUsage ?? info.usage
117
+ if (total && typeof total === "object") {
118
+ const pick = (...names: string[]): number => {
119
+ for (const n of names) if (typeof total[n] === "number") return total[n]
120
+ return 0
121
+ }
122
+ acc.tokens.input = pick("input_tokens", "inputTokens", "input")
123
+ acc.tokens.cacheRead = pick("cached_input_tokens", "cachedInputTokens", "cache_read", "cached")
124
+ acc.tokens.output = pick("output_tokens", "outputTokens", "output")
125
+ acc.tokens.reasoning = pick("reasoning_output_tokens", "reasoning_tokens", "reasoningOutputTokens", "reasoning")
126
+ }
127
+ } else if (p.type === "turn_context" && p.model) {
128
+ acc.model = String(p.model)
129
+ }
130
+ }
131
+ }
@@ -0,0 +1,170 @@
1
+ /**
2
+ * Generic JSONL adapter — operator-declared sources via
3
+ * ~/.config/tktop/adapters.json. See docs/ADR-003 for the config schema.
4
+ */
5
+ import { existsSync, readFileSync } from "node:fs"
6
+ import { homedir } from "node:os"
7
+ import type { Adapter, SessionInfo, TokenCounts } from "../types"
8
+ import { JsonlTail } from "./jsonl-tail"
9
+
10
+ /** Resolve dotted path "a.b.c" against an object; undefined when missing. */
11
+ function dig(obj: any, path: string | undefined): any {
12
+ if (!path) return undefined
13
+ let cur = obj
14
+ for (const seg of path.split(".")) {
15
+ if (cur == null || typeof cur !== "object") return undefined
16
+ cur = cur[seg]
17
+ }
18
+ return cur
19
+ }
20
+
21
+ function toTime(v: any): number {
22
+ if (v == null) return 0
23
+ if (typeof v === "number") return v < 1e12 ? v * 1000 : v
24
+ const t = Date.parse(String(v))
25
+ return Number.isNaN(t) ? 0 : t
26
+ }
27
+
28
+ export interface GenericSourceConfig {
29
+ harness: string
30
+ glob: string
31
+ sessionId?: string
32
+ model?: string
33
+ title?: string
34
+ directory?: string
35
+ timeUpdated?: string
36
+ usage?: string
37
+ tokens?: Partial<Record<keyof TokenCounts, string>>
38
+ cost?: string
39
+ mode?: "additive" | "latest"
40
+ }
41
+
42
+ interface Acc {
43
+ id: string
44
+ model: string
45
+ title: string
46
+ directory: string
47
+ tokens: TokenCounts
48
+ cost: number
49
+ timeUpdated: number
50
+ }
51
+
52
+ export function loadGenericConfig(path?: string): GenericSourceConfig[] {
53
+ const p = path ?? `${homedir()}/.config/tktop/adapters.json`
54
+ if (!existsSync(p)) return []
55
+ try {
56
+ const raw = JSON.parse(readFileSync(p, "utf8"))
57
+ return Array.isArray(raw?.sources) ? (raw.sources as GenericSourceConfig[]) : []
58
+ } catch {
59
+ return []
60
+ }
61
+ }
62
+
63
+ export class GenericJsonlAdapter implements Adapter {
64
+ lastError: string | null = null
65
+
66
+ readonly harness: string
67
+ private tail: JsonlTail
68
+ private accs = new Map<string, Acc>()
69
+ private additive: boolean
70
+ private started = false
71
+
72
+ constructor(private cfg: GenericSourceConfig) {
73
+ this.harness = cfg.harness || "generic"
74
+ this.tail = new JsonlTail(cfg.glob)
75
+ this.additive = (cfg.mode ?? "additive") === "additive"
76
+ }
77
+
78
+ available(): boolean {
79
+ this.tail.refreshFiles()
80
+ return this.tail.filePaths.length > 0
81
+ }
82
+
83
+ poll(): SessionInfo[] {
84
+ try {
85
+ if (!this.started) {
86
+ this.tail.refreshFiles()
87
+ this.started = true
88
+ } else {
89
+ this.tail.refreshFiles() // cheap enough for operator-defined source counts
90
+ }
91
+ this.tail.read((obj, path) => this.onLine(obj, path))
92
+
93
+ const out: SessionInfo[] = []
94
+ for (const [sid, a] of this.accs) {
95
+ out.push({
96
+ key: `${this.harness}:${sid}`,
97
+ harness: this.harness,
98
+ id: sid,
99
+ title: a.title || sid.slice(0, 12),
100
+ directory: a.directory,
101
+ model: a.model || "-",
102
+ archived: false,
103
+ tokens: { ...a.tokens },
104
+ cost: a.cost,
105
+ timeCreated: a.timeUpdated,
106
+ timeUpdated: a.timeUpdated,
107
+ })
108
+ }
109
+ this.lastError = null
110
+ return out
111
+ } catch (e) {
112
+ this.lastError = (e as Error).message
113
+ return []
114
+ }
115
+ }
116
+
117
+ private onLine(obj: any, path: string): void {
118
+ if (!obj || typeof obj !== "object") return
119
+ const sid = String(dig(obj, this.cfg.sessionId) ?? path)
120
+ let acc = this.accs.get(sid)
121
+ if (!acc) {
122
+ acc = {
123
+ id: sid,
124
+ model: "",
125
+ title: "",
126
+ directory: "",
127
+ tokens: { input: 0, output: 0, reasoning: 0, cacheRead: 0, cacheWrite: 0 },
128
+ cost: 0,
129
+ timeUpdated: 0,
130
+ }
131
+ this.accs.set(sid, acc)
132
+ }
133
+
134
+ if (this.cfg.model) {
135
+ const m = dig(obj, this.cfg.model)
136
+ if (m != null) acc.model = String(m)
137
+ }
138
+ if (this.cfg.title && !acc.title) {
139
+ const t = dig(obj, this.cfg.title)
140
+ if (t != null) acc.title = String(t).replace(/\s+/g, " ").slice(0, 80)
141
+ }
142
+ if (this.cfg.directory) {
143
+ const d = dig(obj, this.cfg.directory)
144
+ if (d != null) acc.directory = String(d)
145
+ }
146
+ const ts = toTime(dig(obj, this.cfg.timeUpdated))
147
+ if (ts > acc.timeUpdated) acc.timeUpdated = ts
148
+
149
+ const usage = this.cfg.usage ? dig(obj, this.cfg.usage) : obj
150
+ if (usage && typeof usage === "object") {
151
+ const map = this.cfg.tokens ?? {}
152
+ const keys: (keyof TokenCounts)[] = ["input", "output", "reasoning", "cacheRead", "cacheWrite"]
153
+ for (const k of keys) {
154
+ const field = map[k] ?? k
155
+ const v = dig(usage, field as string)
156
+ if (typeof v === "number") {
157
+ if (this.additive) acc.tokens[k] += v
158
+ else acc.tokens[k] = v
159
+ }
160
+ }
161
+ }
162
+ if (this.cfg.cost) {
163
+ const c = dig(obj, this.cfg.cost)
164
+ if (typeof c === "number") {
165
+ if (this.additive) acc.cost += c
166
+ else acc.cost = c
167
+ }
168
+ }
169
+ }
170
+ }