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 +21 -0
- arvre-0.1.0/PKG-INFO +96 -0
- arvre-0.1.0/README.md +73 -0
- arvre-0.1.0/pyproject.toml +39 -0
- arvre-0.1.0/setup.cfg +4 -0
- arvre-0.1.0/src/arvre/__init__.py +3 -0
- arvre-0.1.0/src/arvre/__main__.py +3 -0
- arvre-0.1.0/src/arvre/app.py +236 -0
- arvre-0.1.0/src/arvre/cli.py +63 -0
- arvre-0.1.0/src/arvre/commands.py +12 -0
- arvre-0.1.0/src/arvre/flowchart.py +176 -0
- arvre-0.1.0/src/arvre/forks.py +37 -0
- arvre-0.1.0/src/arvre/graph_view.py +184 -0
- arvre-0.1.0/src/arvre/layout.py +117 -0
- arvre-0.1.0/src/arvre/scanner.py +113 -0
- arvre-0.1.0/src/arvre/themes.py +76 -0
- arvre-0.1.0/src/arvre.egg-info/PKG-INFO +96 -0
- arvre-0.1.0/src/arvre.egg-info/SOURCES.txt +22 -0
- arvre-0.1.0/src/arvre.egg-info/dependency_links.txt +1 -0
- arvre-0.1.0/src/arvre.egg-info/entry_points.txt +2 -0
- arvre-0.1.0/src/arvre.egg-info/requires.txt +5 -0
- arvre-0.1.0/src/arvre.egg-info/top_level.txt +1 -0
- arvre-0.1.0/tests/test_app.py +134 -0
- arvre-0.1.0/tests/test_core.py +127 -0
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,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}"
|