arvre 0.1.0__tar.gz

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-0.1.0/LICENSE ADDED
@@ -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.
arvre-0.1.0/PKG-INFO ADDED
@@ -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).
arvre-0.1.0/README.md ADDED
@@ -0,0 +1,73 @@
1
+ # arvre
2
+
3
+ A terminal graph of your Claude Code sessions and their forks.
4
+
5
+ `/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.
6
+
7
+ Example output (sample names):
8
+
9
+ ```
10
+ ● Auth refactor · 3h ago (2026-10-30 19:12) · 2 children
11
+ ├─ ● try the other approach · 1h ago (2026-10-30 21:02) · ↑ Auth refactor
12
+ └─ ● spike rename test · 2m ago (2026-10-30 22:08) · ↑ Auth refactor
13
+ ```
14
+
15
+ ## Install
16
+
17
+ With pipx (macOS and Linux, Python 3.11+):
18
+
19
+ ```sh
20
+ pipx install git+https://github.com/victorcarrico/arvre
21
+ ```
22
+
23
+ You need Claude Code installed, with `claude` on your `PATH`, for the "open in this terminal" action.
24
+
25
+ ## Usage
26
+
27
+ ```sh
28
+ arvre # sessions from the current directory, last 24 hours
29
+ arvre --all-time # all sessions, not just the last 24 hours
30
+ arvre --all-projects # sessions from every project
31
+ arvre --style flow # start with the left-to-right flowchart
32
+ arvre --theme ocean # start with a colour theme
33
+ arvre --print # print the tree as text and exit
34
+ ```
35
+
36
+ Run `arvre --help` for all options.
37
+
38
+ ### Keys
39
+
40
+ | Key | Action |
41
+ | --- | --- |
42
+ | `↑` / `↓` (`k` / `j`) | Move. In compact style, through the tree. In flowchart style, to the next session at the same depth. |
43
+ | `←` / `→` (`h` / `l`) | Go to the parent / first child |
44
+ | `Enter` | Open the action menu for the selected session (or click it twice) |
45
+ | `a` | Toggle last 24 hours / all time |
46
+ | `p` | Toggle this directory / all projects |
47
+ | `v` | Toggle compact / flowchart style |
48
+ | `t` | Cycle colour themes (`classic`, `ocean`, `forest`, `sunset`) |
49
+ | `r` | Reload |
50
+ | `q` | Quit |
51
+
52
+ The action menu offers:
53
+
54
+ - **Open in this terminal**: runs `claude --resume <id>` in the session's directory, and returns to arvre when Claude exits.
55
+ - **Copy command**: copies `cd '<dir>' && claude --resume <id>` to the clipboard, for pasting into another terminal.
56
+
57
+ ## How forks are detected
58
+
59
+ 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).
60
+
61
+ Session data is read from `~/.claude/projects`. This is an undocumented format, so a future Claude Code release could change it.
62
+
63
+ ## Development
64
+
65
+ ```sh
66
+ python3 -m venv .venv
67
+ .venv/bin/pip install -e ".[test]"
68
+ .venv/bin/python -m pytest
69
+ ```
70
+
71
+ ## License
72
+
73
+ MIT. See [LICENSE](LICENSE).
@@ -0,0 +1,39 @@
1
+ [build-system]
2
+ requires = ["setuptools>=77"]
3
+ build-backend = "setuptools.build_meta"
4
+
5
+ [project]
6
+ name = "arvre"
7
+ version = "0.1.0"
8
+ description = "Terminal graph of Claude Code sessions and their forks"
9
+ readme = "README.md"
10
+ license = "MIT"
11
+ license-files = ["LICENSE"]
12
+ requires-python = ">=3.11"
13
+ authors = [{ name = "Victor Carrico" }]
14
+ keywords = ["claude", "claude-code", "tui", "sessions", "fork"]
15
+ classifiers = [
16
+ "Environment :: Console",
17
+ "Operating System :: MacOS",
18
+ "Operating System :: POSIX :: Linux",
19
+ "Programming Language :: Python :: 3",
20
+ "Topic :: Utilities",
21
+ ]
22
+ dependencies = ["textual>=8.2.8,<9"]
23
+
24
+ [project.optional-dependencies]
25
+ test = ["pytest", "pytest-asyncio"]
26
+
27
+ [project.urls]
28
+ Homepage = "https://github.com/victorcarrico/arvre"
29
+ Issues = "https://github.com/victorcarrico/arvre/issues"
30
+
31
+ [project.scripts]
32
+ arvre = "arvre.cli:main"
33
+
34
+ [tool.setuptools.packages.find]
35
+ where = ["src"]
36
+
37
+ [tool.pytest.ini_options]
38
+ testpaths = ["tests"]
39
+ asyncio_default_fixture_loop_scope = "function"
arvre-0.1.0/setup.cfg ADDED
@@ -0,0 +1,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -0,0 +1,3 @@
1
+ """Terminal graph of Claude Code sessions and their forks."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from arvre.cli import main
2
+
3
+ main()
@@ -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
@@ -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()
@@ -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}"