arvre 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.
- arvre/__init__.py +3 -0
- arvre/__main__.py +3 -0
- arvre/app.py +236 -0
- arvre/cli.py +63 -0
- arvre/commands.py +12 -0
- arvre/flowchart.py +176 -0
- arvre/forks.py +37 -0
- arvre/graph_view.py +184 -0
- arvre/layout.py +117 -0
- arvre/scanner.py +113 -0
- arvre/themes.py +76 -0
- arvre-0.1.0.dist-info/METADATA +96 -0
- arvre-0.1.0.dist-info/RECORD +17 -0
- arvre-0.1.0.dist-info/WHEEL +5 -0
- arvre-0.1.0.dist-info/entry_points.txt +2 -0
- arvre-0.1.0.dist-info/licenses/LICENSE +21 -0
- arvre-0.1.0.dist-info/top_level.txt +1 -0
arvre/__init__.py
ADDED
arvre/__main__.py
ADDED
arvre/app.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
"""Textual interface: session graph, filters, and the action modal."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import shutil
|
|
7
|
+
import subprocess
|
|
8
|
+
import sys
|
|
9
|
+
from datetime import datetime, timezone
|
|
10
|
+
from functools import partial
|
|
11
|
+
from pathlib import Path
|
|
12
|
+
|
|
13
|
+
from rich.text import Text
|
|
14
|
+
from textual.app import App, ComposeResult
|
|
15
|
+
from textual.binding import Binding
|
|
16
|
+
from textual.containers import ScrollableContainer, Vertical
|
|
17
|
+
from textual.screen import ModalScreen
|
|
18
|
+
from textual.widgets import Label, OptionList, Static
|
|
19
|
+
from textual.widgets.option_list import Option
|
|
20
|
+
|
|
21
|
+
from arvre.commands import resume_command
|
|
22
|
+
from arvre.forks import link_forks
|
|
23
|
+
from arvre.graph_view import GraphView
|
|
24
|
+
from arvre.layout import build_rows, tree_members, visible_ids
|
|
25
|
+
from arvre.scanner import DEFAULT_ROOT, Session, discover
|
|
26
|
+
from arvre.themes import THEME_ORDER, THEMES, Theme
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
class ActionModal(ModalScreen[str | None]):
|
|
30
|
+
"""Choose what to do with a session. Dismisses with "open", "copy" or None."""
|
|
31
|
+
|
|
32
|
+
BINDINGS = [Binding("escape", "close", "Close")]
|
|
33
|
+
|
|
34
|
+
def __init__(self, session: Session) -> None:
|
|
35
|
+
super().__init__()
|
|
36
|
+
self.session = session
|
|
37
|
+
|
|
38
|
+
def compose(self) -> ComposeResult:
|
|
39
|
+
with Vertical(id="modal"):
|
|
40
|
+
yield Label(Text(self.session.name, style="bold"), id="modal-title")
|
|
41
|
+
yield Static(f"{self.session.id}\n{self.session.cwd}", id="modal-meta")
|
|
42
|
+
yield OptionList(
|
|
43
|
+
Option("Open in this terminal", id="open"),
|
|
44
|
+
Option("Copy command", id="copy"),
|
|
45
|
+
id="modal-actions",
|
|
46
|
+
)
|
|
47
|
+
yield Static(resume_command(self.session), id="modal-command")
|
|
48
|
+
|
|
49
|
+
def on_mount(self) -> None:
|
|
50
|
+
self.query_one(OptionList).focus()
|
|
51
|
+
|
|
52
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
53
|
+
self.dismiss(event.option.id)
|
|
54
|
+
|
|
55
|
+
def action_close(self) -> None:
|
|
56
|
+
self.dismiss(None)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class ArvreApp(App):
|
|
60
|
+
TITLE = "arvre"
|
|
61
|
+
CSS = """
|
|
62
|
+
#scroll { height: 1fr; }
|
|
63
|
+
#detail { height: auto; max-height: 8; padding: 0 1; border-top: solid $accent; }
|
|
64
|
+
#status { height: 1; padding: 0 1; }
|
|
65
|
+
ActionModal { align: center middle; }
|
|
66
|
+
#modal { width: 70; height: auto; border: thick $accent; background: $surface; padding: 1 2; }
|
|
67
|
+
#modal-actions { height: auto; margin: 1 0; }
|
|
68
|
+
#modal-meta, #modal-command { color: $text-muted; }
|
|
69
|
+
"""
|
|
70
|
+
BINDINGS = [
|
|
71
|
+
Binding("q", "quit", "Quit"),
|
|
72
|
+
Binding("r", "reload", "Reload"),
|
|
73
|
+
Binding("a", "toggle_window", "24h / all"),
|
|
74
|
+
Binding("p", "toggle_scope", "this dir / all"),
|
|
75
|
+
Binding("v", "toggle_style", "compact / flow"),
|
|
76
|
+
Binding("t", "toggle_theme", "theme"),
|
|
77
|
+
]
|
|
78
|
+
|
|
79
|
+
def __init__(
|
|
80
|
+
self,
|
|
81
|
+
cwd: str,
|
|
82
|
+
root: Path = DEFAULT_ROOT,
|
|
83
|
+
window_24h: bool = True,
|
|
84
|
+
all_projects: bool = False,
|
|
85
|
+
style: str = "compact",
|
|
86
|
+
theme: str = "classic",
|
|
87
|
+
) -> None:
|
|
88
|
+
super().__init__()
|
|
89
|
+
self.cwd = cwd
|
|
90
|
+
self.root = root
|
|
91
|
+
self.window_24h = window_24h
|
|
92
|
+
self.all_projects = all_projects
|
|
93
|
+
self.style_name = style
|
|
94
|
+
self.theme_name = theme
|
|
95
|
+
self.sessions: dict[str, Session] = {}
|
|
96
|
+
self._cache: dict = {}
|
|
97
|
+
|
|
98
|
+
@property
|
|
99
|
+
def palette(self) -> Theme:
|
|
100
|
+
return THEMES[self.theme_name]
|
|
101
|
+
|
|
102
|
+
def compose(self) -> ComposeResult:
|
|
103
|
+
with ScrollableContainer(id="scroll"):
|
|
104
|
+
yield GraphView(id="graph")
|
|
105
|
+
yield Static(id="detail")
|
|
106
|
+
yield Static(id="status")
|
|
107
|
+
|
|
108
|
+
def on_mount(self) -> None:
|
|
109
|
+
self.load()
|
|
110
|
+
self.query_one(GraphView).focus()
|
|
111
|
+
|
|
112
|
+
# --- data -------------------------------------------------------------
|
|
113
|
+
|
|
114
|
+
def load(self) -> None:
|
|
115
|
+
self.sessions = discover(self.root, self._cache)
|
|
116
|
+
link_forks(self.sessions)
|
|
117
|
+
self.refresh_rows()
|
|
118
|
+
|
|
119
|
+
def refresh_rows(self) -> None:
|
|
120
|
+
now = datetime.now(timezone.utc)
|
|
121
|
+
visible = visible_ids(self.sessions, now, self.window_24h, self.all_projects, self.cwd)
|
|
122
|
+
rows = build_rows(self.sessions, tree_members(self.sessions, visible))
|
|
123
|
+
self.query_one(GraphView).set_data(self.sessions, rows, now, self.style_name, self.palette)
|
|
124
|
+
self.update_status(len(visible))
|
|
125
|
+
|
|
126
|
+
def update_status(self, count: int) -> None:
|
|
127
|
+
accent = self.palette.accent
|
|
128
|
+
scope = "all projects" if self.all_projects else "this dir"
|
|
129
|
+
window = "24h" if self.window_24h else "all time"
|
|
130
|
+
line = Text()
|
|
131
|
+
for key, label in [
|
|
132
|
+
("a", window),
|
|
133
|
+
("p", scope),
|
|
134
|
+
("v", self.style_name),
|
|
135
|
+
("t", f"theme {self.theme_name}"),
|
|
136
|
+
]:
|
|
137
|
+
line.append(f"{key} ", style=accent)
|
|
138
|
+
line.append(f"{label} ")
|
|
139
|
+
line.append("r ", style=accent)
|
|
140
|
+
line.append("reload ")
|
|
141
|
+
line.append("q ", style=accent)
|
|
142
|
+
line.append("quit ")
|
|
143
|
+
line.append(f"· {count} sessions")
|
|
144
|
+
self.query_one("#status", Static).update(line)
|
|
145
|
+
self.update_detail(self.query_one(GraphView).selected_id)
|
|
146
|
+
|
|
147
|
+
def update_detail(self, sid: str | None) -> None:
|
|
148
|
+
detail = self.query_one("#detail", Static)
|
|
149
|
+
if sid is None:
|
|
150
|
+
detail.update(Text("No sessions match the filters. Press a / p to widen them."))
|
|
151
|
+
return
|
|
152
|
+
theme = self.palette
|
|
153
|
+
s = self.sessions[sid]
|
|
154
|
+
parent = self.sessions[s.parent_id].name if s.parent_id else "none"
|
|
155
|
+
children = ", ".join(self.sessions[c].name for c in s.child_ids) or "none"
|
|
156
|
+
text = Text()
|
|
157
|
+
text.append(s.name, style=theme.style("name"))
|
|
158
|
+
text.append(f"\nid {s.id} · branch {s.branch or '-'} · {s.cwd}", style=theme.style("meta"))
|
|
159
|
+
text.append("\nparent: ", style=theme.style("meta"))
|
|
160
|
+
text.append(parent)
|
|
161
|
+
text.append("\nchildren: ", style=theme.style("meta"))
|
|
162
|
+
text.append(children)
|
|
163
|
+
detail.update(text)
|
|
164
|
+
|
|
165
|
+
# --- events -------------------------------------------------------------
|
|
166
|
+
|
|
167
|
+
def on_graph_view_highlighted(self, event: GraphView.Highlighted) -> None:
|
|
168
|
+
self.update_detail(event.session_id)
|
|
169
|
+
|
|
170
|
+
def on_graph_view_activated(self, event: GraphView.Activated) -> None:
|
|
171
|
+
if event.session_id is None:
|
|
172
|
+
return
|
|
173
|
+
session = self.sessions[event.session_id]
|
|
174
|
+
self.push_screen(ActionModal(session), callback=partial(self.run_choice, session))
|
|
175
|
+
|
|
176
|
+
# --- actions ------------------------------------------------------------
|
|
177
|
+
|
|
178
|
+
def action_reload(self) -> None:
|
|
179
|
+
self.load()
|
|
180
|
+
|
|
181
|
+
def action_toggle_window(self) -> None:
|
|
182
|
+
self.window_24h = not self.window_24h
|
|
183
|
+
self.refresh_rows()
|
|
184
|
+
|
|
185
|
+
def action_toggle_scope(self) -> None:
|
|
186
|
+
self.all_projects = not self.all_projects
|
|
187
|
+
self.refresh_rows()
|
|
188
|
+
|
|
189
|
+
def action_toggle_style(self) -> None:
|
|
190
|
+
self.style_name = "flow" if self.style_name == "compact" else "compact"
|
|
191
|
+
self.refresh_rows()
|
|
192
|
+
|
|
193
|
+
def action_toggle_theme(self) -> None:
|
|
194
|
+
self.theme_name = THEME_ORDER[(THEME_ORDER.index(self.theme_name) + 1) % len(THEME_ORDER)]
|
|
195
|
+
self.refresh_rows()
|
|
196
|
+
|
|
197
|
+
def run_choice(self, session: Session, choice: str | None) -> None:
|
|
198
|
+
if choice == "open":
|
|
199
|
+
self.open_here(session)
|
|
200
|
+
elif choice == "copy":
|
|
201
|
+
self.copy_command(session)
|
|
202
|
+
|
|
203
|
+
def open_here(self, session: Session) -> None:
|
|
204
|
+
if not os.path.isdir(session.cwd):
|
|
205
|
+
self.notify(f"Directory no longer exists: {session.cwd}", severity="error")
|
|
206
|
+
return
|
|
207
|
+
try:
|
|
208
|
+
with self.suspend():
|
|
209
|
+
subprocess.run(["claude", "--resume", session.id], cwd=session.cwd, check=False)
|
|
210
|
+
except FileNotFoundError:
|
|
211
|
+
self.notify("`claude` was not found on PATH.", severity="error")
|
|
212
|
+
return
|
|
213
|
+
self.load()
|
|
214
|
+
|
|
215
|
+
def copy_command(self, session: Session) -> None:
|
|
216
|
+
command = resume_command(session)
|
|
217
|
+
self.copy_to_clipboard(command) # OSC 52: works over SSH and in most modern terminals
|
|
218
|
+
clipboard = system_clipboard_command()
|
|
219
|
+
if clipboard:
|
|
220
|
+
subprocess.run(clipboard, input=command, text=True, check=False)
|
|
221
|
+
self.notify(f"Copied: {command}")
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def system_clipboard_command() -> list[str] | None:
|
|
225
|
+
"""A native clipboard tool for this platform, if one is installed."""
|
|
226
|
+
if sys.platform == "darwin":
|
|
227
|
+
return ["pbcopy"]
|
|
228
|
+
candidates = [
|
|
229
|
+
["wl-copy"],
|
|
230
|
+
["xclip", "-selection", "clipboard"],
|
|
231
|
+
["xsel", "--clipboard", "--input"],
|
|
232
|
+
]
|
|
233
|
+
for command in candidates:
|
|
234
|
+
if shutil.which(command[0]):
|
|
235
|
+
return command
|
|
236
|
+
return None
|
arvre/cli.py
ADDED
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
"""Command-line entry point for arvre."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import argparse
|
|
6
|
+
import os
|
|
7
|
+
from datetime import datetime, timezone
|
|
8
|
+
|
|
9
|
+
from arvre.app import ArvreApp
|
|
10
|
+
from arvre.forks import link_forks
|
|
11
|
+
from arvre.layout import build_rows, row_label, tree_members, visible_ids
|
|
12
|
+
from arvre.scanner import DEFAULT_ROOT, discover
|
|
13
|
+
from arvre.themes import THEMES
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def main(argv: list[str] | None = None) -> None:
|
|
17
|
+
parser = argparse.ArgumentParser(
|
|
18
|
+
prog="arvre",
|
|
19
|
+
description="Show Claude Code sessions and their forks as a graph.",
|
|
20
|
+
)
|
|
21
|
+
parser.add_argument("--dir", default=os.getcwd(), help="project directory to show (default: current)")
|
|
22
|
+
parser.add_argument("--all-projects", action="store_true", help="start with sessions from every project")
|
|
23
|
+
parser.add_argument("--all-time", action="store_true", help="start with all sessions, not only the last 24h")
|
|
24
|
+
parser.add_argument(
|
|
25
|
+
"--style",
|
|
26
|
+
choices=["compact", "flow"],
|
|
27
|
+
default="compact",
|
|
28
|
+
help="tree style to start with (default: compact)",
|
|
29
|
+
)
|
|
30
|
+
parser.add_argument(
|
|
31
|
+
"--theme",
|
|
32
|
+
choices=list(THEMES),
|
|
33
|
+
default="classic",
|
|
34
|
+
help="colour theme to start with (default: classic); press t to cycle",
|
|
35
|
+
)
|
|
36
|
+
parser.add_argument("--print", dest="print_only", action="store_true", help="print the graph as text and exit")
|
|
37
|
+
args = parser.parse_args(argv)
|
|
38
|
+
|
|
39
|
+
cwd = os.path.realpath(args.dir)
|
|
40
|
+
if args.print_only:
|
|
41
|
+
print_graph(cwd, args)
|
|
42
|
+
return
|
|
43
|
+
ArvreApp(
|
|
44
|
+
cwd=cwd,
|
|
45
|
+
root=DEFAULT_ROOT,
|
|
46
|
+
window_24h=not args.all_time,
|
|
47
|
+
all_projects=args.all_projects,
|
|
48
|
+
style=args.style,
|
|
49
|
+
theme=args.theme,
|
|
50
|
+
).run()
|
|
51
|
+
|
|
52
|
+
|
|
53
|
+
def print_graph(cwd: str, args: argparse.Namespace) -> None:
|
|
54
|
+
now = datetime.now(timezone.utc)
|
|
55
|
+
sessions = discover(DEFAULT_ROOT)
|
|
56
|
+
link_forks(sessions)
|
|
57
|
+
visible = visible_ids(sessions, now, not args.all_time, args.all_projects, cwd)
|
|
58
|
+
for row in build_rows(sessions, tree_members(sessions, visible)):
|
|
59
|
+
print(row_label(row, sessions, now))
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
if __name__ == "__main__":
|
|
63
|
+
main()
|
arvre/commands.py
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Commands for resuming a session."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import shlex
|
|
6
|
+
|
|
7
|
+
from arvre.scanner import Session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def resume_command(session: Session) -> str:
|
|
11
|
+
# --resume is scoped to the project directory, so cd there first.
|
|
12
|
+
return f"cd {shlex.quote(session.cwd)} && claude --resume {session.id}"
|
arvre/flowchart.py
ADDED
|
@@ -0,0 +1,176 @@
|
|
|
1
|
+
"""Left-to-right flowchart rendering of a session tree."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
from datetime import datetime
|
|
7
|
+
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
|
|
10
|
+
from arvre.layout import Row, format_age
|
|
11
|
+
from arvre.scanner import Session
|
|
12
|
+
from arvre.themes import Theme
|
|
13
|
+
|
|
14
|
+
BOX_W = 28
|
|
15
|
+
BOX_H = 4
|
|
16
|
+
GAP_X = 6
|
|
17
|
+
GAP_Y = 1
|
|
18
|
+
|
|
19
|
+
UP, DOWN, LEFT, RIGHT = 1, 2, 4, 8
|
|
20
|
+
LINE_CHARS = {
|
|
21
|
+
UP | DOWN: "│",
|
|
22
|
+
LEFT | RIGHT: "─",
|
|
23
|
+
DOWN | RIGHT: "┌",
|
|
24
|
+
DOWN | LEFT: "┐",
|
|
25
|
+
UP | RIGHT: "└",
|
|
26
|
+
UP | LEFT: "┘",
|
|
27
|
+
UP | DOWN | RIGHT: "├",
|
|
28
|
+
UP | DOWN | LEFT: "┤",
|
|
29
|
+
DOWN | LEFT | RIGHT: "┬",
|
|
30
|
+
UP | LEFT | RIGHT: "┴",
|
|
31
|
+
UP | DOWN | LEFT | RIGHT: "┼",
|
|
32
|
+
UP: "│",
|
|
33
|
+
DOWN: "│",
|
|
34
|
+
LEFT: "─",
|
|
35
|
+
RIGHT: "─",
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
@dataclass(frozen=True)
|
|
40
|
+
class Box:
|
|
41
|
+
sid: str
|
|
42
|
+
x: int
|
|
43
|
+
y: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass
|
|
47
|
+
class FlowLayout:
|
|
48
|
+
boxes: dict[str, Box]
|
|
49
|
+
masks: list[list[int]] # direction bits per cell, turned into line chars when drawn
|
|
50
|
+
width: int
|
|
51
|
+
height: int
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def flow_layout(rows: list[Row]) -> FlowLayout:
|
|
55
|
+
"""Columns are tree depths; each parent is centred on its children."""
|
|
56
|
+
kids: dict[str, list[str]] = {}
|
|
57
|
+
for row in rows:
|
|
58
|
+
if row.session.parent_id:
|
|
59
|
+
kids.setdefault(row.session.parent_id, []).append(row.session.id)
|
|
60
|
+
depth = {row.session.id: row.depth for row in rows}
|
|
61
|
+
|
|
62
|
+
ys: dict[str, int] = {}
|
|
63
|
+
slot = 0
|
|
64
|
+
|
|
65
|
+
def place(sid: str) -> int:
|
|
66
|
+
nonlocal slot
|
|
67
|
+
children = kids.get(sid, [])
|
|
68
|
+
if children:
|
|
69
|
+
child_ys = [place(child) for child in children]
|
|
70
|
+
ys[sid] = (child_ys[0] + child_ys[-1]) // 2
|
|
71
|
+
else:
|
|
72
|
+
ys[sid] = slot * (BOX_H + GAP_Y)
|
|
73
|
+
slot += 1
|
|
74
|
+
return ys[sid]
|
|
75
|
+
|
|
76
|
+
for row in rows:
|
|
77
|
+
if row.depth == 0:
|
|
78
|
+
place(row.session.id)
|
|
79
|
+
|
|
80
|
+
boxes = {sid: Box(sid, depth[sid] * (BOX_W + GAP_X), y) for sid, y in ys.items()}
|
|
81
|
+
if not boxes:
|
|
82
|
+
return FlowLayout({}, [], 0, 0)
|
|
83
|
+
|
|
84
|
+
width = max(box.x for box in boxes.values()) + BOX_W
|
|
85
|
+
height = max(box.y for box in boxes.values()) + BOX_H
|
|
86
|
+
masks = [[0] * width for _ in range(height)]
|
|
87
|
+
|
|
88
|
+
for parent_id, child_ids in kids.items():
|
|
89
|
+
parent = boxes[parent_id]
|
|
90
|
+
exit_x = parent.x + BOX_W
|
|
91
|
+
exit_y = parent.y + 1
|
|
92
|
+
bend_x = exit_x + GAP_X // 2
|
|
93
|
+
for child_id in child_ids:
|
|
94
|
+
child = boxes[child_id]
|
|
95
|
+
enter_y = child.y + 1
|
|
96
|
+
enter_x = child.x - 1
|
|
97
|
+
_horizontal(masks, exit_y, exit_x, bend_x)
|
|
98
|
+
_vertical(masks, bend_x, exit_y, enter_y)
|
|
99
|
+
_horizontal(masks, enter_y, bend_x, enter_x)
|
|
100
|
+
|
|
101
|
+
return FlowLayout(boxes, masks, width, height)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _horizontal(masks: list[list[int]], y: int, x1: int, x2: int) -> None:
|
|
105
|
+
masks[y][x1] |= RIGHT
|
|
106
|
+
masks[y][x2] |= LEFT
|
|
107
|
+
for x in range(x1 + 1, x2):
|
|
108
|
+
masks[y][x] |= LEFT | RIGHT
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _vertical(masks: list[list[int]], x: int, y1: int, y2: int) -> None:
|
|
112
|
+
if y1 == y2:
|
|
113
|
+
return
|
|
114
|
+
top, bottom = sorted((y1, y2))
|
|
115
|
+
masks[top][x] |= DOWN
|
|
116
|
+
masks[bottom][x] |= UP
|
|
117
|
+
for y in range(top + 1, bottom):
|
|
118
|
+
masks[y][x] |= UP | DOWN
|
|
119
|
+
|
|
120
|
+
|
|
121
|
+
def flow_text(
|
|
122
|
+
layout: FlowLayout,
|
|
123
|
+
sessions: dict[str, Session],
|
|
124
|
+
now: datetime,
|
|
125
|
+
selected: str | None,
|
|
126
|
+
theme: Theme,
|
|
127
|
+
) -> Text:
|
|
128
|
+
grid: list[list[tuple[str, str]]] = [
|
|
129
|
+
[(LINE_CHARS.get(mask, "┼") if mask else " ", theme.style("connector") if mask else "") for mask in row]
|
|
130
|
+
for row in layout.masks
|
|
131
|
+
]
|
|
132
|
+
for box in layout.boxes.values():
|
|
133
|
+
_draw_box(grid, box, sessions[box.sid], now, theme, box.sid == selected)
|
|
134
|
+
|
|
135
|
+
text = Text(no_wrap=True)
|
|
136
|
+
for y, row in enumerate(grid):
|
|
137
|
+
for char, style in row:
|
|
138
|
+
text.append(char, style=style)
|
|
139
|
+
if y < len(grid) - 1:
|
|
140
|
+
text.append("\n")
|
|
141
|
+
return text
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def _draw_box(
|
|
145
|
+
grid: list[list[tuple[str, str]]],
|
|
146
|
+
box: Box,
|
|
147
|
+
session: Session,
|
|
148
|
+
now: datetime,
|
|
149
|
+
theme: Theme,
|
|
150
|
+
selected: bool,
|
|
151
|
+
) -> None:
|
|
152
|
+
inner = BOX_W - 2
|
|
153
|
+
name = _fit(session.name, inner - 2)
|
|
154
|
+
stats = _fit(f"{format_age(now, session.last_ts)} · {_children(session)}", inner - 2)
|
|
155
|
+
lines = [
|
|
156
|
+
("┌" + "─" * inner + "┐", "border"),
|
|
157
|
+
("│ " + name.ljust(inner - 2) + " │", "name"),
|
|
158
|
+
("│ " + stats.ljust(inner - 2) + " │", "meta"),
|
|
159
|
+
("└" + "─" * inner + "┘", "border"),
|
|
160
|
+
]
|
|
161
|
+
for dy, (line, role) in enumerate(lines):
|
|
162
|
+
for dx, char in enumerate(line):
|
|
163
|
+
frame = dy in (0, 3) or dx in (0, BOX_W - 1) or char == " "
|
|
164
|
+
cell_role = "border" if frame else role
|
|
165
|
+
grid[box.y + dy][box.x + dx] = (char, theme.style(cell_role, selected))
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
def _children(session: Session) -> str:
|
|
169
|
+
count = len(session.child_ids)
|
|
170
|
+
if count == 0:
|
|
171
|
+
return "no children"
|
|
172
|
+
return "1 child" if count == 1 else f"{count} children"
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _fit(text: str, width: int) -> str:
|
|
176
|
+
return text if len(text) <= width else text[: width - 1] + "…"
|
arvre/forks.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Infer parent/child links between sessions. See docs/forks.md."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import Counter, defaultdict
|
|
6
|
+
|
|
7
|
+
from arvre.scanner import Session
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def link_forks(sessions: dict[str, Session]) -> None:
|
|
11
|
+
"""Set parent_id and child_ids on every session.
|
|
12
|
+
|
|
13
|
+
A fork is a copy of its parent's records with the same uuids, so the parent
|
|
14
|
+
of a session is the earlier session sharing the most uuids with it.
|
|
15
|
+
"""
|
|
16
|
+
order = sorted(sessions.values(), key=lambda s: (s.first_ts, s.created))
|
|
17
|
+
rank = {s.id: i for i, s in enumerate(order)}
|
|
18
|
+
|
|
19
|
+
by_uuid: dict[str, list[str]] = defaultdict(list)
|
|
20
|
+
for session in sessions.values():
|
|
21
|
+
session.parent_id = None
|
|
22
|
+
session.child_ids = []
|
|
23
|
+
for uuid in session.uuids:
|
|
24
|
+
by_uuid[uuid].append(session.id)
|
|
25
|
+
|
|
26
|
+
for session in sessions.values():
|
|
27
|
+
shared: Counter[str] = Counter()
|
|
28
|
+
for uuid in session.uuids:
|
|
29
|
+
for other_id in by_uuid[uuid]:
|
|
30
|
+
if other_id != session.id and rank[other_id] < rank[session.id]:
|
|
31
|
+
shared[other_id] += 1
|
|
32
|
+
if shared:
|
|
33
|
+
session.parent_id = max(shared, key=lambda oid: (shared[oid], -rank[oid]))
|
|
34
|
+
|
|
35
|
+
for session in sessions.values():
|
|
36
|
+
if session.parent_id:
|
|
37
|
+
sessions[session.parent_id].child_ids.append(session.id)
|
arvre/graph_view.py
ADDED
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
"""Clickable, keyboard-navigable widget that draws the session graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from datetime import datetime
|
|
6
|
+
|
|
7
|
+
from rich.cells import cell_len
|
|
8
|
+
from rich.text import Text
|
|
9
|
+
from textual import events
|
|
10
|
+
from textual.binding import Binding
|
|
11
|
+
from textual.geometry import Region
|
|
12
|
+
from textual.message import Message
|
|
13
|
+
from textual.widget import Widget
|
|
14
|
+
|
|
15
|
+
from arvre.flowchart import BOX_H, BOX_W, FlowLayout, flow_layout, flow_text
|
|
16
|
+
from arvre.layout import Row, row_label, row_segments
|
|
17
|
+
from arvre.scanner import Session
|
|
18
|
+
from arvre.themes import THEMES, Theme
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class GraphView(Widget, can_focus=True):
|
|
22
|
+
BINDINGS = [
|
|
23
|
+
Binding("up,k", "move(-1)", "Up", show=False),
|
|
24
|
+
Binding("down,j", "move(1)", "Down", show=False),
|
|
25
|
+
Binding("left,h", "parent", "Parent"),
|
|
26
|
+
Binding("right,l", "child", "Child"),
|
|
27
|
+
Binding("enter", "activate", "Open"),
|
|
28
|
+
]
|
|
29
|
+
|
|
30
|
+
class Highlighted(Message):
|
|
31
|
+
def __init__(self, session_id: str | None) -> None:
|
|
32
|
+
super().__init__()
|
|
33
|
+
self.session_id = session_id
|
|
34
|
+
|
|
35
|
+
class Activated(Message):
|
|
36
|
+
def __init__(self, session_id: str | None) -> None:
|
|
37
|
+
super().__init__()
|
|
38
|
+
self.session_id = session_id
|
|
39
|
+
|
|
40
|
+
def __init__(self, id: str | None = None) -> None:
|
|
41
|
+
super().__init__(id=id)
|
|
42
|
+
self.sessions: dict[str, Session] = {}
|
|
43
|
+
self.rows: list[Row] = []
|
|
44
|
+
self.now = datetime.now().astimezone()
|
|
45
|
+
self.style_name = "compact"
|
|
46
|
+
self.theme: Theme = THEMES["classic"]
|
|
47
|
+
self._index = 0
|
|
48
|
+
self._flow: FlowLayout | None = None
|
|
49
|
+
|
|
50
|
+
# --- data ---------------------------------------------------------------
|
|
51
|
+
|
|
52
|
+
def set_data(
|
|
53
|
+
self,
|
|
54
|
+
sessions: dict[str, Session],
|
|
55
|
+
rows: list[Row],
|
|
56
|
+
now: datetime,
|
|
57
|
+
style_name: str,
|
|
58
|
+
theme: Theme,
|
|
59
|
+
) -> None:
|
|
60
|
+
selected = self.selected_id
|
|
61
|
+
self.sessions = sessions
|
|
62
|
+
self.rows = rows
|
|
63
|
+
self.now = now
|
|
64
|
+
self.style_name = style_name
|
|
65
|
+
self.theme = theme
|
|
66
|
+
self._index = next((i for i, r in enumerate(rows) if r.session.id == selected), 0)
|
|
67
|
+
|
|
68
|
+
if style_name == "flow":
|
|
69
|
+
self._flow = flow_layout(rows)
|
|
70
|
+
width, height = self._flow.width, self._flow.height
|
|
71
|
+
else:
|
|
72
|
+
self._flow = None
|
|
73
|
+
labels = [row_label(row, sessions, now) for row in rows]
|
|
74
|
+
width = max((cell_len(label) for label in labels), default=0)
|
|
75
|
+
height = len(labels)
|
|
76
|
+
self.styles.width = max(width, 1)
|
|
77
|
+
self.styles.height = max(height, 1)
|
|
78
|
+
self.refresh(layout=True)
|
|
79
|
+
self.post_message(self.Highlighted(self.selected_id))
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def selected_id(self) -> str | None:
|
|
83
|
+
if not self.rows:
|
|
84
|
+
return None
|
|
85
|
+
return self.rows[self._index].session.id
|
|
86
|
+
|
|
87
|
+
def select_id(self, session_id: str) -> bool:
|
|
88
|
+
for i, row in enumerate(self.rows):
|
|
89
|
+
if row.session.id == session_id:
|
|
90
|
+
self._select(i)
|
|
91
|
+
return True
|
|
92
|
+
return False
|
|
93
|
+
|
|
94
|
+
def _select(self, index: int) -> None:
|
|
95
|
+
if not self.rows:
|
|
96
|
+
return
|
|
97
|
+
self._index = max(0, min(index, len(self.rows) - 1))
|
|
98
|
+
self.refresh()
|
|
99
|
+
self._reveal()
|
|
100
|
+
self.post_message(self.Highlighted(self.selected_id))
|
|
101
|
+
|
|
102
|
+
def _reveal(self) -> None:
|
|
103
|
+
if self._flow:
|
|
104
|
+
box = self._flow.boxes[self.selected_id]
|
|
105
|
+
region = Region(box.x, box.y, BOX_W, BOX_H)
|
|
106
|
+
else:
|
|
107
|
+
region = Region(0, self._index, 1, 1)
|
|
108
|
+
self.scroll_to_region(region, animate=False)
|
|
109
|
+
|
|
110
|
+
def _index_at(self, x: int, y: int) -> int | None:
|
|
111
|
+
if self._flow:
|
|
112
|
+
for i, row in enumerate(self.rows):
|
|
113
|
+
box = self._flow.boxes[row.session.id]
|
|
114
|
+
if box.x <= x < box.x + BOX_W and box.y <= y < box.y + BOX_H:
|
|
115
|
+
return i
|
|
116
|
+
return None
|
|
117
|
+
return y if 0 <= y < len(self.rows) else None
|
|
118
|
+
|
|
119
|
+
# --- rendering ----------------------------------------------------------
|
|
120
|
+
|
|
121
|
+
def render(self) -> Text:
|
|
122
|
+
if not self.rows:
|
|
123
|
+
return Text("")
|
|
124
|
+
if self._flow:
|
|
125
|
+
return flow_text(self._flow, self.sessions, self.now, self.selected_id, self.theme)
|
|
126
|
+
lines = []
|
|
127
|
+
for i, row in enumerate(self.rows):
|
|
128
|
+
selected = i == self._index
|
|
129
|
+
line = Text(no_wrap=True)
|
|
130
|
+
for segment, role in row_segments(row, self.sessions, self.now):
|
|
131
|
+
line.append(segment, style=self.theme.style(role, selected))
|
|
132
|
+
lines.append(line)
|
|
133
|
+
return Text("\n").join(lines)
|
|
134
|
+
|
|
135
|
+
# --- keyboard -----------------------------------------------------------
|
|
136
|
+
|
|
137
|
+
def action_move(self, step: int) -> None:
|
|
138
|
+
if self._flow:
|
|
139
|
+
self._move_same_level(step)
|
|
140
|
+
else:
|
|
141
|
+
self._select(self._index + step)
|
|
142
|
+
|
|
143
|
+
def _move_same_level(self, step: int) -> None:
|
|
144
|
+
"""Flowchart: up/down step through sessions in the same column (same depth)."""
|
|
145
|
+
depth = self.rows[self._index].depth
|
|
146
|
+
level = [i for i, row in enumerate(self.rows) if row.depth == depth]
|
|
147
|
+
level.sort(key=lambda i: self._flow.boxes[self.rows[i].session.id].y)
|
|
148
|
+
target = level.index(self._index) + step
|
|
149
|
+
if 0 <= target < len(level):
|
|
150
|
+
self._select(level[target])
|
|
151
|
+
|
|
152
|
+
def action_parent(self) -> None:
|
|
153
|
+
if self.selected_id is None:
|
|
154
|
+
return
|
|
155
|
+
parent = self.sessions[self.selected_id].parent_id
|
|
156
|
+
if parent:
|
|
157
|
+
self.select_id(parent)
|
|
158
|
+
|
|
159
|
+
def action_child(self) -> None:
|
|
160
|
+
if self.selected_id is None:
|
|
161
|
+
return
|
|
162
|
+
child = next(
|
|
163
|
+
(i for i, row in enumerate(self.rows) if row.session.parent_id == self.selected_id),
|
|
164
|
+
None,
|
|
165
|
+
)
|
|
166
|
+
if child is not None:
|
|
167
|
+
self._select(child)
|
|
168
|
+
|
|
169
|
+
def action_activate(self) -> None:
|
|
170
|
+
self.post_message(self.Activated(self.selected_id))
|
|
171
|
+
|
|
172
|
+
# --- mouse --------------------------------------------------------------
|
|
173
|
+
|
|
174
|
+
def on_click(self, event: events.Click) -> None:
|
|
175
|
+
offset = event.get_content_offset(self)
|
|
176
|
+
if offset is None:
|
|
177
|
+
return
|
|
178
|
+
index = self._index_at(offset.x, offset.y)
|
|
179
|
+
if index is None:
|
|
180
|
+
return
|
|
181
|
+
if index == self._index:
|
|
182
|
+
self.post_message(self.Activated(self.selected_id))
|
|
183
|
+
else:
|
|
184
|
+
self._select(index)
|
arvre/layout.py
ADDED
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
"""Filtering and tree layout for the session graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from collections import defaultdict
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from datetime import datetime, timedelta
|
|
8
|
+
|
|
9
|
+
from arvre.scanner import Session
|
|
10
|
+
|
|
11
|
+
WINDOW = timedelta(hours=24)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
@dataclass(frozen=True)
|
|
15
|
+
class Row:
|
|
16
|
+
session: Session
|
|
17
|
+
prefix: str
|
|
18
|
+
depth: int
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def visible_ids(
|
|
22
|
+
sessions: dict[str, Session],
|
|
23
|
+
now: datetime,
|
|
24
|
+
window_24h: bool,
|
|
25
|
+
all_projects: bool,
|
|
26
|
+
cwd: str,
|
|
27
|
+
) -> set[str]:
|
|
28
|
+
cutoff = now - WINDOW
|
|
29
|
+
return {
|
|
30
|
+
s.id
|
|
31
|
+
for s in sessions.values()
|
|
32
|
+
if (not window_24h or s.last_ts >= cutoff)
|
|
33
|
+
and (all_projects or s.cwd == cwd)
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def tree_members(sessions: dict[str, Session], visible: set[str]) -> set[str]:
|
|
38
|
+
"""Every session in the family tree of any matching session."""
|
|
39
|
+
roots = set()
|
|
40
|
+
for sid in visible:
|
|
41
|
+
current = sid
|
|
42
|
+
while sessions[current].parent_id:
|
|
43
|
+
current = sessions[current].parent_id
|
|
44
|
+
roots.add(current)
|
|
45
|
+
|
|
46
|
+
members: set[str] = set()
|
|
47
|
+
stack = list(roots)
|
|
48
|
+
while stack:
|
|
49
|
+
sid = stack.pop()
|
|
50
|
+
members.add(sid)
|
|
51
|
+
stack.extend(sessions[sid].child_ids)
|
|
52
|
+
return members
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def build_rows(sessions: dict[str, Session], shown: set[str]) -> list[Row]:
|
|
56
|
+
children: dict[str, list[str]] = defaultdict(list)
|
|
57
|
+
roots: list[str] = []
|
|
58
|
+
for sid in shown:
|
|
59
|
+
parent = sessions[sid].parent_id
|
|
60
|
+
if parent in shown:
|
|
61
|
+
children[parent].append(sid)
|
|
62
|
+
else:
|
|
63
|
+
roots.append(sid)
|
|
64
|
+
|
|
65
|
+
def newest_first(ids: list[str]) -> list[str]:
|
|
66
|
+
return sorted(ids, key=lambda sid: sessions[sid].last_ts, reverse=True)
|
|
67
|
+
|
|
68
|
+
rows: list[Row] = []
|
|
69
|
+
|
|
70
|
+
def walk(sid: str, prefix: str, child_base: str, depth: int) -> None:
|
|
71
|
+
rows.append(Row(sessions[sid], prefix, depth))
|
|
72
|
+
kids = newest_first(children[sid])
|
|
73
|
+
for i, kid in enumerate(kids):
|
|
74
|
+
last = i == len(kids) - 1
|
|
75
|
+
walk(
|
|
76
|
+
kid,
|
|
77
|
+
child_base + ("└─ " if last else "├─ "),
|
|
78
|
+
child_base + (" " if last else "│ "),
|
|
79
|
+
depth + 1,
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
for root in newest_first(roots):
|
|
83
|
+
walk(root, "", "", 0)
|
|
84
|
+
return rows
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
def format_age(now: datetime, then: datetime) -> str:
|
|
88
|
+
seconds = int((now - then).total_seconds())
|
|
89
|
+
if seconds < 60:
|
|
90
|
+
return "just now"
|
|
91
|
+
if seconds < 3600:
|
|
92
|
+
return f"{seconds // 60}m ago"
|
|
93
|
+
if seconds < 86400:
|
|
94
|
+
return f"{seconds // 3600}h ago"
|
|
95
|
+
return f"{seconds // 86400}d ago"
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
def row_segments(row: Row, sessions: dict[str, Session], now: datetime) -> list[tuple[str, str]]:
|
|
99
|
+
"""The compact row as (text, role) pairs; roles are styled by the theme."""
|
|
100
|
+
s = row.session
|
|
101
|
+
local = s.last_ts.astimezone().strftime("%Y-%m-%d %H:%M")
|
|
102
|
+
meta = [f"{format_age(now, s.last_ts)} ({local})"]
|
|
103
|
+
if s.parent_id:
|
|
104
|
+
meta.append(f"↑ {sessions[s.parent_id].name}")
|
|
105
|
+
if s.child_ids:
|
|
106
|
+
count = len(s.child_ids)
|
|
107
|
+
meta.append(f"{count} {'child' if count == 1 else 'children'}")
|
|
108
|
+
|
|
109
|
+
segments = [(row.prefix, "connector"), ("● ", "bullet"), (s.name, "name")]
|
|
110
|
+
for text in meta:
|
|
111
|
+
segments.append((" · ", "meta"))
|
|
112
|
+
segments.append((text, "meta"))
|
|
113
|
+
return segments
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
def row_label(row: Row, sessions: dict[str, Session], now: datetime) -> str:
|
|
117
|
+
return "".join(text for text, _ in row_segments(row, sessions, now))
|
arvre/scanner.py
ADDED
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
"""Read Claude Code session files from ~/.claude/projects."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from datetime import datetime
|
|
9
|
+
from pathlib import Path
|
|
10
|
+
|
|
11
|
+
DEFAULT_ROOT = Path.home() / ".claude" / "projects"
|
|
12
|
+
MESSAGE_TYPES = {"user", "assistant"}
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass
|
|
16
|
+
class Session:
|
|
17
|
+
id: str
|
|
18
|
+
path: Path
|
|
19
|
+
cwd: str
|
|
20
|
+
name: str
|
|
21
|
+
first_ts: datetime
|
|
22
|
+
last_ts: datetime
|
|
23
|
+
branch: str
|
|
24
|
+
created: float
|
|
25
|
+
uuids: frozenset[str] = field(repr=False)
|
|
26
|
+
parent_id: str | None = None
|
|
27
|
+
child_ids: list[str] = field(default_factory=list)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def parse_ts(value: str) -> datetime:
|
|
31
|
+
return datetime.fromisoformat(value.replace("Z", "+00:00"))
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def last_field(records: list[dict], key: str, record_type: str | None = None) -> str:
|
|
35
|
+
for record in reversed(records):
|
|
36
|
+
if record_type and record.get("type") != record_type:
|
|
37
|
+
continue
|
|
38
|
+
if record.get(key):
|
|
39
|
+
return str(record[key])
|
|
40
|
+
return ""
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
def display_name(records: list[dict], session_id: str) -> str:
|
|
44
|
+
"""Same priority as /resume: custom title, AI title, last prompt, short id."""
|
|
45
|
+
custom = last_field(records, "customTitle", "custom-title")
|
|
46
|
+
if custom:
|
|
47
|
+
return custom
|
|
48
|
+
ai = last_field(records, "aiTitle", "ai-title")
|
|
49
|
+
if ai:
|
|
50
|
+
return ai
|
|
51
|
+
prompt = last_field(records, "lastPrompt", "last-prompt").strip()
|
|
52
|
+
if prompt:
|
|
53
|
+
return prompt.splitlines()[0][:60]
|
|
54
|
+
return session_id[:8]
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
def parse_session(path: Path) -> Session | None:
|
|
58
|
+
records: list[dict] = []
|
|
59
|
+
with path.open(encoding="utf-8", errors="replace") as handle:
|
|
60
|
+
for line in handle:
|
|
61
|
+
try:
|
|
62
|
+
records.append(json.loads(line))
|
|
63
|
+
except json.JSONDecodeError:
|
|
64
|
+
continue
|
|
65
|
+
|
|
66
|
+
messages = [
|
|
67
|
+
r
|
|
68
|
+
for r in records
|
|
69
|
+
if r.get("type") in MESSAGE_TYPES and not r.get("isSidechain") and r.get("timestamp")
|
|
70
|
+
]
|
|
71
|
+
if not messages:
|
|
72
|
+
return None
|
|
73
|
+
|
|
74
|
+
stamps = [parse_ts(r["timestamp"]) for r in messages]
|
|
75
|
+
stat = path.stat()
|
|
76
|
+
session_id = path.stem
|
|
77
|
+
return Session(
|
|
78
|
+
id=session_id,
|
|
79
|
+
path=path,
|
|
80
|
+
cwd=next((r["cwd"] for r in records if r.get("cwd")), ""),
|
|
81
|
+
name=display_name(records, session_id),
|
|
82
|
+
first_ts=min(stamps),
|
|
83
|
+
last_ts=max(stamps),
|
|
84
|
+
branch=last_field(messages, "gitBranch"),
|
|
85
|
+
created=getattr(stat, "st_birthtime", stat.st_ctime),
|
|
86
|
+
uuids=frozenset(r["uuid"] for r in messages if r.get("uuid")),
|
|
87
|
+
)
|
|
88
|
+
|
|
89
|
+
|
|
90
|
+
def discover(root: Path = DEFAULT_ROOT, cache: dict | None = None) -> dict[str, Session]:
|
|
91
|
+
"""Parse every session under root. `cache` maps path -> (mtime, size, Session)."""
|
|
92
|
+
sessions: dict[str, Session] = {}
|
|
93
|
+
if not root.is_dir():
|
|
94
|
+
return sessions
|
|
95
|
+
cache = {} if cache is None else cache
|
|
96
|
+
for path in root.glob("*/*.jsonl"):
|
|
97
|
+
try:
|
|
98
|
+
stat = path.stat()
|
|
99
|
+
except OSError:
|
|
100
|
+
continue
|
|
101
|
+
key = (stat.st_mtime_ns, stat.st_size)
|
|
102
|
+
cached = cache.get(path)
|
|
103
|
+
if cached and cached[0] == key:
|
|
104
|
+
session = cached[1]
|
|
105
|
+
else:
|
|
106
|
+
try:
|
|
107
|
+
session = parse_session(path)
|
|
108
|
+
except OSError:
|
|
109
|
+
session = None
|
|
110
|
+
cache[path] = (key, session)
|
|
111
|
+
if session:
|
|
112
|
+
sessions[session.id] = session
|
|
113
|
+
return sessions
|
arvre/themes.py
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
"""Colour palettes for the session graph."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from dataclasses import dataclass
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True)
|
|
9
|
+
class Theme:
|
|
10
|
+
name: str
|
|
11
|
+
connector: str # tree and flowchart lines
|
|
12
|
+
bullet: str
|
|
13
|
+
name_style: str # session names
|
|
14
|
+
meta: str # age, counts, parent arrow
|
|
15
|
+
border: str # flowchart box frames
|
|
16
|
+
selected: str # the highlighted row or box
|
|
17
|
+
accent: str # key letters in the status line
|
|
18
|
+
|
|
19
|
+
def style(self, role: str, selected: bool = False) -> str:
|
|
20
|
+
if selected:
|
|
21
|
+
return self.selected
|
|
22
|
+
return {
|
|
23
|
+
"connector": self.connector,
|
|
24
|
+
"bullet": self.bullet,
|
|
25
|
+
"name": self.name_style,
|
|
26
|
+
"meta": self.meta,
|
|
27
|
+
"border": self.border,
|
|
28
|
+
}.get(role, "")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
THEMES: dict[str, Theme] = {
|
|
32
|
+
theme.name: theme
|
|
33
|
+
for theme in [
|
|
34
|
+
Theme(
|
|
35
|
+
name="classic",
|
|
36
|
+
connector="dim",
|
|
37
|
+
bullet="",
|
|
38
|
+
name_style="bold",
|
|
39
|
+
meta="dim",
|
|
40
|
+
border="",
|
|
41
|
+
selected="reverse",
|
|
42
|
+
accent="bold",
|
|
43
|
+
),
|
|
44
|
+
Theme(
|
|
45
|
+
name="ocean",
|
|
46
|
+
connector="#4a86b8",
|
|
47
|
+
bullet="#5fd7ff",
|
|
48
|
+
name_style="bold #e6f1ff",
|
|
49
|
+
meta="#7f98b5",
|
|
50
|
+
border="#4a86b8",
|
|
51
|
+
selected="bold #0b1d2e on #5fd7ff",
|
|
52
|
+
accent="bold #5fd7ff",
|
|
53
|
+
),
|
|
54
|
+
Theme(
|
|
55
|
+
name="forest",
|
|
56
|
+
connector="#5f8f5f",
|
|
57
|
+
bullet="#a6e3a1",
|
|
58
|
+
name_style="bold #d8f3dc",
|
|
59
|
+
meta="#8aa58a",
|
|
60
|
+
border="#5f8f5f",
|
|
61
|
+
selected="bold #0d2b1a on #a6e3a1",
|
|
62
|
+
accent="bold #a6e3a1",
|
|
63
|
+
),
|
|
64
|
+
Theme(
|
|
65
|
+
name="sunset",
|
|
66
|
+
connector="#c26a5a",
|
|
67
|
+
bullet="#ffb86c",
|
|
68
|
+
name_style="bold #ffe6cc",
|
|
69
|
+
meta="#c9a38f",
|
|
70
|
+
border="#e07a5f",
|
|
71
|
+
selected="bold #2b1420 on #ffb86c",
|
|
72
|
+
accent="bold #ffb86c",
|
|
73
|
+
),
|
|
74
|
+
]
|
|
75
|
+
}
|
|
76
|
+
THEME_ORDER = list(THEMES)
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: arvre
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Terminal graph of Claude Code sessions and their forks
|
|
5
|
+
Author: Victor Carrico
|
|
6
|
+
License-Expression: MIT
|
|
7
|
+
Project-URL: Homepage, https://github.com/victorcarrico/arvre
|
|
8
|
+
Project-URL: Issues, https://github.com/victorcarrico/arvre/issues
|
|
9
|
+
Keywords: claude,claude-code,tui,sessions,fork
|
|
10
|
+
Classifier: Environment :: Console
|
|
11
|
+
Classifier: Operating System :: MacOS
|
|
12
|
+
Classifier: Operating System :: POSIX :: Linux
|
|
13
|
+
Classifier: Programming Language :: Python :: 3
|
|
14
|
+
Classifier: Topic :: Utilities
|
|
15
|
+
Requires-Python: >=3.11
|
|
16
|
+
Description-Content-Type: text/markdown
|
|
17
|
+
License-File: LICENSE
|
|
18
|
+
Requires-Dist: textual<9,>=8.2.8
|
|
19
|
+
Provides-Extra: test
|
|
20
|
+
Requires-Dist: pytest; extra == "test"
|
|
21
|
+
Requires-Dist: pytest-asyncio; extra == "test"
|
|
22
|
+
Dynamic: license-file
|
|
23
|
+
|
|
24
|
+
# arvre
|
|
25
|
+
|
|
26
|
+
A terminal graph of your Claude Code sessions and their forks.
|
|
27
|
+
|
|
28
|
+
`/resume` shows sessions as a flat list. `arvre` shows them as a tree, so you can see which session was forked from which, when each one was last used, and jump between parents and children.
|
|
29
|
+
|
|
30
|
+
Example output (sample names):
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
● Auth refactor · 3h ago (2026-10-30 19:12) · 2 children
|
|
34
|
+
├─ ● try the other approach · 1h ago (2026-10-30 21:02) · ↑ Auth refactor
|
|
35
|
+
└─ ● spike rename test · 2m ago (2026-10-30 22:08) · ↑ Auth refactor
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
## Install
|
|
39
|
+
|
|
40
|
+
With pipx (macOS and Linux, Python 3.11+):
|
|
41
|
+
|
|
42
|
+
```sh
|
|
43
|
+
pipx install git+https://github.com/victorcarrico/arvre
|
|
44
|
+
```
|
|
45
|
+
|
|
46
|
+
You need Claude Code installed, with `claude` on your `PATH`, for the "open in this terminal" action.
|
|
47
|
+
|
|
48
|
+
## Usage
|
|
49
|
+
|
|
50
|
+
```sh
|
|
51
|
+
arvre # sessions from the current directory, last 24 hours
|
|
52
|
+
arvre --all-time # all sessions, not just the last 24 hours
|
|
53
|
+
arvre --all-projects # sessions from every project
|
|
54
|
+
arvre --style flow # start with the left-to-right flowchart
|
|
55
|
+
arvre --theme ocean # start with a colour theme
|
|
56
|
+
arvre --print # print the tree as text and exit
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
Run `arvre --help` for all options.
|
|
60
|
+
|
|
61
|
+
### Keys
|
|
62
|
+
|
|
63
|
+
| Key | Action |
|
|
64
|
+
| --- | --- |
|
|
65
|
+
| `↑` / `↓` (`k` / `j`) | Move. In compact style, through the tree. In flowchart style, to the next session at the same depth. |
|
|
66
|
+
| `←` / `→` (`h` / `l`) | Go to the parent / first child |
|
|
67
|
+
| `Enter` | Open the action menu for the selected session (or click it twice) |
|
|
68
|
+
| `a` | Toggle last 24 hours / all time |
|
|
69
|
+
| `p` | Toggle this directory / all projects |
|
|
70
|
+
| `v` | Toggle compact / flowchart style |
|
|
71
|
+
| `t` | Cycle colour themes (`classic`, `ocean`, `forest`, `sunset`) |
|
|
72
|
+
| `r` | Reload |
|
|
73
|
+
| `q` | Quit |
|
|
74
|
+
|
|
75
|
+
The action menu offers:
|
|
76
|
+
|
|
77
|
+
- **Open in this terminal**: runs `claude --resume <id>` in the session's directory, and returns to arvre when Claude exits.
|
|
78
|
+
- **Copy command**: copies `cd '<dir>' && claude --resume <id>` to the clipboard, for pasting into another terminal.
|
|
79
|
+
|
|
80
|
+
## How forks are detected
|
|
81
|
+
|
|
82
|
+
Claude Code doesn't record a parent link when you fork a session. A fork is a copy of its parent's messages, so arvre links each session to the earlier session that shares the most message IDs with it. The details are in [docs/forks.md](docs/forks.md).
|
|
83
|
+
|
|
84
|
+
Session data is read from `~/.claude/projects`. This is an undocumented format, so a future Claude Code release could change it.
|
|
85
|
+
|
|
86
|
+
## Development
|
|
87
|
+
|
|
88
|
+
```sh
|
|
89
|
+
python3 -m venv .venv
|
|
90
|
+
.venv/bin/pip install -e ".[test]"
|
|
91
|
+
.venv/bin/python -m pytest
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
## License
|
|
95
|
+
|
|
96
|
+
MIT. See [LICENSE](LICENSE).
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
arvre/__init__.py,sha256=ryMDtJzlnMHjAX55Ig_irn7vpor1dKjVHNZmp3y7v8M,85
|
|
2
|
+
arvre/__main__.py,sha256=-LJMp_IM7ZHZn3dwyVop0bRH1s39OKfoc2UNdRW-EuI,35
|
|
3
|
+
arvre/app.py,sha256=mRBYx5lvhjKbzDhcKB9okc029tQv5EZ4EuDeLgh56p0,8583
|
|
4
|
+
arvre/cli.py,sha256=kLyPaPNBsZ9fqIQ-AAZzxdUfba2JdRm-xTytKxr1umM,2114
|
|
5
|
+
arvre/commands.py,sha256=a_VG4vx28riRwaB-qSzAryODkXbrti4RsKzozQJEKk0,317
|
|
6
|
+
arvre/flowchart.py,sha256=QyzEeLdxFQkbQtMA8C3rdBSOSgxcrYUyloAKaR-QdB0,4949
|
|
7
|
+
arvre/forks.py,sha256=1I24NzeC1uyYNwtfDf_IlLThyfsjiZc5r2tcyyHiyIw,1344
|
|
8
|
+
arvre/graph_view.py,sha256=ygkcjfaP9pYsTsjK1rOQsclSH19ft595PYGM2ezkCew,6384
|
|
9
|
+
arvre/layout.py,sha256=vzT6pZnoEOevVcVadhkcMwNWcsgjiKKpfAwCfUYa1ow,3437
|
|
10
|
+
arvre/scanner.py,sha256=wZk2jFerNiIswTmOBoozp5WC_UDS08lXXmg8WCVj36E,3363
|
|
11
|
+
arvre/themes.py,sha256=7DgW0lA4Dx8ccxDRpPRS-14hmNjdF7q3TmlBOfJfrBM,2025
|
|
12
|
+
arvre-0.1.0.dist-info/licenses/LICENSE,sha256=UbT2l7z_OBJqur-yYSOEgZRDLRNMG_wul-b6Y1vrFa4,1071
|
|
13
|
+
arvre-0.1.0.dist-info/METADATA,sha256=2QEXLZmsVY_dzjq9PiuT0GaiKg8aiN6ZE5UT4Nh5CoA,3379
|
|
14
|
+
arvre-0.1.0.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
|
|
15
|
+
arvre-0.1.0.dist-info/entry_points.txt,sha256=KmXSwuNS-xEG7QSYbhNN47TcPG7O38cr0bHlcl5hODY,41
|
|
16
|
+
arvre-0.1.0.dist-info/top_level.txt,sha256=oZ7d9aSN6h4pScYSGgWCpKFtGQupGIkIQGxE9g0nsGA,6
|
|
17
|
+
arvre-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Victor Carrico
|
|
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 @@
|
|
|
1
|
+
arvre
|