Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
114 changes: 110 additions & 4 deletions CLAUDE.md

Large diffs are not rendered by default.

2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ uv run fujimoto
After making changes, reinstall to test the global `fujimoto` command:

```sh
uv tool install --force --reinstall .
uv tool install --force --reinstall --no-cache .
```

Both `--force` and `--reinstall` are required — `--force` alone reuses cached wheel builds and won't pick up code changes.
Expand Down
52 changes: 49 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -56,10 +56,12 @@ uv tool install git+https://github.com/jongracecox/fujimoto.git
Or install from a local clone:

```sh
uv tool install --force --reinstall /path/to/this/repo
uv tool install --force --reinstall --no-cache /path/to/this/repo
```

Re-run with `--force --reinstall` after local code changes to pick up updates.
Re-run with `--force --reinstall --no-cache` after local code changes to pick
up updates. Without `--no-cache`, uv can reuse the previous build and report
success while installing nothing new.

## Configuration

Expand Down Expand Up @@ -375,6 +377,10 @@ the mode then sticks until you change it or restart.
| `Ctrl+R` / `Ctrl+T` / `Ctrl+I` | Toggle regex / message-text-vs-raw / match-case |
| `Escape` | Back to the home screen (the query is kept for next time) |

Backing out of a result's actions puts the highlight back on the result you
opened, so working down a list of results doesn't drop you at the top of it
every time.


### Three Session Types

Expand Down Expand Up @@ -412,11 +418,44 @@ collapses to a single `⚒ 10 tool calls Bash, Read, Edit` row, which opens int
the individual calls, each still folded. Opening a call shows its arguments and
the output it produced — the result belongs to the call rather than being a row
of its own. Tab to a row and press Enter, or click it, to expand. Long tool inputs and results are clipped, and sub-agent
(sidechain) entries are omitted. Escape goes back — to the search results if
(sidechain) entries are omitted. The viewer carries no hint line of its own —
the footer already names the keys — so the transcript gets the full height.
Escape goes back — to the search results if
you got there from a transcript search,
otherwise to the home screen. When a session has more than one transcript, a
picker asks which to read.

If a transcript turns out to hold an entry shape fujimoto doesn't recognise, the
viewer says so and shows the raw log instead of refusing to open it. The raw
view searches exactly like the normal one.

#### Searching within the transcript — `/`

Press `/` inside the viewer to search the conversation you are reading. Every
match is highlighted in place, `n` steps to the next one and `N` to the
previous, wrapping at either end; the status line above the transcript tracks
where you are (`3 of 11 matching messages`). A match buried in a folded tool
call opens that fold, so nothing is highlighted where you can't reach it.

`Ctrl+R` and `Ctrl+I` toggle regex and case exactly as they do in the transcript
search, and the current settings are shown alongside the match count. (`Ctrl+T`
has no meaning here — the transcript has already been parsed into messages, so
there is no raw JSON left to scan.) `Enter` hands focus to the transcript, where
`n`/`N` and the arrow keys work; the first `Escape` clears the search, and the
next one leaves the viewer.

Searching runs in the background and only repaints the lines whose matches
changed, so the transcript never rebuilds under you and the query box keeps
focus while you type.

| Key | While reading a session log |
|-----|-----------------------------|
| `/` | Search this transcript |
| `n` / `N` | Next / previous match |
| `Ctrl+R` / `Ctrl+I` | Toggle regex / match-case |
| `Enter` (in the box) | Move focus to the transcript |
| `Escape` | Clear the search, then leave the viewer |

### Fork Session

**Fork session** branches a conversation and the filesystem in one step: it
Expand Down Expand Up @@ -484,10 +523,17 @@ These options are set per-session and don't affect your global tmux config.
| `/` | Filter sessions by name (home screen) |
| `s` | Search inside session transcripts (home screen) |
| `r` | Refresh the session list (home screen) |
| `n` / `N` | Next / previous match (session log viewer) |
| `Escape` | Back (or quit from home; clears an active filter first) |
| `q` | Quit |
| Arrow keys | Navigate |

The footer only lists the keys that do something in the view you are in — `s`
and `r` disappear once you leave the home screen, the `Ctrl+R`/`Ctrl+T`/`Ctrl+I` mode
toggles appear only while you are searching, `n`/`N` only when a log search has
matches to step through, and `/` is labelled *Filter* on the home screen but
*Search* while you are reading a session log.

## Troubleshooting (`--debug`)

If fujimoto misbehaves — a session doesn't appear, a Claude state indicator
Expand Down
2 changes: 2 additions & 0 deletions src/fujimoto/claude/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
get_claude_projects_dir,
get_sessions_for_path,
parse_session,
read_raw_transcript,
read_transcript,
session_dirs_for_path,
)
Expand Down Expand Up @@ -47,6 +48,7 @@
"iter_hits",
"list_session_logs",
"parse_session",
"read_raw_transcript",
"read_transcript",
"search_log",
"session_dirs_for_path",
Expand Down
40 changes: 40 additions & 0 deletions src/fujimoto/claude/log_parser.py
Original file line number Diff line number Diff line change
Expand Up @@ -588,6 +588,46 @@ def _block_text(block: dict) -> str:
return ""


# How much of a raw JSONL line the fallback view keeps. A single entry can be
# a megabyte of tool output; the fallback exists to be read, not to be complete.
_MAX_RAW_LINE_CHARS = 2000


def read_raw_transcript(jsonl_path: Path) -> list[str]:
"""The log's lines as written, for when structuring them fails.

Claude's entry shapes evolve, so a log the parser chokes on is a question
of when rather than if. Falling back to the bytes keeps such a transcript
readable (and searchable) instead of turning it into an error screen.
Raises ClaudeLogError if the file cannot be read at all — there is nothing
to fall back *to* in that case. Blank lines are dropped and very long ones
clipped; see `TestReadRawTranscript` for the details.
"""
try:
text = jsonl_path.read_text(errors="replace")
except OSError as e:
raise ClaudeLogError(f"Cannot read {jsonl_path}: {e}")

lines: list[str] = []
clipped = 0
for raw in text.splitlines():
line = raw.strip()
if not line:
continue
if len(line) > _MAX_RAW_LINE_CHARS:
line = line[:_MAX_RAW_LINE_CHARS].rstrip() + " …"
clipped += 1
lines.append(line)
debug.log(
"claude.read_raw",
log=debug.rp(jsonl_path),
lines=len(lines),
clipped=clipped,
bytes=len(text),
)
return lines


def read_transcript(jsonl_path: Path) -> list[TranscriptMessage]:
"""Read a Claude session log into an ordered list of messages.

Expand Down
18 changes: 17 additions & 1 deletion src/fujimoto/claude/search.py
Original file line number Diff line number Diff line change
Expand Up @@ -288,11 +288,26 @@ def iter_hits(

batch: list[SearchHit] = []
hits = 0
failed = 0
scanned = 0
for log in logs:
if is_cancelled is not None and is_cancelled():
return
hit = search_log(log, matcher)
try:
hit = search_log(log, matcher)
except Exception as exc:
# Entry shapes evolve, so one log the parser chokes on must not end
# a scan over hundreds of them. Dropping it is the honest outcome:
# the alternative is no results at all — but a transcript silently
# missing from a result set is exactly what a debug log is for.
debug.log(
"search.scan_failed",
log=debug.rp(log),
error=type(exc).__name__,
detail=debug.rv(str(exc)),
)
failed += 1
hit = None
scanned += 1
if hit is not None:
batch.append(hit)
Expand All @@ -309,6 +324,7 @@ def iter_hits(
logs=len(logs),
scanned=scanned,
hits=hits,
failed=failed,
mode=matcher.mode,
)

Expand Down
Loading
Loading