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 ADDED
@@ -0,0 +1,3 @@
1
+ """Terminal graph of Claude Code sessions and their forks."""
2
+
3
+ __version__ = "0.1.0"
arvre/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from arvre.cli import main
2
+
3
+ main()
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,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ arvre = arvre.cli:main
@@ -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