diff --git a/CLAUDE.md b/CLAUDE.md index 248ae73..2f8f8b4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -11,7 +11,7 @@ uv run pytest # Run tests with coverage uv run nox # Run tests across all supported Python versions uv run nox -s tests-3.14 # Run tests against a single Python version uv run nox -s tests_textual # Run dependency-pin matrix (textual) -uv tool install --force --reinstall . # Install globally (re-run after code changes) +uv tool install --force --reinstall --no-cache . # Install globally (re-run after code changes) ``` ## Environment Variables (all optional) @@ -384,6 +384,7 @@ pydantic): - `parse_session(jsonl_path)` — reads JSONL, tracks last meaningful (non-sidechain) entry, derives state. Skips lines that parse to a non-dict (a bare list or string is valid JSON but has no entry type), which previously raised `AttributeError` out of `get_sessions_for_path` and into the home screen. - `get_sessions_for_path(project_path)` — encodes path, globs `*.jsonl`, returns sorted sessions - `TranscriptMessage` — frozen dataclass: `role`, `text`, `timestamp`, `tool_id`. `role` is one of `user`, `assistant`, `thinking`, `tool_use`, `tool_result` — the last three come from *content blocks*, not entry types. `tool_id` carries a `tool_use`'s `id` and a `tool_result`'s `tool_use_id`, so the viewer can pair a call with its own reply +- `read_raw_transcript(jsonl_path)` — the log's lines as written (blank lines dropped, each clipped to `_MAX_RAW_LINE_CHARS`), for the viewer's fallback when `read_transcript` meets an entry shape it doesn't know. Raises `ClaudeLogError` only when the file itself cannot be read - `read_transcript(jsonl_path)` — reads a log into an ordered `list[TranscriptMessage]` for the read-only viewer. Skips sidechain (sub-agent), meta and unrecognized entries; clips tool inputs/results to 20 lines / 2000 chars (`_clip`) since whole-file payloads are unreadable in a TUI **`claude/search.py`** — Full-text search over transcript *contents* (as opposed @@ -400,10 +401,11 @@ responsible for running it off the event loop. - `_collect(source, matcher, count, snippets)` counts every match but skips snippetting one that falls inside a window already emitted — so `MAX_SNIPPETS` buys three *different* places rather than three near-identical views of one dense paragraph. Counts are unaffected. **`cli.py`** — Textual TUI with async view management: +- `LogBody` — frozen dataclass `(widget, text, folds)`: one highlightable body in the log viewer, its source text, and the `Collapsible`s that hide it. `Spans` is the alias for its `(start, end)` match offsets - `SessionInfo` — dataclass for session state (type, project, path, tmux name, active status, claude_session_id, claude_state, is_fork) - `LaunchTarget` — `NamedTuple` describing what `main()` should launch: `(project, working_dir, tmux_name, session_type, resume_session_id, forked_from_session_id, forked_from_worktree)`. A `NamedTuple` rather than a dataclass so existing index-based access keeps working. - `SessionApp` — main app class with CSS styling -- Module-level helpers: `_claude_state_label(state)`, `_relative_time(dt)`, `_get_claude_sessions(root, worktrees)`, `_is_fork_worktree(path)`, `_build_fork_system_prompt(project, working_dir, parent_worktree, base_branch)`, `_fit_snippet(snippet, max_width)` / `_render_snippet(snippet, max_width)` (search-result snippet rendering — see the `Content.assemble` gotcha) +- Module-level helpers: `_claude_state_label(state)`, `_relative_time(dt)`, `_get_claude_sessions(root, worktrees)`, `_is_fork_worktree(path)`, `_build_fork_system_prompt(project, working_dir, parent_worktree, base_branch)`, `_fit_snippet(snippet, max_width)` / `_render_snippet(snippet, max_width)` (search-result snippet rendering — see the `Content.assemble` gotcha), `_match_spans(text, matcher)` / `_highlight(text, spans)` / `_log_body(text, folds)` (log-viewer match highlighting, split so the regex half can run in a worker thread and only the `Content` half touches widgets), `_tool_collapsible(title, …)` (a `Collapsible` whose title is `Content`, never markup) - Instance helpers: `_build_session_label(session, state_suffix)` — the single source of truth for session row text (including the 🍴 fork marker), used by `_show_home`'s render of direct *and* worktree rows and by `_poll_session_states` for in-place updates; `_build_claude_session_items(sessions, prefix)` — shared row rendering for the resume (`rp-*`), fork (`fp-*`) and log-viewer (`lp-*`) pickers; `_matching_worktree(path)` — the project worktree a path refers to, compared by `resolve()`; `_direct_session_cwd(tmux_name)` — where a `direct-N` row really runs and on what branch (memoized in `_direct_cwd_cache`); `_resume_target(project, cwd, session=None)` — the tmux name and session type a resume should use - Views: home (sessions list), session actions submenu, terminate/stop prompt (`#terminate-prompt`, opened by a pending `close` from `Ctrl-A x`; Terminate / Stop / Cancel with Terminate highlighted so Enter matches the `confirm-before` it replaces, and Cancel re-attaching via `_launch_target` so it costs nothing), finish flow, confirm dialog, create form, branch select (3 options), branch picker (filterable list), fork title form, fork branch select, fork session picker, session log picker + read-only log viewer, conflict resolution, project switcher (with autocomplete filter), tmux install, error - Transcript search (`s`) — see the dedicated section below. @@ -458,11 +460,99 @@ responsible for running it off the event loop. Collapsible `padding-bottom` and `border-top`, which otherwise read as gaps between messages, and `.log-tool-run .log-tool` drops the top margin again inside an opened run, where the calls are a list rather than separate - messages. `#main` is already a `VerticalScroll`, so focusing it gives + messages. The messages live in their own `VerticalScroll` (`#log-messages`) + inside `#log-panel` rather than directly in `#main`, so the header, the `/` + box and the hint stay put while the transcript scrolls; focusing it gives arrow/page/end scrolling for free, and Escape falls through to `action_go_back`, which returns to the search results when the menu came from a search and to the home screen otherwise. Nothing is launched — this path never sets `_launch_target`. +- **Log viewer search (`/`)** — the same three-axis matcher as `s`, applied to + the one transcript on screen. `_show_session_log` reads and parses the log + once; `_render_log_view` mounts the panel once. **Searching never comes back + through either.** Mechanics worth knowing: + - **Nothing is remounted for a query.** `_render_transcript(messages)` returns + `(widgets, bodies)` where a `LogBody` is `(widget, text, folds)` — the + `Static` for one body, its source text, and the `Collapsible`s that hide it. + A query then only calls `Static.update()` on the bodies whose spans changed. + Measured on a real 838-message log: a full rebuild is ~540 ms (visible as + the query box vanishing mid-keystroke), while a rescan is ~1.7 ms of + matching plus ~6 ms of `Content` building. + - **Matching runs in a thread, applying runs on the event loop.** + `_start_log_search` bumps `_log_search_token`, cancels the `log-search` + group and hands `[body.text …]` plus the matcher to `_run_log_search` + (`@work(thread=True, exclusive=True)`), which returns `list[Spans]` — pure + data, no widgets — via `call_from_thread(self._apply_log_search, …)`. As + with the transcript search, **the token is the correctness mechanism**, not + worker cancellation: a scan can already be queued when the query changes. + - **`_match_spans` and `_highlight` are separate for exactly that reason** — + the first is regex work that can happen anywhere, the second builds + `Content` and must not. Zero-width matches are dropped, since there is + nothing to highlight or scroll to. + - **`_apply_log_search` accumulates fold state before applying it.** A fold + opens if *any* body inside it matched, so a run holding one hit and nine + misses would be re-closed by the misses if each body decided alone. Folds + are keyed by `id()` (widget equality is not identity) and assigned only on + a change, since each assignment costs a refresh. Everything with no match + folds back up, so each query starts from the same view. + - **`_log_matches` is the list of matching body `Static`s** (document order, + since `_log_bodies` is). `_step_log_match(±1)` wraps, swaps the + `.log-match-current` class and calls `scroll_to_widget`; starting from `-1` + is what makes the first `n` land on the first match and the first `N` on + the last. `_stop_log_search` (from `_clear_main`) drops the lists and bumps + the token — the entries are live widgets from a removed subtree. + - **`n`/`N` are plain letters, unlike the `s` view's Ctrl chords**, because + the `Input` swallows them while it holds focus — which is also why + `Input.Submitted` moves focus to `#log-messages`, and why Textual withholds + them from the footer while the box is focused. `ContentMode` is not offered: + the transcript is already parsed, so there is no raw JSON to scan. + - **The viewer mounts no `.hint` row**, unlike every other view. `/` and + Escape are already in the footer, and a second strip of grey text below a + screenful of transcript reads as a second footer rather than as help. + - **The query box is built with `select_on_focus=False`.** Textual selects an + `Input`'s whole value on focus by default, which ate the query whenever + focus came back to the box. + - Escape is layered: `_on_key` clears the search when the box has focus, and + `action_go_back` clears it before leaving the view when it does not. + - **Instrumented as `log_search.start` / `.done` / `.dropped`** (plus + `tui.log_view` when the viewer opens). `.done` reports `repainted`, which is + the number that says whether the in-place update is doing its job — a value + close to `bodies` means the skip-unchanged check has stopped working. Its + fields walk every body, so the whole call sits behind `debug.is_enabled()`. +- **Raw fallback for a transcript the parser can't structure.** Claude's entry + shapes evolve, so `_show_session_log` treats an unexpected exception out of + `read_transcript` as "show it anyway": `read_raw_transcript` returns the log's + lines as written (blank lines dropped, each clipped to + `_MAX_RAW_LINE_CHARS`), `_log_parse_error` records `Type: message`, and + `_render_log_view` puts a `#log-raw-warning` banner above one `_log_body` per + line — so `/`, highlighting and `n`/`N` all work unchanged. A `ClaudeLogError` + is still a real error screen: there is nothing to fall back *to* when the file + cannot be read. `search.iter_hits` swallows the same class of failure per log, + so one bad transcript cannot end a scan over hundreds. + Both swallowing paths are the ones `--debug` most needs to see, since neither + is visible in the UI: the viewer logs `claude.transcript_unparsed` **with the + traceback** plus `tui.log_raw_fallback`, and `iter_hits` logs + `search.scan_failed` per log and a `failed=` count on `search.scan phase=done`. + An unknown entry shape is a parser bug to fix, not just a view to degrade. +- **`check_action` makes the footer context-aware.** `s` and `r` show only on + the home screen, the mode toggles only where they toggle something, and `n`/`N` + only when a log search has matches to step through. Two details: + - **Return `False`, never `None`.** Textual's polarity is the opposite of what + it reads like: `False` drops a binding from the footer entirely, `None` + shows it greyed out. + - **`/` is two bindings on one key** — `search` ("Filter") and `log_search` + ("Search") — because a binding's description is static, and one action with + a switch inside it would label the log viewer's search "Filter". Textual's + `_check_bindings` walks every binding for a key and falls through the ones + whose `check_action` is false, so exactly one fires. + - **The mode toggles are `show=True`.** They were hidden from the footer back + when they would have appeared on every screen; `check_action` now confines + them to the two views they act on, and a Ctrl chord — unlike `n`/`N` — + still reaches the footer while the query `Input` holds focus, which is + exactly when someone needs to discover them. + - It is only consulted when something calls `refresh_bindings()`, which + `_clear_main`, `_show_home`, `_show_session_search`, `_render_log_view` and + `_apply_log_search` all do. - Finish flow: Push & Create PR (background Claude), Cherry-pick to base, Discard & Delete - Open terminal flow: sub-menu with "This window" (default; uses Textual's `App.suspend()` to pause the TUI, then runs `subprocess.run([$SHELL], cwd=session.path)` as a child process — when the user types `exit`, the TUI resumes on the session actions menu) and "New window" (spawns a new iTerm/Terminal/Linux emulator window via `open_terminal()`) - All view transitions are `async` — `await _clear_main()` then `await mount()` @@ -493,7 +583,15 @@ Three custom exception types, all caught in `main()`: - **TUI loop with tmux detach**: The TUI runs in a `while True` loop. After tmux detach (subprocess.run returns), the loop restarts and the TUI reappears. The loop breaks when the user quits without selecting a session. - **Per-session tmux config**: Prefix defaults to `Ctrl-B` (tmux's standard default; configurable via `FUJIMOTO_TMUX_PREFIX`), status bar with shortcut hints — all set via `tmux set-option -t` so the user's global config is untouched. The attach flow is silent (no pre-attach banner) to reduce noise when launching sessions repeatedly. -- **Global install via `uv tool`**: Requires `--force --reinstall` to rebuild the wheel from source. Plain `--force` reuses cached builds. +- **Global install via `uv tool` needs `--no-cache`**: `--force` alone reuses a + cached build, and so, in practice, does `--force --reinstall` — uv's build + cache is keyed on the derived version, and `hatch-vcs` gives every commit in + a run of untagged work the same `X.Y.Z.dev0` string. The install then reports + success while leaving the previous wheel in place, which looks exactly like a + feature that doesn't work. Always + `uv tool install --force --reinstall --no-cache .`, and verify with + `fujimoto --version` (the `.devN` suffix moves) or by grepping the installed + `site-packages/fujimoto/cli.py` for something the change added. - **Remembering sessions across a restart**: `session_state.py` records every session as open at launch — in `main()`, **before** `launch_claude_in_tmux` blocks on the attach, so a host that dies mid-session still has a record to restore from. `_init_git_info` loads the pruned state into `_open_sessions`; `_stopped_records()` derives the open-but-not-running set for the current project; `_build_home_items` renders those in the *sessions* section as 🟠 and excludes them from *inactive worktrees*. A **Restore N stopped sessions** row relaunches them all via `create_session` (detached, resuming each path's latest transcript, attaching to none) — deliberately without `_apply_worktree_config`, which would run N `init` blocks up front; config still runs when the user actually attaches. `_end_session(session, terminate=...)` is the single handler behind both menu items and both outcomes of the `Ctrl-A x` prompt; it tolerates a `kill_session` failure only when `session_exists` confirms the session is already gone (otherwise marking a live session closed would hide it). `_do_delete_worktree` and `on_rename_submitted` keep the store honest via `mark_closed` / `rename`. - **Session metadata**: `.fujimoto/meta.json` stored in worktree directory records the base branch for cherry-pick targeting, the `source_root` (main repo) for project-config source resolution, and — for forks — `forked_from_session_id` plus `forked_from_worktree`. The `.fujimoto/` directory contains a `.gitignore` with `*` so its contents are automatically ignored by git. - **Project config (`.fujimoto.yaml`)**: An optional, committed per-project file (`project_config.py`) declaring files to copy/link into a worktree and init commands to run. Applied centrally in `main()`'s launch loop (parent process, **before** `tmux attach`) by `_apply_worktree_config(working_dir)`, for **every** worktree connection mode (new, reconnect-to-live, relaunch/resume) — so copy/link/init run on each connect, not just creation. `_do_create_and_launch` no longer applies config; it only creates the worktree and stores meta. Key mechanics: @@ -628,6 +726,13 @@ Three custom exception types, all caught in `main()`: `_show_session_search(restore=True)` — re-rendering the collected `_search_hits` rather than throwing a completed scan away. Opening the menu from the home screen clears the flag, so that path still returns home. + - **A restore focuses the results list, not the query box, and puts the + highlight back on the row that was opened** (`_search_selected_index`, + recovered from the `sr-{index}` row id and cleared by `_clear_search_results` + when a fresh scan starts). Focusing the input on the way back meant every + Escape dropped the user at the top of the list, which makes walking a set of + results one at a time impossible. A restore with no hits still focuses the + input, since there is nothing to highlight. - **Home name filter rebuilds rows, not the screen**: `_show_home` mounts the panel; all row construction (and the population of `_session_map` / `_claude_state_snapshot`) lives in `_build_home_items()`, which applies the @@ -823,6 +928,7 @@ Things discovered during development that are easy to forget: - **Claude encodes a project path by replacing `/` *and* `.` with `-`.** `/repo/.fujimoto/worktrees/x` is stored as `-repo--fujimoto-worktrees-x` — the double hyphen is the `/` plus the `.`. Encoding only the slashes made `get_sessions_for_path` miss every transcript for a worktree under the default `/.fujimoto/worktrees/` root (used whenever `FUJIMOTO_WORKTREE_ROOT` is unset), which showed up as "Resume previous session" being absent from every inactive worktree — and, from the same lookup, no Claude state icons, no fork, no session log and no search hits. Verify the rule against real data rather than assuming: read the `cwd` out of a transcript and compare it to the directory name holding it. Claude also honours `CLAUDE_CONFIG_DIR`, which moves `projects/` wholesale. - **`kill_session` raises when the session is already gone**, so any code path that ends a session has to decide what that means. `_end_session` re-raises only if `session_exists` still reports the session — a session that died between being listed and being acted on is already in the state the kill was aiming for, while a kill that genuinely failed must not silently mark a live session closed. - **Never splice arbitrary text into a console-markup string — assemble a `Content` instead.** Search snippets are raw transcript bytes cut at arbitrary offsets, and both of these silently corrupt the row: a fragment ending in `[` swallows the tag that follows it (`[dim]{"a": [[/]` renders the literal text `{"a": [[/]`), and a fragment ending in `\` escapes it (`[dim]path\[/]` renders `path[/]`). `rich.markup.escape` does **not** save you — it only escapes a `[` that still looks like a tag *in the fragment it is given*, so `escape('{"a": [')` returns the string unchanged. `Content.assemble(("text", "style"), ...)` never parses the text at all, resolves `$theme-variables` in the style, and — unlike a nested `[b]` inside `[dim]` — does not inherit the surrounding `dim` into the highlight. See `_render_snippet`. +- **Anything built from transcript text must be `Content`, including a `Collapsible` title.** The `Content.assemble` rule below is usually stated about *bodies*, but a widget's title argument is parsed as markup too: `Collapsible(title=...)` hands the string to `Content.from_text(markup=True)`, so a `⚒ Bash command: until [ "$(gh run list …` title raised `MarkupError: Expected markup value` and took the whole viewer down. The same applies to the viewer's header (a session title or first prompt) and the Claude-session picker rows (`_build_claude_session_items`), which mix a model-written title with a `[dim]` timestamp — that one is `Content.assemble`, since it is styled as well as arbitrary. Rule of thumb: if a string came out of a transcript, it reaches a widget as `Content` or not at all. - **`Static`/`Label` text in tests is read with `str(widget.render())`, not `.renderable`** — Textual 8 dropped the attribute. `render()` returns the *resolved* content, so console markup (`[dim]`, `[b]`) is gone from the string; assert on the plain text. And a `ListItem`'s own children are composed when the item mounts, so `item.query(Label)` needs an `await pilot.pause()` after a non-awaited `ListView.append`. - **A worker's `is_cancelled` is not a synchronisation primitive.** Cancelling a Textual worker (or letting `exclusive=True` supersede it) does not unwind work already queued on the event loop via `call_from_thread`. Anything a worker hands back must carry a generation token the handler checks — see `_search_token`. Bump the token *before* cancelling, so a batch in flight is stale from the moment the decision is made. - **OSC escape writes during a Textual run must go to `sys.__stdout__`, not `sys.stdout`.** Textual replaces `sys.stdout` with an internal capture while the app runs, so an OSC sequence (e.g. the `set_terminal_title` iTerm2/window-title escape) written to `sys.stdout` from inside a running app — such as `_init_git_info` updating the title on project switch — never reaches the terminal. `sys.__stdout__` stays connected to the real tty, so writing there works both before and during `app.run()`. This is why the session-manager title set at `main()` (pre-run) worked but the in-app update initially did not. diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index 5deeafd..865bdaf 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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. diff --git a/README.md b/README.md index d559bfe..41468db 100644 --- a/README.md +++ b/README.md @@ -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 @@ -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 @@ -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 @@ -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 diff --git a/src/fujimoto/claude/__init__.py b/src/fujimoto/claude/__init__.py index 86ef35d..1d02c7f 100644 --- a/src/fujimoto/claude/__init__.py +++ b/src/fujimoto/claude/__init__.py @@ -11,6 +11,7 @@ get_claude_projects_dir, get_sessions_for_path, parse_session, + read_raw_transcript, read_transcript, session_dirs_for_path, ) @@ -47,6 +48,7 @@ "iter_hits", "list_session_logs", "parse_session", + "read_raw_transcript", "read_transcript", "search_log", "session_dirs_for_path", diff --git a/src/fujimoto/claude/log_parser.py b/src/fujimoto/claude/log_parser.py index eacf541..48ee810 100644 --- a/src/fujimoto/claude/log_parser.py +++ b/src/fujimoto/claude/log_parser.py @@ -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. diff --git a/src/fujimoto/claude/search.py b/src/fujimoto/claude/search.py index 95c2b2a..6f806a2 100644 --- a/src/fujimoto/claude/search.py +++ b/src/fujimoto/claude/search.py @@ -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) @@ -309,6 +324,7 @@ def iter_hits( logs=len(logs), scanned=scanned, hits=hits, + failed=failed, mode=matcher.mode, ) diff --git a/src/fujimoto/cli.py b/src/fujimoto/cli.py index 38000da..b461a97 100644 --- a/src/fujimoto/cli.py +++ b/src/fujimoto/cli.py @@ -7,7 +7,7 @@ import tempfile import textwrap import threading -from dataclasses import dataclass, field +from dataclasses import dataclass, field, replace from datetime import datetime, timezone from pathlib import Path from typing import NamedTuple @@ -41,10 +41,17 @@ SessionState, TranscriptMessage, get_sessions_for_path, + read_raw_transcript, read_transcript, ) from fujimoto.claude import search as claude_search -from fujimoto.claude.search import ContentMode, SearchError, SearchHit, Snippet +from fujimoto.claude.search import ( + ContentMode, + Matcher, + SearchError, + SearchHit, + Snippet, +) from fujimoto.config import ( ConfigError, build_worktree_path, @@ -262,6 +269,73 @@ def _render_snippet(snippet: Snippet, max_width: int) -> Content: return Content.assemble(*parts) +# Style applied to a matched substring inside the session log viewer. Same +# `$warning` accent as a search snippet, so a match reads the same in both. +LOG_MATCH_STYLE = "b $warning" + +# Half-open `(start, end)` offsets of the matches inside one transcript body. +Spans = tuple[tuple[int, int], ...] + + +@dataclass(frozen=True) +class LogBody: + """One highlightable body in the log viewer, and what hides it. + + Searching updates these in place rather than rebuilding the transcript: + on a real log a full rebuild costs about half a second, which is a visible + stall between keystrokes. `folds` is the chain of `Collapsible`s a body sits + inside (a call, and the run that call belongs to), so a match can open its + way out of both. + """ + + widget: Static + text: str + folds: tuple[Collapsible, ...] = () + + +def _match_spans(text: str, matcher: Matcher | None) -> Spans: + """Where `matcher` hits inside `text`. + + Zero-width matches (`x*` against a run with no `x`) are dropped: they + highlight nothing and there is nothing to scroll to, so counting them would + only inflate the tally. + """ + if matcher is None: + return () + return tuple( + (m.start(), m.end()) + for m in matcher.pattern.finditer(text) + if m.end() > m.start() + ) + + +def _highlight(text: str, spans: Spans) -> Content: + """Transcript text as `Content`, with `spans` picked out. + + Assembled from `(text, style)` pairs rather than markup for the reason + snippets are: transcript text is arbitrary bytes and a stray `[` would be + parsed as a tag. + """ + if not spans: + return Content(text) + + parts: list[str | tuple[str, str]] = [] + cursor = 0 + for start, end in spans: + if start > cursor: + parts.append(text[cursor:start]) + parts.append((text[start:end], LOG_MATCH_STYLE)) + cursor = end + if cursor < len(text): + parts.append(text[cursor:]) + return Content.assemble(*parts) + + +def _log_body(text: str, folds: tuple[Collapsible, ...] = ()) -> LogBody: + """An unhighlighted transcript body, ready for a query to be applied.""" + return LogBody(Static(Content(text), classes="log-body"), text, folds) + + # How each transcript role is labelled and styled in the log viewer. _TRANSCRIPT_ROLES: dict[str, tuple[str, str]] = { "user": ("You", "log-user"), @@ -329,35 +403,56 @@ def _result_heading(result: TranscriptMessage) -> str: return f"↳ {count} {'line' if count == 1 else 'lines'}" +def _tool_collapsible( + title: str, *contents: Widget, collapsed: bool, classes: str +) -> Collapsible: + """A `Collapsible` whose title is never parsed as console markup. + + Textual annotates `title` as `str` but hands it to + `Content.from_text(markup=True)`, so a title cut from transcript bytes + (`⚒ Bash command: until [ "$(gh run list …`) opens a tag it cannot close + and raises `MarkupError`, taking the whole viewer down. `Content` is what + the widget actually wants — the annotation is just narrower than the + runtime contract. + """ + return Collapsible( + *contents, + title=Content(title), # ty: ignore[invalid-argument-type] + collapsed=collapsed, + classes=classes, + ) + + def _tool_widget( msg: TranscriptMessage, result: TranscriptMessage | None = None -) -> Collapsible: +) -> tuple[Collapsible, list[LogBody]]: """One folded tool call, with its result inside if it has one. The result belongs to the call, so it is shown as part of that expansion rather than as a row of its own — one thing to open, not two. + + Returns the widget and its bodies, each carrying the fold it sits inside so + a later search can open its way out to a match. """ if msg.role == "tool_result": - return Collapsible( - Static(Content(msg.text), classes="log-body"), - title=_tool_summary(msg), - collapsed=True, - classes="log-tool", + body = _log_body(msg.text) + fold = _tool_collapsible( + _tool_summary(msg), body.widget, collapsed=True, classes="log-tool" ) + return fold, [replace(body, folds=(fold,))] # The title already names the tool, so the body drops that first line and # keeps the arguments, which the title only shows clipped. - body = msg.text.partition("\n")[2] or msg.text - contents: list[Widget] = [Static(Content(body), classes="log-body")] + bodies = [_log_body(msg.text.partition("\n")[2] or msg.text)] + contents: list[Widget] = [bodies[0].widget] if result is not None: contents.append(Static(_result_heading(result), classes="log-result-heading")) - contents.append(Static(Content(result.text), classes="log-body")) - return Collapsible( - *contents, - title=_tool_summary(msg), - collapsed=True, - classes="log-tool", + bodies.append(_log_body(result.text)) + contents.append(bodies[-1].widget) + fold = _tool_collapsible( + _tool_summary(msg), *contents, collapsed=True, classes="log-tool" ) + return fold, [replace(body, folds=(fold,)) for body in bodies] def _pair_results( @@ -401,7 +496,9 @@ def _pair_results( return pairs -def _render_transcript(messages: list[TranscriptMessage]) -> list[Widget]: +def _render_transcript( + messages: list[TranscriptMessage], +) -> tuple[list[Widget], list[LogBody]]: """Build the widgets for a transcript, styled by role. Prose (you, Claude, thinking) is rendered as a role header plus a body. @@ -419,16 +516,22 @@ def _render_transcript(messages: list[TranscriptMessage]) -> list[Widget]: Bodies are `Content` rather than markup for the same reason snippets are: transcript text is arbitrary bytes, and a stray `[` or trailing backslash would otherwise be parsed as (or corrupt) a console markup tag. + + The second return value is every body in document order, which is what the + `/` search updates in place — it never rebuilds this. """ widgets: list[Widget] = [] + bodies: list[LogBody] = [] index = 0 while index < len(messages): msg = messages[index] if msg.role not in _TOOL_ROLES: label, css = _TRANSCRIPT_ROLES.get(msg.role, (msg.role, "log-assistant")) + body = _log_body(msg.text) widgets.append(Static(label, classes=f"log-role {css}")) - widgets.append(Static(Content(msg.text), classes="log-body")) + widgets.append(body.widget) + bodies.append(body) index += 1 continue @@ -438,19 +541,25 @@ def _render_transcript(messages: list[TranscriptMessage]) -> list[Widget]: run = messages[index:end] index = end - pairs = _pair_results(run) + calls = [_tool_widget(call, result) for call, result in _pair_results(run)] if sum(1 for m in run if m.role == "tool_use") > 1: - widgets.append( - Collapsible( - *[_tool_widget(call, result) for call, result in pairs], - title=_tool_run_title(run), - collapsed=True, - classes="log-tool log-tool-run", - ) + outer = _tool_collapsible( + _tool_run_title(run), + *[widget for widget, _ in calls], + collapsed=True, + classes="log-tool log-tool-run", + ) + widgets.append(outer) + # A body inside a run is hidden twice over, so it records both folds. + bodies.extend( + replace(body, folds=body.folds + (outer,)) + for _, call_bodies in calls + for body in call_bodies ) else: - widgets.extend(_tool_widget(call, result) for call, result in pairs) - return widgets + widgets.extend(widget for widget, _ in calls) + bodies.extend(body for _, call_bodies in calls for body in call_bodies) + return widgets, bodies def _is_fork_worktree(worktree: Path) -> bool: @@ -624,11 +733,37 @@ def _get_claude_sessions( } #log-panel { - height: auto; + height: 1fr; padding: 1 2; border: round $primary; } +/* The messages scroll; the header, search box and hint stay put. */ +#log-messages { + height: 1fr; +} + +#log-search { + margin-bottom: 1; +} + +#log-raw-warning { + color: $warning; + height: auto; + margin-bottom: 1; +} + +#log-search-status { + color: $text-muted; + height: auto; + margin-bottom: 1; +} + +/* The match `n`/`N` last landed on, so it is findable in a dense body. */ +.log-match-current { + background: $accent 30%; +} + #log-panel .form-label { margin-bottom: 0; text-style: bold; @@ -1010,17 +1145,30 @@ class SessionApp(App): Binding("ctrl+c", "quit", "Quit", show=False), Binding("escape", "go_back", "Back", show=True), Binding("d", "dismiss_update", "Dismiss update", show=False), + # `/` means two different things, so it is two bindings on one key — + # `check_action` disables whichever doesn't apply, and Textual falls + # through to the next binding for the key. One action with a switch + # inside it could not give the footer a per-view label. Binding("slash", "search", "Filter", show=True), + Binding("slash", "log_search", "Search", show=True), Binding("s", "session_search", "Search transcripts", show=True), Binding("r", "refresh", "Refresh", show=True), - # Mode toggles for the transcript search. They have to fire while the - # query Input holds focus, so they are Ctrl chords (which Input leaves - # alone) rather than plain letters. - Binding("ctrl+r", "toggle_search_regex", "Regex", show=False), - Binding("ctrl+t", "toggle_search_mode", "Raw/text", show=False), + # Mode toggles for the transcript search and the log viewer's own. They + # have to fire while the query Input holds focus, so they are Ctrl + # chords (which Input leaves alone) rather than plain letters — and + # unlike a plain letter, Textual still shows a chord in the footer with + # the box focused, which is exactly when you want to see them. + # `check_action` confines them to the views they act on. + Binding("ctrl+r", "toggle_search_regex", "Regex", show=True), + Binding("ctrl+t", "toggle_search_mode", "Raw/text", show=True), # Ctrl+I is the same byte as Tab in a legacy terminal; it only arrives # distinctly under the kitty keyboard protocol, which Textual requests. - Binding("ctrl+i", "toggle_search_case", "Match case", show=False), + Binding("ctrl+i", "toggle_search_case", "Match case", show=True), + # Session-log viewer only: step through the `/` matches. Plain letters + # are safe here because the query Input swallows them while it has + # focus, and `check_action` hides both outside the viewer. + Binding("n", "log_next_match", "Next match", show=True), + Binding("N", "log_prev_match", "Previous match", show=True), ] def __init__( @@ -1108,6 +1256,42 @@ def __init__( # Set when the session-actions menu was opened from a search result, so # Cancel/Escape returns to the results instead of the home screen. self._actions_from_search: bool = False + # Which result row the actions menu was opened from, so backing out + # lands the highlight there instead of at the top of the list. + self._search_selected_index: int | None = None + + # -- Session log viewer, and its own `/` search -- + self._on_log: bool = False + self._log_session: ClaudeSession | None = None + self._log_messages: list[TranscriptMessage] = [] + # Set together when `read_transcript` fails on a shape it doesn't know: + # the log's lines as written, and what went wrong. `_log_parse_error` + # being non-None is what puts the viewer in raw mode. + self._log_raw_lines: list[str] = [] + self._log_parse_error: str | None = None + # `_log_searching` is whether the query box is armed; `_log_query` is + # what is highlighted. Regex/case mirror the transcript-search toggles + # but are tracked separately, since the two views are used differently. + self._log_searching: bool = False + self._log_query: str = "" + self._log_regex: bool = False + self._log_case_sensitive: bool = False + self._log_error: str | None = None + # Every highlightable body in the open transcript, in document order. + # Searching updates these in place — the viewer is never rebuilt for a + # query, which is what a half-second render would otherwise cost. + self._log_bodies: list[LogBody] = [] + # The spans last applied to each body, so a rescan only repaints what + # actually changed rather than every body in the transcript. + self._log_spans: list[Spans] = [] + # The subset currently matching, plus which one `n`/`N` is sitting on. + self._log_matches: list[Static] = [] + self._log_match_index: int = -1 + self._log_debounce: Timer | None = None + # Same role as `_search_token`: a scan's result can land after the query + # has moved on, so the handler checks generation rather than trusting + # worker cancellation. + self._log_search_token: int = 0 def compose(self) -> ComposeResult: yield Header() @@ -1200,12 +1384,60 @@ def _init_git_info(self) -> None: except ConfigError: pass + def check_action(self, action: str, parameters: tuple[object, ...]) -> bool | None: + """Hide bindings that don't apply to the view on screen. + + The footer is the only place most of these keys are advertised, so a + binding that silently does nothing here (`s` in the log viewer, `n` + anywhere else) is worse than absent. Note the polarity: Textual treats + `False` as "hide entirely" and `None` as "show, greyed out" — hiding is + what we want, so this returns `False`, never `None`. + """ + if action == "search": + return self._on_home + if action == "log_search": + return self._on_log + if action == "session_search": + return self._on_home + if action == "refresh": + # `action_refresh` already no-ops off the home screen; this is so + # the footer stops offering it there. + return self._on_home + if action in ("log_next_match", "log_prev_match"): + return self._on_log and bool(self._log_matches) + if action in ("toggle_search_regex", "toggle_search_case"): + return self._on_search or self._on_log + if action == "toggle_search_mode": + return self._on_search + return True + async def _clear_main(self) -> None: self._stop_polling() self._stop_transcript_search() + self._stop_log_search() self._on_home = False main = self.query_one("#main") await main.remove_children() + self.refresh_bindings() + + def _stop_log_search(self) -> None: + """Leave the log viewer: drop the body list and cancel any pending scan. + + The bodies are live widgets, so holding them past a teardown would pin a + removed subtree and let `n` scroll to something no longer mounted. The + token is bumped before the cancel, so a scan already handed back to the + event loop is stale from the moment the decision is made. + """ + self._on_log = False + self._log_bodies = [] + self._log_spans = [] + self._log_matches = [] + self._log_match_index = -1 + if self._log_debounce is not None: + self._log_debounce.stop() + self._log_debounce = None + self._log_search_token += 1 + self.workers.cancel_group(self, "log-search") def _stop_transcript_search(self) -> None: """Leave the search view: cancel the scan and any pending debounce. @@ -1290,6 +1522,7 @@ def _project_config_error(self) -> str | None: async def _show_home(self) -> None: await self._clear_main() self._on_home = True + self.refresh_bindings() self._claude_cache = None main = self.query_one("#main") @@ -1791,7 +2024,7 @@ def _build_home_items(self) -> list[ListItem]: return items async def action_search(self) -> None: - """Arm the home-screen search box (bound to `/`).""" + """Arm the home-screen name filter (bound to `/` on the home screen).""" if not self._on_home or not self.query("#home-search"): return self._searching = True @@ -1903,6 +2136,7 @@ async def _show_session_search(self, *, restore: bool = False) -> None: """ await self._clear_main() self._on_search = True + self.refresh_bindings() if not restore: self._search_hits = [] main = self.query_one("#main") @@ -1928,11 +2162,29 @@ async def _show_session_search(self, *, restore: bool = False) -> None: id="search-panel", ) ) - self.query_one("#search-input", Input).focus() if restore and self._search_hits: count = len(self._search_hits) plural = "s" if count != 1 else "" self._set_search_status(f"[dim]{count} session{plural} matched[/]") + # Coming back from a result's actions menu, put the highlight back + # on that result and focus the list — otherwise walking a set of + # results means being dropped at the top of it every time. + results = self.query_one("#search-results", ListView) + if ( + self._search_selected_index is not None + and self._search_selected_index < len(results) + ): + results.index = self._search_selected_index + results.focus() + debug.log( + "search.restored", + hits=count, + index=self._search_selected_index + if self._search_selected_index is not None + else "none", + ) + else: + self.query_one("#search-input", Input).focus() def _search_status_text(self, detail: str = "") -> str: """The mode line above the results, plus an optional progress detail.""" @@ -1989,7 +2241,17 @@ def _build_search_result_item( return ListItem(*labels, id=item_id) async def action_toggle_search_regex(self) -> None: - """Flip literal/regex matching and rerun the scan (bound to ctrl+r).""" + """Flip literal/regex matching and rerun the scan (bound to ctrl+r). + + Shared with the log viewer's `/` search, which keeps its own flag: the + two views are used differently and a choice in one shouldn't surprise + the other. + """ + if self._on_log: + self._log_regex = not self._log_regex + self._set_log_status() + self._start_log_search() + return if not self._on_search: return self._transcript_regex = not self._transcript_regex @@ -2003,7 +2265,12 @@ async def action_toggle_search_mode(self) -> None: await self._restart_transcript_search() async def action_toggle_search_case(self) -> None: - """Flip case sensitivity and rerun the scan (bound to ctrl+i).""" + """Flip case sensitivity and rerun (bound to ctrl+i, log viewer too).""" + if self._on_log: + self._log_case_sensitive = not self._log_case_sensitive + self._set_log_status() + self._start_log_search() + return if not self._on_search: return self._transcript_case_sensitive = not self._transcript_case_sensitive @@ -2044,6 +2311,7 @@ def on_transcript_search_submitted(self, event: Input.Submitted) -> None: async def _clear_search_results(self) -> None: self._search_hits = [] + self._search_selected_index = None if self.query("#search-results"): await self.query_one("#search-results", ListView).clear() self._search_result_map = {} @@ -2159,6 +2427,9 @@ def _search_failed(self, token: int, message: str) -> None: async def on_search_result_selected(self, event: ListView.Selected) -> None: item_id = event.item.id if item_id and item_id in self._search_result_map: + # Row ids are `sr-{index}` into `_search_hits`, so the index alone + # is enough to restore the highlight when the menu is backed out of. + self._search_selected_index = int(item_id.split("-", 1)[1]) await self._show_session_actions( self._search_result_map[item_id], from_search=True ) @@ -2642,20 +2913,22 @@ def _build_claude_session_items( items: list[ListItem] = [] for i, cs in enumerate(sessions): time_text = _relative_time(cs.last_activity) + # Assembled, not markup: a session title is model-written text and + # a stray `[` in it would otherwise be parsed as a console tag. meta = ( - f"{cs.title} [dim]{time_text}[/]" + Content.assemble(cs.title, " ", (time_text, "dim")) if cs.title - else f"[dim]{time_text}[/]" + else Content.assemble((time_text, "dim")) ) if cs.first_prompt: prompt_lines = _format_prompt_lines(cs.first_prompt, max_width) item = ListItem( - *[Label(ln) for ln in prompt_lines], - Label(meta, markup=True), + *[Label(Content(ln)) for ln in prompt_lines], + Label(meta), id=f"{prefix}-{i}", ) else: - item = ListItem(Label(meta, markup=True), id=f"{prefix}-{i}") + item = ListItem(Label(meta), id=f"{prefix}-{i}") items.append(item) items.append( ListItem(Label("[dim]Cancel[/]", markup=True), id=f"{prefix}-cancel") @@ -2705,41 +2978,384 @@ async def _show_log_picker(self, session: SessionInfo) -> None: self.query_one("#log-picker").focus() async def _show_session_log(self, cs: ClaudeSession) -> None: - """Render a Claude transcript read-only, without launching Claude.""" + """Open a Claude transcript read-only, without launching Claude. + + A log the parser cannot structure falls back to its raw lines with a + banner saying so, rather than an error screen: Claude's entry shapes + evolve, and a transcript you can still read and search beats one you + cannot open at all. A file that cannot be *read* is a real error — + there is nothing to fall back to. + """ + messages: list[TranscriptMessage] = [] + raw_lines: list[str] = [] + parse_error: str | None = None try: messages = read_transcript(cs.jsonl_path) except ClaudeLogError as e: await self._show_error(str(e)) return + except Exception as e: + # The traceback is the report: a shape the parser doesn't know is a + # bug to fix, not just a view to degrade. + debug.log_exception("claude.transcript_unparsed", e) + debug.log( + "tui.log_raw_fallback", + log=debug.rp(cs.jsonl_path), + session=debug.rid(cs.session_id), + error=type(e).__name__, + ) + try: + raw_lines = read_raw_transcript(cs.jsonl_path) + except ClaudeLogError as read_error: + await self._show_error(str(read_error)) + return + parse_error = f"{type(e).__name__}: {e}" + + # A fresh transcript starts unsearched; the regex/case choices persist, + # like the transcript-search toggles do. + self._log_session = cs + self._log_messages = messages + self._log_raw_lines = raw_lines + self._log_parse_error = parse_error + self._log_searching = False + self._log_query = "" + self._log_error = None + await self._render_log_view() + + async def _render_log_view(self) -> None: + """Mount the log viewer for `_log_session`, unhighlighted. + + This runs once per transcript. On a real log building these widgets + costs about half a second, which is why searching never comes back + here: `_apply_log_search` updates the bodies that are already mounted. + + Deliberately no `.hint` row, unlike the other views: everything one + would say is already in the app footer, and a grey strip under a full + screen of transcript reads as a second footer. + """ + cs = self._log_session + if cs is None: # pragma: no cover - only reachable via _show_session_log + return + messages = self._log_messages await self._clear_main() + self._on_log = True main = self.query_one("#main") - header = cs.title or cs.first_prompt or cs.session_id # Widget, not Static: `_render_transcript` mixes in Collapsibles. - widgets: list[Widget] = [ - Label(_first_line(header, 80), classes="form-label"), - Static( - f"{cs.session_id} · {_relative_time(cs.last_activity)}", - classes="session-info", - ), - ] - if messages: - widgets.extend(_render_transcript(messages)) + body: list[Widget] + if self._log_parse_error is not None: + # Raw fallback: one row per line, still built through `_log_body` + # so `/` highlighting and `n`/`N` work exactly as they do above. + self._log_bodies = [_log_body(line) for line in self._log_raw_lines] + body = [b.widget for b in self._log_bodies] + elif messages: + body, self._log_bodies = _render_transcript(messages) else: - widgets.append(Static("[dim]This log has no messages.[/]", markup=True)) - widgets.append( - Static( - "[dim]Tab to a tool row + Enter to expand · Escape to go back[/]" - if any(m.role in ("tool_use", "tool_result") for m in messages) - else "[dim]Escape to go back[/]", - markup=True, - classes="hint", + self._log_bodies = [] + body = [Static("[dim]This log has no messages.[/]", markup=True)] + + search = Input( + value=self._log_query, + placeholder="Search this transcript...", + id="log-search", + # Textual selects an Input's whole value on focus by default, which + # would eat the query the first time focus came back to the box. + select_on_focus=False, + ) + status = Static(self._log_status_text(), id="log-search-status", markup=True) + # Mounted always, shown only once `/` arms them, so the header and the + # message pane don't shuffle when the box appears. + search.display = self._log_searching + status.display = self._log_searching + + header = cs.title or cs.first_prompt or cs.session_id + banner: list[Widget] = [] + if self._log_parse_error is not None: + banner.append( + Static( + Content.assemble( + ("⚠ Could not read this transcript's structure — ", "b"), + "showing the raw log. ", + (self._log_parse_error, "dim"), + ), + id="log-raw-warning", + ) + ) + + await main.mount( + Container( + # Content, not markup: a title or first prompt is arbitrary + # transcript text and a stray `[` would be parsed as a tag. + Label(Content(_first_line(header, 80)), classes="form-label"), + Static( + f"{cs.session_id} · {_relative_time(cs.last_activity)}", + classes="session-info", + ), + *banner, + search, + status, + # No hint row: the app footer already carries `/` and Escape, + # and a second strip of grey text under a wall of transcript + # reads as a second footer. Expanding a tool row is a Tab and + # an Enter, which the fold's own ▶ marker gives away. + VerticalScroll(*body, id="log-messages"), + id="log-panel", ) ) - await main.mount(Container(*widgets, id="log-panel")) - main.focus() + self._log_spans = [] + self._log_matches = [] + self._log_match_index = -1 + self.refresh_bindings() + self.query_one("#log-messages", VerticalScroll).focus() + debug.log( + "tui.log_view", + session=debug.rid(cs.session_id), + log=debug.rp(cs.jsonl_path), + messages=len(messages), + widgets=len(body), + bodies=len(self._log_bodies), + raw="yes" if self._log_parse_error is not None else "no", + ) + + def _log_matcher(self) -> Matcher | None: + """Compile the viewer's query, recording a bad regex rather than raising. + + `ContentMode` doesn't apply here: the transcript has already been parsed + into messages, so there is no raw JSON left to scan. + """ + self._log_error = None + query = self._log_query.strip() + if not query: + return None + try: + return claude_search.compile_matcher( + query, + regex=self._log_regex, + mode=ContentMode.RAW, + case_sensitive=self._log_case_sensitive, + ) + except SearchError as e: + self._log_error = str(e) + return None + + def _start_log_search(self) -> None: + """Scan the open transcript for the current query, off the event loop. + + Only the *matching* is threaded — applying the result touches mounted + widgets and so has to happen on the event loop. That split is what keeps + the search box from stuttering: nothing is unmounted, and the scan of a + large transcript never blocks a keystroke. + + An empty or malformed query still runs a scan, with a `None` matcher: it + is what clears the previous query's highlights. + """ + self._log_search_token += 1 + self.workers.cancel_group(self, "log-search") + matcher = self._log_matcher() + debug.log( + "log_search.start", + query=debug.rv(self._log_query), + chars=len(self._log_query), + regex=self._log_regex, + case_sensitive=self._log_case_sensitive, + bodies=len(self._log_bodies), + token=self._log_search_token, + error=self._log_error or "none", + ) + self._run_log_search( + [body.text for body in self._log_bodies], matcher, self._log_search_token + ) + + @work(thread=True, exclusive=True, group="log-search") + def _run_log_search( + self, texts: list[str], matcher: Matcher | None, token: int + ) -> None: + spans = [_match_spans(text, matcher) for text in texts] + self.call_from_thread(self._apply_log_search, token, spans) + + def _apply_log_search(self, token: int, spans: list[Spans]) -> None: + """Repaint the bodies for a completed scan, if it is still the current one. + + As with the transcript search, the token — not worker cancellation — is + the correctness mechanism: a result can already be queued on the event + loop when the query changes again. + """ + if token != self._log_search_token or not self._on_log: + debug.log( + "log_search.dropped", + token=token, + current=self._log_search_token, + on_log=self._on_log, + ) + return + if len(spans) != len(self._log_bodies): # pragma: no cover - guarded by token + debug.log( + "log_search.dropped", + token=token, + reason="body-count-changed", + spans=len(spans), + bodies=len(self._log_bodies), + ) + return + + matches: list[Static] = [] + # Every mounted body is *checked*, but only the ones whose matches + # actually moved are repainted: `Static.update` forces a layout refresh, + # and a transcript has hundreds of bodies that a given query never + # touches. Bodies start unhighlighted, so an absent previous entry is (). + previous = self._log_spans + # A fold opens if *any* body inside it matched, so the decision has to + # be accumulated across bodies before it is applied — a run holding one + # hit and nine misses would otherwise be closed again by the misses. + # Keyed by id() because a widget's equality is not identity. + folds: dict[int, tuple[Collapsible, bool]] = {} + for index, (body, body_spans) in enumerate(zip(self._log_bodies, spans)): + hit = bool(body_spans) + was = previous[index] if index < len(previous) else () + if body_spans != was: + body.widget.update(_highlight(body.text, body_spans)) + body.widget.set_class(hit, "log-match") + if hit: + matches.append(body.widget) + for fold in body.folds: + known = folds.get(id(fold)) + folds[id(fold)] = (fold, hit or (known is not None and known[1])) + + for fold, hit in folds.values(): + # A match opens its way out; everything else folds back up, so a new + # query starts from the same view every time. Assigned only on a + # change, since each one costs a refresh. + if fold.collapsed is hit: + fold.collapsed = not hit + + # Guarded: these tallies walk every body, and the whole point of the + # in-place update is not to do per-body work on a keystroke. + if debug.is_enabled(): + debug.log( + "log_search.done", + query=debug.rv(self._log_query), + token=token, + bodies=len(self._log_bodies), + matching_bodies=len(matches), + matches=sum(len(body_spans) for body_spans in spans), + repainted=sum( + 1 + for index, body_spans in enumerate(spans) + if body_spans != (previous[index] if index < len(previous) else ()) + ), + folds_open=sum(1 for _, hit in folds.values() if hit), + ) + self._log_spans = spans + self._log_matches = matches + self._log_match_index = -1 + self.refresh_bindings() + if matches: + self._step_log_match(1) + else: + self._set_log_status() + + async def action_log_search(self) -> None: + """Search the transcript on screen (bound to `/` in the log viewer).""" + if not self._on_log: + return + await self._arm_log_search() + + async def _arm_log_search(self) -> None: + """Reveal and focus the viewer's query box.""" + if not self.query("#log-search"): + return # pragma: no cover - the box is mounted with the viewer + self._log_searching = True + search = self.query_one("#log-search", Input) + search.display = True + self.query_one("#log-search-status", Static).display = True + search.focus() + + async def _clear_log_search(self) -> None: + """Drop the query, hide the box and clear the highlights.""" + self._log_searching = False + self._log_query = "" + if self.query("#log-search"): + box = self.query_one("#log-search", Input) + box.value = "" + box.display = False + self.query_one("#log-search-status", Static).display = False + if self._log_debounce is not None: + self._log_debounce.stop() + self._log_debounce = None + self._start_log_search() + if self.query("#log-messages"): + self.query_one("#log-messages", VerticalScroll).focus() + + @on(Input.Changed, "#log-search") + def on_log_search_changed(self, event: Input.Changed) -> None: + if event.value == self._log_query: + return + self._log_query = event.value + if self._log_debounce is not None: + self._log_debounce.stop() + self._log_debounce = self.set_timer( + self.SEARCH_DEBOUNCE, self._debounced_log_search + ) + + def _debounced_log_search(self) -> None: + self._log_debounce = None + self._start_log_search() + + @on(Input.Submitted, "#log-search") + def on_log_search_submitted(self, event: Input.Submitted) -> None: + """Hand focus to the messages, where `n`/`N` and the arrows work.""" + if self.query("#log-messages"): + self.query_one("#log-messages", VerticalScroll).focus() + + def _log_status_text(self) -> str: + """Mode line above the transcript, plus where `n`/`N` currently sits.""" + pattern = "regex" if self._log_regex else "literal" + case = "match case" if self._log_case_sensitive else "ignore case" + mode = f"[b]{pattern}[/] · [b]{case}[/]" + if self._log_error is not None: + return f"{mode} [red]{escape(self._log_error)}[/]" + if not self._log_query.strip(): + return mode + total = len(self._log_matches) + if not total: + return f"{mode} [dim]no matches[/]" + position = self._log_match_index + 1 if self._log_match_index >= 0 else 1 + plural = "s" if total != 1 else "" + return f"{mode} [dim]{position} of {total} matching message{plural}[/]" + + def _set_log_status(self) -> None: + if self.query("#log-search-status"): + self.query_one("#log-search-status", Static).update(self._log_status_text()) + + async def action_log_next_match(self) -> None: + """Scroll to the next `/` match in the log viewer (bound to `n`).""" + self._step_log_match(1) + + async def action_log_prev_match(self) -> None: + """Scroll to the previous `/` match (bound to `N`).""" + self._step_log_match(-1) + + def _step_log_match(self, step: int) -> None: + """Move the current-match marker by `step`, wrapping at either end. + + Starting from -1 means the first `n` lands on the first match and the + first `N` on the last, which is what a fresh search wants either way. + """ + if not self._on_log or not self._log_matches: + return + if not self.query("#log-messages"): + return # pragma: no cover - the pane outlives the match list + if 0 <= self._log_match_index < len(self._log_matches): + self._log_matches[self._log_match_index].remove_class("log-match-current") + self._log_match_index = (self._log_match_index + step) % len(self._log_matches) + current = self._log_matches[self._log_match_index] + current.add_class("log-match-current") + self.query_one("#log-messages", VerticalScroll).scroll_to_widget( + current, top=True + ) + self._set_log_status() @on(ListView.Selected, "#log-picker") async def on_log_picker_selected(self, event: ListView.Selected) -> None: @@ -3313,6 +3929,13 @@ async def _on_key(self, event: events.Key) -> None: if not self.focused: return + if self.focused.id == "log-search": + if event.key == "escape": + event.prevent_default() + event.stop() + await self._clear_log_search() + return + if self.focused.id == "home-search": if event.key == "escape": event.prevent_default() @@ -3838,6 +4461,9 @@ async def action_go_back(self) -> None: await self._clear_search() return self.exit() + elif self._on_log and (self._log_searching or self._log_query): + # In the log viewer escape drops the search before leaving the view. + await self._clear_log_search() elif self._actions_from_search and not self._on_search: # Back out of a result's actions menu into the results it came from. await self._show_session_search(restore=True) diff --git a/tests/test_claude_log.py b/tests/test_claude_log.py index 0394342..6a960bf 100644 --- a/tests/test_claude_log.py +++ b/tests/test_claude_log.py @@ -17,6 +17,7 @@ get_claude_projects_dir, get_sessions_for_path, parse_session, + read_raw_transcript, read_transcript, session_dirs_for_path, ) @@ -900,3 +901,34 @@ def test_fallback_survives_an_unreadable_log(self, tmp_path: Path) -> None: ] finally: blocked.chmod(0o644) + + +class TestReadRawTranscript: + """The viewer's fallback when an entry shape defeats `read_transcript`.""" + + def test_missing_file_raises(self, tmp_path: Path) -> None: + with pytest.raises(ClaudeLogError): + read_raw_transcript(tmp_path / "missing.jsonl") + + def test_lines_come_back_as_written_minus_the_blanks(self, tmp_path: Path) -> None: + log = tmp_path / "s.jsonl" + log.write_text('{"a": 1}\n\n {"b": 2} \n') + assert read_raw_transcript(log) == ['{"a": 1}', '{"b": 2}'] + + def test_unparseable_lines_survive(self, tmp_path: Path) -> None: + """Structure is exactly what this view isn't trying to impose.""" + log = tmp_path / "s.jsonl" + log.write_text("not json at all\n[1, 2, 3]\n") + assert read_raw_transcript(log) == ["not json at all", "[1, 2, 3]"] + + def test_a_huge_line_is_clipped(self, tmp_path: Path) -> None: + log = tmp_path / "s.jsonl" + log.write_text('{"out": "' + "x" * 5000 + '"}') + line = read_raw_transcript(log)[0] + assert len(line) < 2100 + assert line.endswith("…") + + def test_undecodable_bytes_do_not_raise(self, tmp_path: Path) -> None: + log = tmp_path / "s.jsonl" + log.write_bytes(b'{"a": "\xff\xfe"}\n') + assert len(read_raw_transcript(log)) == 1 diff --git a/tests/test_claude_search.py b/tests/test_claude_search.py index f742794..a76aab1 100644 --- a/tests/test_claude_search.py +++ b/tests/test_claude_search.py @@ -509,3 +509,28 @@ def test_case_sensitive_snippet_spans_cover_the_match(tmp_path): snippet = _snippet("say Needle and needle", 4, 10, matcher) # Only the correctly-cased occurrence is reported. assert [snippet.text[a:b] for a, b in snippet.spans] == ["Needle"] + + +class TestIterHitsResilience: + def test_one_unparseable_log_does_not_end_the_scan( + self, tmp_path: Path, monkeypatch + ) -> None: + """A scan over hundreds of logs must survive one bad apple.""" + from fujimoto.claude import search as search_module + + first = _simple_log(tmp_path / "a.jsonl", "the needle") + second = _simple_log(tmp_path / "b.jsonl", "the needle again") + + real = search_module.search_log + + def exploding(path: Path, matcher): + if path == first: + raise AttributeError("'list' object has no attribute 'get'") + return real(path, matcher) + + monkeypatch.setattr(search_module, "search_log", exploding) + matcher = compile_matcher("needle", mode=ContentMode.RAW) + batches = list(search_module.iter_hits([first, second], matcher)) + scanned, hits = batches[-1] + assert scanned == 2 + assert [h.session.jsonl_path for h in hits] == [second] diff --git a/tests/test_cli.py b/tests/test_cli.py index 1cca220..e75c3a9 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -1,5 +1,6 @@ from __future__ import annotations +from dataclasses import replace from datetime import datetime, timezone from pathlib import Path from unittest.mock import patch @@ -34,9 +35,12 @@ _claude_state_label, _format_prompt_lines, _friendly_key_label, + LOG_MATCH_STYLE, SNIPPET_MATCH_STYLE, _fit_snippet, _get_claude_sessions, + _highlight, + _match_spans, _relative_time, _render_snippet, _pair_results, @@ -6154,6 +6158,45 @@ async def test_escape_from_actions_returns_to_the_results(self) -> None: assert app._on_search is True assert len(app.query_one("#search-results", ListView)) == 1 + @pytest.mark.asyncio + async def test_returning_lands_on_the_result_that_was_opened(self) -> None: + """Walking a list of results shouldn't restart at the top each time.""" + app = SessionApp() + with _patch_git_info(): + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + app._apply_search_batch( + app._search_token, + 3, + 3, + (_make_hit("aaa"), _make_hit("bbb"), _make_hit("ccc")), + ) + results = app.query_one("#search-results", ListView) + results.focus() + results.index = 1 + await pilot.press("enter") + await pilot.pause() + assert app._search_selected_index == 1 + + await app.action_go_back() + await pilot.pause() + results = app.query_one("#search-results", ListView) + assert results.index == 1 + assert app.focused is results + + @pytest.mark.asyncio + async def test_a_fresh_scan_forgets_the_remembered_row(self) -> None: + app = SessionApp() + with _patch_git_info(): + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + app._search_selected_index = 2 + await app._clear_search_results() + await pilot.pause() + assert app._search_selected_index is None + @pytest.mark.asyncio async def test_actions_opened_from_home_still_return_home(self) -> None: app = SessionApp() @@ -6705,7 +6748,7 @@ async def _select(self, app: SessionApp, pilot, list_id: str, item_id: str) -> N @staticmethod def _body_texts(app: SessionApp) -> list[str]: """Prose bodies only — a collapsible's body is nested, not a child.""" - return [str(w.visual) for w in app.query("#log-panel > .log-body")] + return [str(w.visual) for w in app.query("#log-messages > .log-body")] @staticmethod def _tool_body_texts(app: SessionApp) -> list[str]: @@ -6766,6 +6809,46 @@ async def test_single_transcript_opens_directly(self, tmp_path: Path) -> None: # Nothing was launched — this is a read-only view. assert app._launch_target is None + @pytest.mark.asyncio + async def test_no_hint_row_under_the_transcript(self, tmp_path: Path) -> None: + """A grey strip under a screenful of transcript reads as a footer. + + Everything it used to say — `/`, Escape — is in the app footer, and the + fold markers give away that a tool row opens. + """ + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Bash", + "input": {"command": "ls"}, + } + ] + }, + }, + ], + ) + with _patch_git_info( + sessions=["test-proj/20260309-test"], + worktrees=[wt], + claude_sessions_fn=lambda _p: [_log_session(log, wt)], + ): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_actions(app, pilot, "wt-20260309-test") + await self._select(app, pilot, "#session-actions", "sa-viewlog") + assert len(app.query("#log-panel .hint")) == 0 + # The messages pane is the last thing in the panel, so it gets + # the rest of the height instead of leaving a row for the hint. + assert app.query_one("#log-panel").children[-1].id == "log-messages" + @pytest.mark.asyncio async def test_escape_returns_home(self, tmp_path: Path) -> None: wt = tmp_path / "20260309-test" @@ -7236,7 +7319,7 @@ async def test_an_unclaimed_result_still_gets_a_row(self, tmp_path: Path) -> Non await self._open_log(app, pilot) rows = list(app.query(".log-tool")) assert len(rows) == 1 - assert rows[0].title.startswith("↳ Result (1 line)") + assert str(rows[0].title).startswith("↳ Result (1 line)") assert self._tool_body_texts(app) == ["orphaned output"] def test_results_pair_positionally_when_a_log_has_no_ids(self) -> None: @@ -7257,3 +7340,1053 @@ def test_a_call_with_no_result_pairs_with_none(self) -> None: ts = datetime(2026, 3, 9, tzinfo=timezone.utc) run = [TranscriptMessage("tool_use", "Bash\ncommand: hung", ts, "t1")] assert _pair_results(run) == [(run[0], None)] + + +class TestSessionLogSearch: + """`/` inside the session log viewer: highlight, count, n/N navigation.""" + + async def _open_log(self, pilot, app: SessionApp, log: Path, wt: Path) -> None: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + + @staticmethod + def _status(app: SessionApp) -> str: + return str(app.query_one("#log-search-status", Static).render()) + + @staticmethod + def _match_texts(app: SessionApp) -> list[str]: + return [str(w.visual) for w in app._log_matches] + + @pytest.mark.asyncio + async def test_slash_arms_the_box_and_matches_are_tagged( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + {"type": "user", "message": {"content": "find the needle"}}, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "no hay here"}]}, + }, + { + "type": "assistant", + "message": {"content": [{"type": "text", "text": "needle again"}]}, + }, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + assert app.query_one("#log-search", Input).display is False + + await pilot.press("slash") + await pilot.pause() + assert app._log_searching is True + assert app.query_one("#log-search", Input).display is True + assert app.focused is app.query_one("#log-search", Input) + + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert self._match_texts(app) == ["find the needle", "needle again"] + assert "1 of 2 matching messages" in self._status(app) + + @pytest.mark.asyncio + async def test_n_and_shift_n_walk_the_matches_and_wrap( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + {"type": "user", "message": {"content": "needle one"}}, + {"type": "user", "message": {"content": "needle two"}}, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + # A fresh search lands on the first match. + assert app._log_match_index == 0 + assert app._log_matches[0].has_class("log-match-current") + + # The Input holds focus, so drive the actions directly — a + # plain `n` would be typed into the query box. + await app.action_log_next_match() + assert app._log_match_index == 1 + assert not app._log_matches[0].has_class("log-match-current") + assert "2 of 2" in self._status(app) + + await app.action_log_next_match() + assert app._log_match_index == 0 + await app.action_log_prev_match() + assert app._log_match_index == 1 + + @pytest.mark.asyncio + async def test_enter_hands_focus_to_the_messages_so_n_works( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + {"type": "user", "message": {"content": "needle one"}}, + {"type": "user", "message": {"content": "needle two"}}, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + await pilot.press("enter") + await pilot.pause() + assert app.focused is app.query_one("#log-messages") + await pilot.press("n") + await pilot.pause() + assert app._log_match_index == 1 + + @pytest.mark.asyncio + async def test_a_match_inside_a_tool_call_opens_the_fold( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Bash", + "input": {"command": "grep needle ."}, + } + ] + }, + }, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + assert app.query_one(Collapsible).collapsed is True + + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + # A match nothing can scroll to is worse than no match. + assert app.query_one(Collapsible).collapsed is False + assert len(app._log_matches) == 1 + + @pytest.mark.asyncio + async def test_no_matches_says_so(self, tmp_path: Path) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hay"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert app._log_matches == [] + assert "no matches" in self._status(app) + + @pytest.mark.asyncio + async def test_ctrl_r_switches_to_regex_and_a_bad_one_is_reported( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "a1b2"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "a.b" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + # Literal by default, so the dot doesn't match "1". + assert app._log_matches == [] + assert "literal" in self._status(app) + + await app.action_toggle_search_regex() + await pilot.pause() + assert app._log_regex is True + assert "regex" in self._status(app) + assert len(app._log_matches) == 1 + + app.query_one("#log-search", Input).value = "(unclosed" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert app._log_matches == [] + assert "regex" in self._status(app) + assert app._log_error is not None + + @pytest.mark.asyncio + async def test_ctrl_i_switches_to_case_sensitive(self, tmp_path: Path) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "Needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app._log_matches) == 1 + + await app.action_toggle_search_case() + await pilot.pause() + assert app._log_case_sensitive is True + assert "match case" in self._status(app) + assert app._log_matches == [] + + @pytest.mark.asyncio + async def test_escape_drops_the_search_before_leaving_the_view( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app._log_matches) == 1 + + # First escape (from the query box) only clears the search. + app.query_one("#log-search", Input).focus() + await pilot.pause() + await pilot.press("escape") + await pilot.pause() + assert app._on_log is True + assert app._log_query == "" + assert app._log_matches == [] + assert app.query_one("#log-search", Input).display is False + + # The next one leaves the viewer. + await pilot.press("escape") + await pilot.pause() + assert len(app.query("#home-list")) == 1 + + @pytest.mark.asyncio + async def test_escape_from_the_messages_pane_also_drops_the_search_first( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + app.query_one("#log-messages").focus() + await pilot.press("escape") + await pilot.pause() + assert app._on_log is True + assert app._log_query == "" + + @pytest.mark.asyncio + async def test_typing_restarts_the_debounce_rather_than_stacking_renders( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + box = app.query_one("#log-search", Input) + box.value = "nee" + await pilot.pause() + first = app._log_debounce + assert first is not None + box.value = "needle" + await pilot.pause() + assert app._log_debounce is not first + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert app._log_debounce is None + assert len(app._log_matches) == 1 + + @pytest.mark.asyncio + async def test_typing_past_a_re_render_appends_rather_than_replacing( + self, tmp_path: Path + ) -> None: + """A re-render refocuses the box; focus must not select what's in it. + + Textual selects an Input's whole value on focus by default, so without + `select_on_focus=False` the first keystroke after a debounced re-render + wiped everything typed before it. + """ + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + await pilot.pause() + await pilot.press("n", "e", "e") + # Let the debounce fire, which remounts and refocuses the box. + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + box = app.query_one("#log-search", Input) + assert box.selection.is_empty + assert app.focused is box + + await pilot.press("d", "l", "e") + assert box.value == "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app._log_matches) == 1 + + @pytest.mark.asyncio + async def test_a_query_updates_bodies_in_place_without_remounting( + self, tmp_path: Path + ) -> None: + """The panel must survive a query change — rebuilding it is the bug. + + A full rebuild costs about half a second on a real transcript and takes + the query box down with it, so the box loses focus mid-keystroke. + """ + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + {"type": "user", "message": {"content": "needle one"}}, + {"type": "user", "message": {"content": "only hay"}}, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + await pilot.pause() + panel = app.query_one("#log-panel") + box = app.query_one("#log-search", Input) + bodies = list(app._log_bodies) + + box.value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + # Same widgets throughout — only their content changed. + assert app.query_one("#log-panel") is panel + assert app.query_one("#log-search", Input) is box + assert app._log_bodies == bodies + assert app.focused is box + assert app._log_matches == [bodies[0].widget] + + # And a narrowed query re-highlights the same widgets again. + box.value = "hay" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert app.query_one("#log-panel") is panel + assert app._log_matches == [bodies[1].widget] + assert not bodies[0].widget.has_class("log-match") + + @pytest.mark.asyncio + async def test_a_stale_scan_is_dropped(self, tmp_path: Path) -> None: + """A scan can land after the query moved on; the token is what catches it.""" + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app._log_matches) == 1 + + stale = app._log_search_token - 1 + app._apply_log_search(stale, [()] * len(app._log_bodies)) + assert len(app._log_matches) == 1 + + @pytest.mark.asyncio + async def test_a_run_of_tool_calls_folds_back_up_when_the_query_changes( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Bash", + "input": {"command": "grep needle ."}, + }, + { + "type": "tool_use", + "id": "t2", + "name": "Bash", + "input": {"command": "ls"}, + }, + ] + }, + }, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + run = app.query_one(".log-tool-run", Collapsible) + assert run.collapsed is True + + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + # The run and the matching call both open; the other stays shut. + assert run.collapsed is False + calls = list(app.query(".log-tool-run .log-tool").results(Collapsible)) + assert [c.collapsed for c in calls] == [False, True] + + app.query_one("#log-search", Input).value = "nothing here" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert run.collapsed is True + assert all(c.collapsed for c in calls) + + @pytest.mark.asyncio + async def test_slash_off_the_viewer_is_inert(self) -> None: + """`check_action` hides it, but the action must still be safe to call.""" + app = SessionApp() + with _patch_git_info(): + async with app.run_test() as pilot: + await app.action_log_search() + await pilot.pause() + assert app._log_searching is False + + @pytest.mark.asyncio + async def test_escape_mid_keystroke_cancels_the_pending_scan( + self, tmp_path: Path + ) -> None: + """Clearing before the debounce fires must not leave it armed.""" + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "need" + await pilot.pause() + assert app._log_debounce is not None + + await app._clear_log_search() + await app.workers.wait_for_complete() + await pilot.pause() + assert app._log_debounce is None + assert app._log_query == "" + assert app._log_matches == [] + + @pytest.mark.asyncio + async def test_a_new_transcript_starts_unsearched(self, tmp_path: Path) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + await app.action_toggle_search_regex() + await pilot.pause() + + await self._open_log(pilot, app, log, wt) + assert app._log_query == "" + assert app._log_searching is False + # The regex/case choices persist, as they do in `s`. + assert app._log_regex is True + + @pytest.mark.asyncio + async def test_leaving_the_viewer_drops_the_match_list( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await self._open_log(pilot, app, log, wt) + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + await app._show_home() + await pilot.pause() + assert app._on_log is False + assert app._log_matches == [] + # `n` off the viewer is inert, not a crash. + await app.action_log_next_match() + + +class TestContextAwareBindings: + """The footer only advertises keys that do something in the current view.""" + + @staticmethod + def _footer_labels(app: SessionApp) -> set[str]: + """Descriptions of the bindings the footer is actually showing.""" + return { + binding.description + for _, binding, _, _ in app.screen.active_bindings.values() + if binding.show + } + + @pytest.mark.asyncio + async def test_slash_still_opens_the_right_search_in_each_view( + self, tmp_path: Path + ) -> None: + """Two bindings share `/`; the disabled one must fall through.""" + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await pilot.press("slash") + await pilot.pause() + assert app._searching is True + assert app.focused is app.query_one("#home-search", Input) + + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + await pilot.press("slash") + await pilot.pause() + assert app._log_searching is True + assert app.focused is app.query_one("#log-search", Input) + + @pytest.mark.asyncio + async def test_home_offers_filter_and_transcript_search_but_not_n(self) -> None: + app = SessionApp() + with _patch_git_info(): + async with app.run_test() as pilot: + assert app.check_action("search", ()) is True + assert app.check_action("session_search", ()) is True + # False, not None: Textual greys a None binding out but hides a + # False one, and hiding is the point. + assert app.check_action("log_search", ()) is False + assert app.check_action("log_next_match", ()) is False + assert app.check_action("toggle_search_regex", ()) is False + await pilot.pause() + assert self._footer_labels(app) == { + "Quit", + "Back", + "Filter", + "Search transcripts", + "Refresh", + } + + @pytest.mark.asyncio + async def test_log_viewer_offers_slash_and_match_keys_but_not_s( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "needle"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + assert app.check_action("search", ()) is False + assert app.check_action("log_search", ()) is True + assert app.check_action("session_search", ()) is False + assert app.check_action("toggle_search_regex", ()) is True + assert app.check_action("toggle_search_mode", ()) is False + # Nothing to step through until a query matches something. + assert app.check_action("log_next_match", ()) is False + # `/` reads as Search here, not Filter — same key, two bindings. + labels = self._footer_labels(app) + assert "Search" in labels + assert "Filter" not in labels + assert "Search transcripts" not in labels + # Regex and case apply here; raw-vs-text does not. + assert {"Regex", "Match case"} <= labels + assert "Raw/text" not in labels + # `r` only rebuilds the home list, so it has nothing to offer. + assert app.check_action("refresh", ()) is False + assert "Refresh" not in labels + + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert app.check_action("log_next_match", ()) is True + assert app.check_action("log_prev_match", ()) is True + # Not in the footer while the query box holds focus: a plain + # letter would be typed into it, so Textual withholds it. + await pilot.press("enter") + await pilot.pause() + assert {"Next match", "Previous match"} <= self._footer_labels(app) + + @pytest.mark.asyncio + async def test_transcript_search_offers_all_three_mode_toggles(self) -> None: + app = SessionApp() + with _patch_git_info(): + async with app.run_test() as pilot: + await pilot.press("s") + await pilot.pause() + assert app.check_action("toggle_search_mode", ()) is True + assert app.check_action("toggle_search_case", ()) is True + assert app.check_action("session_search", ()) is False + assert app.check_action("quit", ()) is True + # The chords are the only way to discover these modes, and the + # query box has focus here — a chord still reaches the footer + # from there, which is why they aren't plain letters. + assert app.focused is app.query_one("#search-input", Input) + assert {"Regex", "Raw/text", "Match case"} <= self._footer_labels(app) + + +class TestHighlight: + """Marking up transcript text for the log viewer's `/` search.""" + + @staticmethod + def _spans(text: str, query: str, **kwargs): + from fujimoto.claude.search import compile_matcher + + return _match_spans(text, compile_matcher(query, **kwargs)) + + def test_no_matcher_means_no_spans_and_untouched_text(self) -> None: + assert _match_spans("plain [text]", None) == () + assert str(_highlight("plain [text]", ())) == "plain [text]" + + def test_matches_are_located_and_styled(self) -> None: + text = "a needle in a needle" + spans = self._spans(text, "needle") + assert spans == ((2, 8), (14, 20)) + content = _highlight(text, spans) + # Spans are styled; the text itself is untouched. + assert str(content) == text + assert [span.style for span in content.spans] == [LOG_MATCH_STYLE] * 2 + + def test_brackets_are_not_parsed_as_markup(self) -> None: + text = '{"a": [needle' + assert str(_highlight(text, self._spans(text, "needle"))) == text + + def test_zero_width_matches_are_dropped(self) -> None: + # `x*` matches empty at every position. There is nothing to highlight + # and nothing to scroll to, so counting them would only inflate the + # tally the status line reports. + assert self._spans("abc", "x*", regex=True) == () + + def test_case_insensitivity_is_the_matcher_s_business(self) -> None: + assert self._spans("Needle", "needle") == ((0, 6),) + assert self._spans("Needle", "needle", case_sensitive=True) == () + + +class TestTranscriptMarkupSafety: + """Transcript text is arbitrary bytes; nothing built from it may be markup. + + A real `Bash` call whose command contained `[ "$(gh run list --workflow=…` + crashed the viewer with `MarkupError: Expected markup value` — the `[` in a + shell test opened a console tag Textual then failed to close. + """ + + @pytest.mark.asyncio + async def test_a_bracket_in_a_tool_title_does_not_crash_the_viewer( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + command = 'until [ "$(gh run list --workflow=post-deploy.yml)" ]; do :; done' + log = _write_log( + tmp_path / "s.jsonl", + [ + { + "type": "assistant", + "message": { + "content": [ + { + "type": "tool_use", + "id": "t1", + "name": "Bash", + "input": {"command": command}, + } + ] + }, + }, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + title = app.query_one(Collapsible).title + # The bracket survives verbatim rather than opening a tag. + assert 'until [ "$(gh run list' in str(title) + + @pytest.mark.asyncio + async def test_a_bracket_in_the_header_does_not_crash_the_viewer( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + cs = replace(_log_session(log, wt), title="fix [DA-494] uniqueness") + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(cs) + await pilot.pause() + header = str(app.query_one("#log-panel .form-label", Label).visual) + assert header == "fix [DA-494] uniqueness" + + @pytest.mark.asyncio + async def test_a_bracket_in_a_picker_row_does_not_crash( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + sessions = [ + replace( + _log_session(log, wt, "aaa11111", minute=1), + title="fix [DA-494]", + first_prompt='run until [ "$(gh run list)" ]', + ), + _log_session(log, wt, "bbb22222", minute=0), + ] + session = SessionInfo( + name="20260309-test", + session_type="worktree", + project="test-proj", + path=wt, + tmux_session="test-proj/20260309-test", + is_active=False, + branch="worktree/20260309-test", + ) + with _patch_git_info(worktrees=[wt], claude_sessions_fn=lambda _p: sessions): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_log_picker(session) + await pilot.pause() + # Children compose on mount, so the picker has to be on screen. + rendered = " ".join( + str(label.visual) for label in app.query("#log-picker Label") + ) + assert "fix [DA-494]" in rendered + assert 'until [ "$(gh run list)" ]' in rendered + + +class TestRawTranscriptFallback: + """A log the parser can't structure is still readable, not an error screen.""" + + @staticmethod + def _rows(app: SessionApp) -> list[str]: + return [str(w.visual) for w in app.query("#log-messages .log-body")] + + @pytest.mark.asyncio + async def test_a_parser_crash_falls_back_to_the_raw_log( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [{"type": "user", "message": {"content": "the needle"}}], + ) + with ( + _patch_git_info(worktrees=[wt]), + patch( + "fujimoto.cli.read_transcript", + side_effect=AttributeError("'list' object has no attribute 'get'"), + ), + ): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + assert len(app.query("#log-panel")) == 1 + warning = str(app.query_one("#log-raw-warning", Static).visual) + assert "showing the raw log" in warning + # The exception is named, so the shape can be chased down. + assert "AttributeError" in warning + assert '"content": "the needle"' in self._rows(app)[0] + + @pytest.mark.asyncio + async def test_the_raw_view_is_still_searchable(self, tmp_path: Path) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + {"type": "user", "message": {"content": "the needle"}}, + {"type": "user", "message": {"content": "only hay"}}, + ], + ) + with ( + _patch_git_info(worktrees=[wt]), + patch("fujimoto.cli.read_transcript", side_effect=TypeError("boom")), + ): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + assert len(app._log_matches) == 1 + assert app._log_match_index == 0 + + @pytest.mark.asyncio + async def test_an_unreadable_file_is_still_an_error(self, tmp_path: Path) -> None: + """There is nothing to fall back *to* when the file can't be read.""" + wt = tmp_path / "20260309-test" + with ( + _patch_git_info(worktrees=[wt]), + patch("fujimoto.cli.read_transcript", side_effect=TypeError("boom")), + ): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(tmp_path / "gone.jsonl", wt)) + await pilot.pause() + assert len(app.query("#log-panel")) == 0 + assert "Error:" in " ".join( + str(w.visual) for w in app.query("#main > Static") + ) + + @pytest.mark.asyncio + async def test_a_healthy_log_shows_no_warning(self, tmp_path: Path) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + assert app._log_parse_error is None + assert len(app.query("#log-raw-warning")) == 0 + + @pytest.mark.asyncio + async def test_opening_a_good_log_next_clears_the_fallback( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + with patch( + "fujimoto.cli.read_transcript", side_effect=TypeError("boom") + ): + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + assert app._log_parse_error is not None + + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + assert app._log_parse_error is None + assert app._log_raw_lines == [] + assert len(app.query("#log-raw-warning")) == 0 + + +class TestLogViewerDebugInventory: + """What --debug records about reading and searching one transcript.""" + + @pytest.fixture(autouse=True) + def _debug_on(self, tmp_path: Path) -> None: + from fujimoto import debug + + debug.enable(redact=False, log_dir=tmp_path / "logs") + yield + debug.disable() + + def _log_text(self, tmp_path: Path, subdir: str = "logs") -> str: + # Newest wins, for the reason `TestDebugHomeInventory` says: a test that + # re-enables the logger leaves the fixture's file alongside its own. + logs = sorted( + (tmp_path / subdir).glob("*.log"), key=lambda p: p.stat().st_mtime + ) + return logs[-1].read_text() + + @pytest.mark.asyncio + async def test_opening_and_searching_a_transcript_is_logged( + self, tmp_path: Path + ) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [ + {"type": "user", "message": {"content": "the needle"}}, + {"type": "user", "message": {"content": "only hay"}}, + ], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + await pilot.press("slash") + app.query_one("#log-search", Input).value = "needle" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + text = self._log_text(tmp_path) + assert "tui.log_view" in text + assert "messages=2" in text + assert "raw=no" in text + assert "log_search.start query=needle chars=6" in text + assert "log_search.done" in text + # One body matched out of two, and only that one was repainted. + assert "matching_bodies=1" in text + assert "repainted=1" in text + + @pytest.mark.asyncio + async def test_the_query_is_redacted(self, tmp_path: Path) -> None: + from fujimoto import debug + + debug.disable() + # Its own directory, so the fixture's unredacted log cannot be the one + # this test reads back. + debug.enable(redact=True, log_dir=tmp_path / "redacted") + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", + [{"type": "user", "message": {"content": "my-secret-term"}}], + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + await pilot.press("slash") + app.query_one("#log-search", Input).value = "my-secret-term" + await pilot.pause(app.SEARCH_DEBOUNCE * 2) + await app.workers.wait_for_complete() + await pilot.pause() + text = self._log_text(tmp_path, "redacted") + assert "log_search.start" in text + assert "chars=14" in text + assert "my-secret-term" not in text + + @pytest.mark.asyncio + async def test_a_stale_scan_says_why_it_was_dropped(self, tmp_path: Path) -> None: + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + with _patch_git_info(worktrees=[wt]): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + app._apply_log_search(app._log_search_token - 1, [()]) + text = self._log_text(tmp_path) + assert "log_search.dropped" in text + + @pytest.mark.asyncio + async def test_the_raw_fallback_reports_the_shape_that_defeated_it( + self, tmp_path: Path + ) -> None: + """The traceback is the report — an unknown entry shape is a bug.""" + wt = tmp_path / "20260309-test" + log = _write_log( + tmp_path / "s.jsonl", [{"type": "user", "message": {"content": "hi"}}] + ) + with ( + _patch_git_info(worktrees=[wt]), + patch( + "fujimoto.cli.read_transcript", + side_effect=AttributeError("'list' object has no attribute 'get'"), + ), + ): + app = SessionApp() + async with app.run_test() as pilot: + await app._show_session_log(_log_session(log, wt)) + await pilot.pause() + text = self._log_text(tmp_path) + assert "claude.transcript_unparsed" in text + assert "AttributeError" in text + assert "Traceback" in text + assert "tui.log_raw_fallback" in text + assert "claude.read_raw" in text + assert "raw=yes" in text diff --git a/tests/test_debug.py b/tests/test_debug.py index 8b18389..2862a61 100644 --- a/tests/test_debug.py +++ b/tests/test_debug.py @@ -2,6 +2,7 @@ import io from pathlib import Path +from unittest.mock import patch import pytest @@ -1182,3 +1183,56 @@ def test_failed_lookup_logs_encoded_name(self, tmp_path: Path, monkeypatch) -> N text = self._log_text(log_dir) assert "via=none dirs=0" in text assert "encoded=-tmp-nope" in text + + +class TestLogViewerInstrumentation: + """The log viewer's `/` search and its raw fallback, under --debug.""" + + def _log_text(self, log_dir: Path) -> str: + return next(log_dir.glob("*.log")).read_text() + + def test_scan_failure_names_the_log_it_dropped(self, tmp_path: Path) -> None: + """A transcript missing from a result set is invisible without this.""" + from fujimoto.claude import search + + bad = tmp_path / "projects" / "-tmp-proj" / "bad.jsonl" + bad.parent.mkdir(parents=True) + bad.write_text("{}\n") + log_dir = tmp_path / "logs" + debug.enable(redact=False, log_dir=log_dir) + with patch.object( + search, "search_log", side_effect=AttributeError("no attribute 'get'") + ): + batches = list(search.iter_hits([bad], search.compile_matcher("x"))) + debug.disable() + assert sum(len(b) for _, b in batches) == 0 + text = self._log_text(log_dir) + assert "search.scan_failed" in text + assert "error=AttributeError" in text + assert "failed=1" in text + + def test_raw_read_reports_what_it_kept(self, tmp_path: Path) -> None: + from fujimoto.claude.log_parser import read_raw_transcript + + log = tmp_path / "s.jsonl" + log.write_text('{"a": 1}\n\n{"b": "' + "x" * 5000 + '"}\n') + log_dir = tmp_path / "logs" + debug.enable(redact=False, log_dir=log_dir) + read_raw_transcript(log) + debug.disable() + text = self._log_text(log_dir) + assert "claude.read_raw" in text + assert "lines=2 clipped=1" in text + + def test_raw_read_redacts_the_path(self, tmp_path: Path) -> None: + from fujimoto.claude.log_parser import read_raw_transcript + + log = tmp_path / "my-private-project.jsonl" + log.write_text('{"a": 1}\n') + log_dir = tmp_path / "logs" + debug.enable(redact=True, log_dir=log_dir) + read_raw_transcript(log) + debug.disable() + text = self._log_text(log_dir) + assert "claude.read_raw" in text + assert "my-private-project" not in text