Add session history browser, turn-tree branching, model picker, mouse capture, and feed cache
Sessions panel now shows on-disk JSONL history for the current directory (background scanner, ~1/s, deduped against live sessions). space/→ expands a session into a lazygit-style turn tree; j/k walks sessions and turns; v anchors a visual range; b materialises a new fully-decoupled branch file. ctrl-↓ is the commit point for attach/resume/spawn; live external sessions get a 3-second liveness guard (past_embeds skips the guard for sessions we killed ourselves). n opens a model-picker popup and spawns a fresh --session-id session; model aliases are auto-discovered by scanning the installed claude binary (no API call, no exec). ctrl-f toggles fullscreen for the embedded pane. Mouse is now captured: wheel always scrolls the feed, left-drag selects screen text copied on release via OSC 52 (like Claude Code). Native selection needs shift held. FeedCache stores per-entry rendered lines + wrapped heights, rebuilt only when the content fingerprint changes — work while holding the app mutex is proportional to what changed, and feed scroll now works past u16::MAX lines. Kind::User entries (deep-blue ❯ prefix) surface user prompts from request bodies via record_user_prompt. src/markdown.rs added: GFM table rendering (box-drawing, width-fitted columns) and heading # marker stripping, compensating tui-markdown 0.3.5 gaps without upgrading. The proxy tap now runs on a dedicated tokio task via a bounded mpsc channel (try_send; drop-on-full) so it never delays forwarded bytes. Listener is bound before the TUI starts so multi-instance port coexistence works from launch; CT_UPSTREAM added for fully offline testing. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
122
CLAUDE.md
122
CLAUDE.md
@@ -10,12 +10,27 @@ extra usage is the core constraint of this project.
|
||||
|
||||
```
|
||||
src/main.rs entry; tokio runtime for proxy task, TUI on main thread; --headless mode
|
||||
src/proxy.rs axum fallback handler: buffers request body (for session metadata),
|
||||
src/proxy.rs axum fallback handler: buffers request body (for session metadata,
|
||||
tool results, and user prompts — `app::record_user_prompt` lifts
|
||||
the trailing user message into a Kind::User feed entry),
|
||||
forwards via reqwest, streams response back unbuffered, tees SSE
|
||||
src/sse.rs incremental SSE parser; tolerant of chunk splits mid-event/mid-UTF-8
|
||||
src/app.rs Arc<Mutex<App>> shared state; Tap = one in-flight tapped request,
|
||||
translates SSE events → session Entries (Drop closes it out)
|
||||
src/ui.rs ratatui rendering @ ~30fps; session list + scrollable feed
|
||||
(FeedCache: per-entry rendered lines + wrapped heights, only
|
||||
changed entries re-render; the viewport window of lines is
|
||||
handed to ratatui so scroll state is usize end-to-end)
|
||||
src/markdown.rs wraps tui-markdown: renders GFM tables itself (box-drawing,
|
||||
width-fitted wrapped columns) and strips heading `#` markers —
|
||||
the pinned tui-markdown 0.3.5 does neither
|
||||
src/sessions.rs on-disk session history: background scanner thread keeps
|
||||
App::disk_sessions fresh (~1/s poll, labels re-read only on
|
||||
mtime change); load_view/load_history rebuild a feed Session
|
||||
from a JSONL transcript (lazily, on first view); build_tree
|
||||
parses uuid/parentUuid chains into a TurnTree (one node per
|
||||
real user prompt; rewinds leave fork points); materialize
|
||||
writes a new session file from a chosen set of turns
|
||||
src/term.rs embedded claude pane: spawns `claude --session-id <uuid>` in a
|
||||
portable-pty routed through the proxy; wezterm-term models the
|
||||
screen (and answers terminal queries); renderer paints cells
|
||||
@@ -29,7 +44,11 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
|
||||
- **Latency-neutral pass-through**: response bytes are forwarded as-is, never
|
||||
buffered or rewritten. Auth headers pass through untouched. If the tap code
|
||||
panics or misparses, the proxy must still relay bytes (tee is best-effort).
|
||||
panics or misparses, the proxy must still relay bytes (tee is best-effort):
|
||||
chunks are `try_send`-cloned into a bounded channel and parsed on a separate
|
||||
task (dropped on overflow, never blocking the relay), and the proxy/tap side
|
||||
locks the app mutex poison-tolerantly (`app::lock_app`) so a UI panic can't
|
||||
kill forwarding.
|
||||
- **`accept-encoding` is stripped** from forwarded requests so the upstream
|
||||
sends identity encoding we can parse in transit. Don't "fix" that.
|
||||
- Hop-by-hop headers (`content-length`, `transfer-encoding`, etc.) are stripped
|
||||
@@ -39,44 +58,109 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
builds `user_…_session_<uuid>`; `proxy::session_key` handles both.
|
||||
Concurrent requests (subagents) share a session but each `Tap` tracks its own
|
||||
current entry index — entries/sessions are append-only, so indices stay stable.
|
||||
- The embedded pane's session is matched by the `--session-id` UUID we
|
||||
generate; the tap drives pane behavior: grows it for AskUserQuestion /
|
||||
- **One app instance = one proxy port = at most one embedded claude**
|
||||
(`EmbedUi::term` / `App::embed_session`, matched by the `--session-id` /
|
||||
`--resume` UUID we spawn with). Every other live session is an external
|
||||
claude pointed at our port: observable, never attachable.
|
||||
- The session list merges live sessions (first, indices stable) with this
|
||||
directory's past sessions from `~/.claude/projects/<cwd with / → ->/*.jsonl`
|
||||
as dimmed stubs (deduped by uuid — a live session's file is on disk too).
|
||||
**Tab is viewing only, never a process operation**: selecting a stub
|
||||
lazy-loads its transcript into `App::history`; tabbing off the embedded
|
||||
session hides the pane without killing the child (instant to come back).
|
||||
ctrl-↓ is the commit point that attaches the pane to the selection:
|
||||
reveal+focus if it's the embedded session, `claude --resume <uuid>`
|
||||
(kill + respawn) for disk stubs and dead embeds, fresh `--session-id`
|
||||
spawn when there's nothing. Live *external* sessions are guarded — their
|
||||
instance may still run elsewhere and a second `--resume` would fork the
|
||||
transcript — but a second ctrl-↓ within 3s forces it (liveness is
|
||||
unknowable: an idle claude sends no traffic; `EmbedUi::past_embeds` skips
|
||||
the guard for sessions whose instance we killed ourselves). `--session-id`
|
||||
cannot be combined with `--resume` (CLI rejects it without `--fork-session`).
|
||||
- **Turn tree / branching** (lazygit/yazi-style, all in the sessions panel):
|
||||
`space` (or `→`/`l`) expands the selected session's turn tree — one row per
|
||||
real user prompt, abandoned rewind branches indented `⑂` under their fork
|
||||
point, trunk continuing below. j/k/↑/↓ walk sessions *and* turns (they
|
||||
never scroll the feed; the wheel and PgUp/PgDn/g/G do that). Highlighting
|
||||
a turn switches the feed to the on-disk transcript along the path through
|
||||
that turn and pins the turn's prompt to the viewport top (HistoryView
|
||||
caches per uuid, rebuilt when the leaf changes; FeedCache keys on
|
||||
leaf+live so views of the same uuid don't share slots). `v` anchors a
|
||||
contiguous visual range, `b` materializes a **new fully decoupled session
|
||||
file** — chain root→turn, or exactly the visual range stitched together
|
||||
(sessionId rewritten, each turn's head re-parented onto the previous
|
||||
turn's tail, our own ai-title record gives it the `⑂ …` label) — injected
|
||||
as the selected top stub. Branching never touches a process: ctrl-↓ stays
|
||||
the only spawn/kill commit point.
|
||||
- The tap drives pane behavior: grows it for AskUserQuestion /
|
||||
ExitPlanMode (sized from the question's option count) before Claude Code
|
||||
renders the prompt, shrinks when the tool_result echoes back, and schedules
|
||||
a ctrl-l transcript wipe 400ms after each turn (the pane is prompt-only;
|
||||
the feed shows the context).
|
||||
- Tool input streams as raw JSON fragments; pretty-printed only on
|
||||
`content_block_stop`. Text re-renders markdown every frame, so partial
|
||||
markdown self-heals.
|
||||
`content_block_stop`. Streaming text re-renders markdown on every change
|
||||
(FeedCache fingerprints by content length + done + result), so partial
|
||||
markdown self-heals; completed entries render from cache.
|
||||
|
||||
## Gotchas
|
||||
|
||||
- `tui-markdown` is pinned `=0.3.5`: later versions use `ratatui-core` (0.30
|
||||
alpha types), incompatible with ratatui 0.29.
|
||||
alpha types), incompatible with ratatui 0.29. Its gaps (no tables, literal
|
||||
heading markers) are compensated in `src/markdown.rs`, not by upgrading.
|
||||
- `wezterm-term`/`wezterm-surface` are not on crates.io: pinned to a git rev
|
||||
of the wezterm monorepo (keep both revs identical).
|
||||
- The pane's render window crops Claude Code chrome by *position*
|
||||
(`term.rs`: `BOTTOM_CROP`, start ≥ 2, `PTY_PAD`) — tuned to the current
|
||||
Claude Code UI; retune there if an update adds/removes chrome rows.
|
||||
Cropping only applies to the compact pane: fullscreen renders the child's
|
||||
screen verbatim from row 0 with the PTY sized exactly to the pane
|
||||
(`crop` flag on `resize`/`render`).
|
||||
- Keybindings avoid Alt entirely: on layouts like dk_mac_fixed, Alt composes
|
||||
characters (alt-c = ©) and never reaches the app as a modifier. Pane keys:
|
||||
F2 toggle, ctrl-↓ focus claude, ctrl-↑ focus feed. `CT_DEBUG_KEYS=1` shows
|
||||
raw key events in the status bar.
|
||||
F2 toggle, ctrl-↓ attach pane to selected session (resume/spawn/focus),
|
||||
ctrl-↑ focus feed, ctrl-f fullscreen toggle (only while the pane is
|
||||
focused), ctrl-q quit (global; needed while the pane is focused, where
|
||||
plain `q` is forwarded to the child), c attach most-recent past session.
|
||||
List keys: j/k/↑/↓ move the session/turn highlight, space/→/← expand/
|
||||
enter/leave the turn tree, n opens the model picker popup and spawns a
|
||||
brand-new `claude --session-id … [--model …]` (kills any current pane —
|
||||
`show_embed_new`; saves resume-then-/clear to get a fresh chat). The picker
|
||||
list comes from `App::model_choices`: seeded with `default_model_choices`,
|
||||
then replaced by `term::spawn_model_discovery` — a background scan that
|
||||
reads the live model-alias array (`["sonnet","opus","haiku","fable",…]`)
|
||||
straight out of the installed `claude` ELF (single self-contained binary
|
||||
with the JS bundle embedded). No API call, never runs claude — just resolves
|
||||
`claude` on PATH and greps its bytes for the longest lowercase-token array
|
||||
anchored by `opus`+`sonnet`. Tab/BackTab cycle sessions (`p` no longer
|
||||
mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree →
|
||||
quit). The feed
|
||||
scrolls only via wheel / PgUp / PgDn / g / G. `CT_DEBUG_KEYS=1` shows raw
|
||||
key events in the status bar.
|
||||
- Mouse is captured: wheel always scrolls the feed (regardless of focus), and
|
||||
left-drag selects screen text, copied on release via OSC 52 (like Claude
|
||||
Code). Native terminal selection therefore needs shift held.
|
||||
- ratatui needs feature `unstable-rendered-line-info` for `Paragraph::line_count`
|
||||
(used for follow/auto-scroll).
|
||||
(used to compute cached per-entry wrapped heights for follow/auto-scroll).
|
||||
- reqwest is `default-features = false` + `rustls-tls,stream` — don't enable
|
||||
compression features (would re-add accept-encoding).
|
||||
- Bind failures (port in use) only surface in the TUI status bar; check for a
|
||||
stale `claude-thinking` process holding 8484.
|
||||
- The listener is bound in `main` before the TUI starts: prefers 8484, falls
|
||||
back to an OS-assigned free port so multiple instances coexist (each pane
|
||||
gets the actual port via `ANTHROPIC_BASE_URL`). `CT_PORT` pins the port and
|
||||
turns bind failure into a hard startup error.
|
||||
- Testing: SSE parser has unit tests (`cargo test`). For a live pass-through
|
||||
check: `--headless`, then POST to `127.0.0.1:8484/v1/messages` without auth —
|
||||
a relayed 401 from Anthropic proves the round-trip. The TUI can't run in a
|
||||
non-tty.
|
||||
check: `--headless` (prints the bound port), then POST to
|
||||
`127.0.0.1:<port>/v1/messages` without auth — a relayed 401 from Anthropic
|
||||
proves the round-trip. The TUI can't run in a non-tty. `CT_UPSTREAM` points
|
||||
the proxy at an alternative upstream (e.g. a local fake SSE server) for
|
||||
fully offline end-to-end tests with zero API usage.
|
||||
|
||||
## Not yet handled (known MVP limits)
|
||||
|
||||
- Non-streaming requests pass through untapped (e.g. `count_tokens`).
|
||||
- Sessions are never pruned; long sessions re-render fully each frame.
|
||||
- Sessions are never pruned (entry memory grows for the process lifetime);
|
||||
the same goes for viewed disk transcripts (`App::history`).
|
||||
- Request bodies are fully buffered (up to 512 MB) before forwarding — needed
|
||||
to read session metadata; adds first-byte latency on huge bodies.
|
||||
- Tool results only appear once the *next* request fires; if the session ends
|
||||
right after a tool call, that result is never seen. Output is what Claude
|
||||
Code sends the model (i.e. post-truncation).
|
||||
@@ -85,3 +169,9 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
on the outer terminal (not done); permission prompts aren't detected for
|
||||
pane growth (not visible in the API stream — would need a Notification
|
||||
hook hitting a local control endpoint).
|
||||
- Materialized branch files satisfy our own parser (round-trip tested) but
|
||||
Claude Code's loader tolerance is only verified empirically by resuming
|
||||
one — if a CC update changes the JSONL schema, retest `b` + ctrl-↓. The
|
||||
tree itself isn't refreshed while expanded (collapse/re-expand re-reads
|
||||
the file), and a highlighted turn of a *live* session views its on-disk
|
||||
transcript, which lags the in-memory feed by however much CC buffers.
|
||||
|
||||
631
src/app.rs
631
src/app.rs
@@ -5,8 +5,32 @@ use std::time::Instant;
|
||||
|
||||
pub type SharedApp = Arc<Mutex<App>>;
|
||||
|
||||
/// Poison-tolerant lock. The proxy/tap path must keep relaying bytes even if
|
||||
/// the UI thread panicked while holding the mutex (display state may be
|
||||
/// stale-but-consistent; entries are append-only so nothing dangles).
|
||||
pub fn lock_app(app: &SharedApp) -> std::sync::MutexGuard<'_, App> {
|
||||
app.lock().unwrap_or_else(std::sync::PoisonError::into_inner)
|
||||
}
|
||||
|
||||
/// Display names for the filterable entry kinds, in toggle order.
|
||||
pub const FILTER_LABELS: [&str; 5] = ["thinking", "text", "tools", "meta", "errors"];
|
||||
pub const FILTER_LABELS: [&str; 6] = ["user", "thinking", "text", "tools", "meta", "errors"];
|
||||
|
||||
/// Fallback model picker entries used until (or unless) `term::discover_models`
|
||||
/// reads the live alias set out of the installed `claude` binary. Each entry is
|
||||
/// `(label, --model arg)`; an empty arg means no `--model` flag (Claude Code's
|
||||
/// configured default).
|
||||
pub fn default_model_choices() -> Vec<(String, String)> {
|
||||
[
|
||||
("default", ""),
|
||||
("opus", "opus"),
|
||||
("sonnet", "sonnet"),
|
||||
("haiku", "haiku"),
|
||||
("fable", "fable"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(l, a)| (l.to_string(), a.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub sessions: Vec<Session>,
|
||||
@@ -18,10 +42,38 @@ pub struct App {
|
||||
pub filters: [bool; FILTER_LABELS.len()],
|
||||
/// `Some(selected_row)` while the filter popup is open.
|
||||
pub filter_popup: Option<usize>,
|
||||
/// `Some(selected_row)` while the `n` model-picker popup is open; picking a
|
||||
/// model spawns a fresh embedded session (indexes `model_choices`).
|
||||
pub model_popup: Option<usize>,
|
||||
/// Model picker entries `(label, --model arg)`. Seeded with
|
||||
/// `default_model_choices`, then replaced by `term::discover_models` once a
|
||||
/// background scan reads the live alias set from the `claude` binary.
|
||||
pub model_choices: Vec<(String, String)>,
|
||||
/// Whether the session list panel is expanded.
|
||||
pub show_sessions: bool,
|
||||
/// Past sessions on disk for this directory (newest first), maintained by
|
||||
/// `sessions::spawn_scanner`. Shown in the session list as selectable
|
||||
/// stubs below the live sessions (deduped by uuid — see `visible_stubs`).
|
||||
pub disk_sessions: Vec<crate::sessions::DiskSession>,
|
||||
/// Lazily loaded transcript views for *viewed* disk sessions, keyed by
|
||||
/// uuid (rebuilt when the requested tree path changes). Never pruned
|
||||
/// (same lifetime policy as `sessions`).
|
||||
pub history: HashMap<String, crate::sessions::HistoryView>,
|
||||
/// The one session whose turn tree is expanded in the session list
|
||||
/// (lazygit-style accordion: expanding another session collapses this).
|
||||
pub expanded: Option<Expanded>,
|
||||
/// Set when the turn highlight moved: the next draw scrolls the feed to
|
||||
/// the highlighted turn's first entry (heights live in the render cache,
|
||||
/// so the key handler can't compute the offset itself).
|
||||
pub turn_dirty: bool,
|
||||
/// Session UUID of the embedded `claude` pane (src/term.rs), so the tap
|
||||
/// can recognise traffic belonging to it.
|
||||
///
|
||||
/// Invariant: one app instance = one proxy port = at most one embedded
|
||||
/// claude instance. Every other live session in `sessions` is an external
|
||||
/// claude pointed at our port — observable, but never attachable (the UI
|
||||
/// can only `--resume` it, which is guarded because the external instance
|
||||
/// may still be running).
|
||||
pub embed_session: Option<String>,
|
||||
/// True while the embedded session shows a large interactive prompt
|
||||
/// (AskUserQuestion/ExitPlanMode seen in the stream, answer not yet
|
||||
@@ -36,6 +88,18 @@ pub struct App {
|
||||
pub embed_clear_at: Option<Instant>,
|
||||
}
|
||||
|
||||
/// Turn-tree expansion state for one session in the session list.
|
||||
pub struct Expanded {
|
||||
pub uuid: String,
|
||||
pub tree: crate::sessions::TurnTree,
|
||||
/// Highlighted row as a position into `tree.display` (None = the session
|
||||
/// row itself is highlighted).
|
||||
pub sel: Option<usize>,
|
||||
/// Visual-mode anchor (display position); the selection is the
|
||||
/// contiguous display range anchor..=sel.
|
||||
pub visual: Option<usize>,
|
||||
}
|
||||
|
||||
/// Client-side tools that render a large interactive UI in Claude Code.
|
||||
fn is_interactive_tool(name: &str) -> bool {
|
||||
matches!(name, "AskUserQuestion" | "ExitPlanMode")
|
||||
@@ -76,22 +140,277 @@ impl App {
|
||||
status: "starting proxy…".into(),
|
||||
filters: [true; FILTER_LABELS.len()],
|
||||
filter_popup: None,
|
||||
model_popup: None,
|
||||
model_choices: default_model_choices(),
|
||||
show_sessions: true,
|
||||
disk_sessions: Vec::new(),
|
||||
history: HashMap::new(),
|
||||
expanded: None,
|
||||
turn_dirty: false,
|
||||
embed_session: None,
|
||||
embed_grow: false,
|
||||
embed_grow_rows: None,
|
||||
embed_clear_at: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Indices into `disk_sessions` that should appear as stubs: everything
|
||||
/// not already present as a live session (a live session *is* on disk —
|
||||
/// Claude Code writes the JSONL continuously — so dedupe by uuid).
|
||||
pub fn visible_stubs(&self) -> Vec<usize> {
|
||||
self.disk_sessions
|
||||
.iter()
|
||||
.enumerate()
|
||||
.filter(|(_, d)| !self.sessions.iter().any(|s| s.key == d.uuid))
|
||||
.map(|(i, _)| i)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Length of the merged selection list: live sessions first (indices
|
||||
/// stay stable — entries/sessions are append-only), then disk stubs.
|
||||
pub fn merged_len(&self) -> usize {
|
||||
self.sessions.len() + self.visible_stubs().len()
|
||||
}
|
||||
|
||||
/// Session uuid the merged selection currently points at (clamped).
|
||||
pub fn selected_key(&self) -> Option<String> {
|
||||
let sel = self.selected.min(self.merged_len().checked_sub(1)?);
|
||||
if let Some(s) = self.sessions.get(sel) {
|
||||
return Some(s.key.clone());
|
||||
}
|
||||
self.visible_stubs()
|
||||
.get(sel - self.sessions.len())
|
||||
.map(|&i| self.disk_sessions[i].uuid.clone())
|
||||
}
|
||||
|
||||
/// Point the merged selection at `key` (live session or disk stub).
|
||||
/// Returns false if the key is in neither list.
|
||||
pub fn select_key(&mut self, key: &str) -> bool {
|
||||
if let Some(i) = self.sessions.iter().position(|s| s.key == key) {
|
||||
self.selected = i;
|
||||
return true;
|
||||
}
|
||||
let stubs = self.visible_stubs();
|
||||
if let Some(pos) = stubs.iter().position(|&i| self.disk_sessions[i].uuid == key) {
|
||||
self.selected = self.sessions.len() + pos;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
}
|
||||
|
||||
/// True while the highlight is on a turn row of the expanded session.
|
||||
pub fn on_turns(&self) -> bool {
|
||||
let key = self.selected_key();
|
||||
self.expanded
|
||||
.as_ref()
|
||||
.is_some_and(|e| key.as_deref() == Some(e.uuid.as_str()) && e.sel.is_some())
|
||||
}
|
||||
|
||||
/// Move the turn highlight (and clear visual mode when leaving the turns).
|
||||
fn set_turn(&mut self, p: Option<usize>) {
|
||||
if let Some(e) = self.expanded.as_mut() {
|
||||
e.sel = p;
|
||||
if p.is_none() {
|
||||
e.visual = None;
|
||||
} else {
|
||||
self.turn_dirty = true;
|
||||
self.follow = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// One j/k step over the unified list: session rows plus the expanded
|
||||
/// session's turn rows (rendered directly under it). Clamped at both
|
||||
/// ends; visual mode pins the highlight inside the turn rows.
|
||||
pub fn nav(&mut self, down: bool) {
|
||||
let n = self.merged_len();
|
||||
if n == 0 {
|
||||
return;
|
||||
}
|
||||
self.selected = self.selected.min(n - 1);
|
||||
let key = self.selected_key();
|
||||
let on_exp = self
|
||||
.expanded
|
||||
.as_ref()
|
||||
.is_some_and(|e| key.as_deref() == Some(e.uuid.as_str()));
|
||||
let (len, tsel, vis) = match (&self.expanded, on_exp) {
|
||||
(Some(e), true) => (e.tree.display.len(), e.sel, e.visual.is_some()),
|
||||
_ => (0, None, false),
|
||||
};
|
||||
match (down, tsel) {
|
||||
(true, None) if on_exp && len > 0 => self.set_turn(Some(0)),
|
||||
(true, Some(p)) if p + 1 < len => self.set_turn(Some(p + 1)),
|
||||
(true, Some(_)) if vis => {} // visual: clamp inside the turns
|
||||
(true, _) => {
|
||||
if self.selected + 1 < n {
|
||||
self.selected += 1;
|
||||
self.set_turn(None);
|
||||
self.follow = true;
|
||||
}
|
||||
}
|
||||
(false, Some(p)) if p > 0 => self.set_turn(Some(p - 1)),
|
||||
(false, Some(_)) if vis => {}
|
||||
(false, Some(_)) => {
|
||||
// Top turn → back to the session row.
|
||||
self.set_turn(None);
|
||||
self.follow = true;
|
||||
}
|
||||
(false, None) => {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
self.follow = true;
|
||||
// The expanded session's turn rows sit between it and the
|
||||
// row we came from: entering from below lands on the last
|
||||
// turn, not the session row.
|
||||
let k2 = self.selected_key();
|
||||
let last = self
|
||||
.expanded
|
||||
.as_ref()
|
||||
.filter(|e| k2.as_deref() == Some(e.uuid.as_str()))
|
||||
.map(|e| e.tree.display.len())
|
||||
.filter(|&l| l > 0)
|
||||
.map(|l| l - 1);
|
||||
if last.is_some() {
|
||||
self.set_turn(last);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Drop any turn highlight / visual state (tab-style jumps call this so a
|
||||
/// stale highlight isn't revived when tabbing back onto the expansion).
|
||||
pub fn clear_turn_focus(&mut self) {
|
||||
self.set_turn(None);
|
||||
}
|
||||
|
||||
/// space: expand the selected session's turn tree / collapse it again.
|
||||
pub fn toggle_expand(&mut self) {
|
||||
let Some(key) = self.selected_key() else { return };
|
||||
if self.expanded.as_ref().is_some_and(|e| e.uuid == key) {
|
||||
self.expanded = None;
|
||||
return;
|
||||
}
|
||||
self.expand(key);
|
||||
}
|
||||
|
||||
fn expand(&mut self, key: String) {
|
||||
match crate::sessions::load_tree(&key) {
|
||||
Some(tree) => {
|
||||
self.expanded = Some(Expanded { uuid: key, tree, sel: None, visual: None });
|
||||
}
|
||||
None => self.status = "no turns on disk for this session yet".into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// →/l (yazi-style): expand the selected session, or step into its turns.
|
||||
pub fn tree_right(&mut self) {
|
||||
let Some(key) = self.selected_key() else { return };
|
||||
let on_exp = self.expanded.as_ref().is_some_and(|e| e.uuid == key);
|
||||
if !on_exp {
|
||||
self.expand(key);
|
||||
} else if self
|
||||
.expanded
|
||||
.as_ref()
|
||||
.is_some_and(|e| e.sel.is_none() && !e.tree.display.is_empty())
|
||||
{
|
||||
self.set_turn(Some(0));
|
||||
}
|
||||
}
|
||||
|
||||
/// ←/h: step out of the turns, or collapse the tree.
|
||||
pub fn tree_left(&mut self) {
|
||||
match &self.expanded {
|
||||
Some(e) if e.sel.is_some() => self.set_turn(None),
|
||||
Some(_) => self.expanded = None,
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
/// v: anchor / cancel visual mode on the highlighted turn.
|
||||
pub fn toggle_visual(&mut self) {
|
||||
if !self.on_turns() {
|
||||
self.status = "highlight a turn first (space/→ opens the tree)".into();
|
||||
return;
|
||||
}
|
||||
if let Some(e) = self.expanded.as_mut() {
|
||||
e.visual = match e.visual {
|
||||
Some(_) => None,
|
||||
None => e.sel,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/// b: materialize a new, fully decoupled session from the highlighted
|
||||
/// turn (its root→turn chain) or the visual selection (stitched range),
|
||||
/// inject it as the top stub and select it. The user starts it with the
|
||||
/// usual ctrl-↓ attach — branching itself never touches any process.
|
||||
pub fn branch_selected(&mut self) -> Result<String, String> {
|
||||
let key = self.selected_key();
|
||||
let Some(e) = self
|
||||
.expanded
|
||||
.as_ref()
|
||||
.filter(|e| key.as_deref() == Some(e.uuid.as_str()))
|
||||
else {
|
||||
return Err("highlight a turn first (space/→ opens the tree)".into());
|
||||
};
|
||||
let Some(p) = e.sel else {
|
||||
return Err("highlight a turn first (j/↓ moves into the tree)".into());
|
||||
};
|
||||
let mut idxs: Vec<usize> = match e.visual {
|
||||
// Visual range in display order → chronological turn order.
|
||||
Some(av) => {
|
||||
let (lo, hi) = (av.min(p), av.max(p));
|
||||
let mut v = e.tree.display[lo..=hi].to_vec();
|
||||
v.sort_unstable();
|
||||
v
|
||||
}
|
||||
None => e.tree.chain(e.tree.display[p]),
|
||||
};
|
||||
idxs.dedup();
|
||||
let title = format!("⑂ {}", e.tree.turns[*idxs.last().unwrap()].label);
|
||||
let new_uuid = crate::sessions::materialize(&e.tree, &idxs, &title)?;
|
||||
if let Some(e) = self.expanded.as_mut() {
|
||||
e.visual = None;
|
||||
e.sel = None; // the selection moves to the new stub
|
||||
}
|
||||
// Surface it immediately (the scanner would take up to ~1s); the next
|
||||
// scan sees the same file and keeps the selection by uuid.
|
||||
self.disk_sessions.insert(
|
||||
0,
|
||||
crate::sessions::DiskSession {
|
||||
uuid: new_uuid.clone(),
|
||||
label: title,
|
||||
modified: std::time::SystemTime::now(),
|
||||
},
|
||||
);
|
||||
self.select_key(&new_uuid);
|
||||
Ok(new_uuid)
|
||||
}
|
||||
|
||||
/// Swap in a fresh scan result, keeping a stub selection pointed at the
|
||||
/// same session even if the list reordered (scanner thread calls this).
|
||||
pub fn set_disk_sessions(&mut self, list: Vec<crate::sessions::DiskSession>) {
|
||||
let kept = self
|
||||
.selected
|
||||
.checked_sub(self.sessions.len())
|
||||
.and_then(|i| self.visible_stubs().get(i).copied())
|
||||
.map(|i| self.disk_sessions[i].uuid.clone());
|
||||
self.disk_sessions = list;
|
||||
if let Some(uuid) = kept {
|
||||
self.select_key(&uuid);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn filter_index(kind: &Kind) -> usize {
|
||||
match kind {
|
||||
Kind::Thinking => 0,
|
||||
Kind::Text => 1,
|
||||
Kind::Tool { .. } => 2,
|
||||
Kind::Meta => 3,
|
||||
Kind::Error => 4,
|
||||
Kind::User => 0,
|
||||
Kind::Thinking => 1,
|
||||
Kind::Text => 2,
|
||||
Kind::Tool { .. } => 3,
|
||||
Kind::Meta => 4,
|
||||
Kind::Error => 5,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -108,8 +427,26 @@ pub struct Session {
|
||||
pub tool_ids: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(key: String, model: String) -> Self {
|
||||
Self {
|
||||
key,
|
||||
model,
|
||||
entries: Vec::new(),
|
||||
active: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
last_activity: Instant::now(),
|
||||
tool_ids: HashMap::new(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq)]
|
||||
pub enum Kind {
|
||||
/// A user-submitted prompt, lifted from the request body (the trailing
|
||||
/// `user` message of a turn-starting request) — not part of the SSE stream.
|
||||
User,
|
||||
Thinking,
|
||||
Text,
|
||||
Tool { name: String },
|
||||
@@ -131,7 +468,7 @@ pub struct ToolResult {
|
||||
}
|
||||
|
||||
impl Entry {
|
||||
fn meta(content: String) -> Self {
|
||||
pub(crate) fn meta(content: String) -> Self {
|
||||
Self { kind: Kind::Meta, content, done: true, result: None }
|
||||
}
|
||||
}
|
||||
@@ -143,7 +480,7 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
|
||||
let Some(messages) = body.get("messages").and_then(Value::as_array) else {
|
||||
return;
|
||||
};
|
||||
let mut a = app.lock().unwrap();
|
||||
let mut a = lock_app(app);
|
||||
let is_embed = a.embed_session.as_deref() == Some(key);
|
||||
// Set when the answer to an interactive prompt comes back: the embedded
|
||||
// pane's prompt is gone, so the extra rows can be released.
|
||||
@@ -182,8 +519,76 @@ pub fn attach_tool_results(app: &SharedApp, key: &str, body: &Value) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Text blocks Claude Code injects around the user's words; not what the
|
||||
/// user typed, so they don't belong in a "user prompt" entry.
|
||||
pub(crate) fn is_injected_block(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
t.starts_with("<system-reminder>")
|
||||
|| t.starts_with("<command-name>")
|
||||
|| t.starts_with("<local-command")
|
||||
}
|
||||
|
||||
/// Record a user-submitted prompt from a request body as a feed entry.
|
||||
/// Purely passive (reads bytes already flowing through the proxy). Only the
|
||||
/// *trailing* user message counts: tool-loop continuations end in tool_result
|
||||
/// blocks and thus contribute no text, so exactly the turn-starting prompt
|
||||
/// lands here. Side requests (topic detection etc.) carry no `tools` and are
|
||||
/// skipped; retries/resends are deduped against the last recorded prompt.
|
||||
pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
|
||||
if body
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.is_none_or(Vec::is_empty)
|
||||
{
|
||||
return;
|
||||
}
|
||||
let Some(last) = body
|
||||
.get("messages")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|m| m.last())
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if last.get("role").and_then(Value::as_str) != Some("user") {
|
||||
return;
|
||||
}
|
||||
let text = match last.get("content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !is_injected_block(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => return,
|
||||
};
|
||||
let text = text.trim();
|
||||
if text.is_empty() {
|
||||
return;
|
||||
}
|
||||
let mut a = lock_app(app);
|
||||
let Some(s) = a.sessions.iter_mut().find(|s| s.key == key) else {
|
||||
return;
|
||||
};
|
||||
if s.entries
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|e| e.kind == Kind::User)
|
||||
.is_some_and(|e| e.content == text)
|
||||
{
|
||||
return;
|
||||
}
|
||||
s.entries.push(Entry {
|
||||
kind: Kind::User,
|
||||
content: text.to_string(),
|
||||
done: true,
|
||||
result: None,
|
||||
});
|
||||
}
|
||||
|
||||
/// tool_result content is either a plain string or an array of content blocks.
|
||||
fn flatten_result_content(v: Option<&Value>) -> String {
|
||||
pub fn flatten_result_content(v: Option<&Value>) -> String {
|
||||
match v {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
@@ -213,21 +618,17 @@ pub struct Tap {
|
||||
impl Tap {
|
||||
pub fn new(app: SharedApp, key: String, model: String) -> Self {
|
||||
let sidx = {
|
||||
let mut a = app.lock().unwrap();
|
||||
let mut a = lock_app(&app);
|
||||
let sidx = match a.sessions.iter().position(|s| s.key == key) {
|
||||
Some(i) => i,
|
||||
None => {
|
||||
a.sessions.push(Session {
|
||||
key,
|
||||
model: model.clone(),
|
||||
entries: Vec::new(),
|
||||
active: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
last_activity: Instant::now(),
|
||||
tool_ids: HashMap::new(),
|
||||
});
|
||||
a.sessions.len() - 1
|
||||
a.sessions.push(Session::new(key, model.clone()));
|
||||
let idx = a.sessions.len() - 1;
|
||||
// Auto-jump to every new session so a fresh `/clear` in
|
||||
// Claude Code is immediately visible without manual switching.
|
||||
a.selected = idx;
|
||||
a.follow = true;
|
||||
idx
|
||||
}
|
||||
};
|
||||
a.sessions[sidx].active += 1;
|
||||
@@ -238,7 +639,7 @@ impl Tap {
|
||||
}
|
||||
|
||||
pub fn handle(&mut self, ev: &str, d: &Value) {
|
||||
let mut a = self.app.lock().unwrap();
|
||||
let mut a = lock_app(&self.app);
|
||||
// Set when a completed block means the embedded pane is about to show
|
||||
// a big interactive prompt (checked against embed_session below).
|
||||
let mut interactive = false;
|
||||
@@ -288,8 +689,11 @@ impl Tap {
|
||||
}
|
||||
}
|
||||
other => {
|
||||
// Unknown block type: record a one-line meta entry but
|
||||
// do NOT set self.cur — we don't want subsequent delta
|
||||
// events appending content to a done Meta entry, and
|
||||
// content_block_stop would wrongly try to pretty-print it.
|
||||
s.entries.push(Entry::meta(format!("[{other}]")));
|
||||
self.cur = Some(s.entries.len() - 1);
|
||||
return;
|
||||
}
|
||||
};
|
||||
@@ -361,19 +765,21 @@ impl Tap {
|
||||
}
|
||||
// The embedded pane is about to render an interactive prompt
|
||||
// (wire-order guarantees this fires before Claude Code draws it):
|
||||
// ask the UI for more rows.
|
||||
// ask the UI for more rows, and cancel any pending transcript wipe so
|
||||
// the prompt is not cleared before the user can respond.
|
||||
if interactive
|
||||
&& a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str())
|
||||
{
|
||||
a.embed_grow = true;
|
||||
a.embed_grow_rows = grow_rows;
|
||||
a.embed_clear_at = None;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Tap {
|
||||
fn drop(&mut self) {
|
||||
let mut a = self.app.lock().unwrap();
|
||||
let mut a = lock_app(&self.app);
|
||||
let s = &mut a.sessions[self.sidx];
|
||||
s.active = s.active.saturating_sub(1);
|
||||
if let Some(i) = self.cur.take() {
|
||||
@@ -381,7 +787,12 @@ impl Drop for Tap {
|
||||
}
|
||||
// A turn of the embedded session just finished streaming: schedule a
|
||||
// transcript wipe shortly after Claude Code prints its final lines.
|
||||
if a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str()) {
|
||||
// Do NOT schedule while embed_grow is active — that means an interactive
|
||||
// prompt (AskUserQuestion / ExitPlanMode) is waiting for user input, and
|
||||
// a ctrl-l wipe would clear the prompt before the user can answer it.
|
||||
if a.embed_session.as_deref() == Some(a.sessions[self.sidx].key.as_str())
|
||||
&& !a.embed_grow
|
||||
{
|
||||
a.embed_clear_at = Some(Instant::now() + std::time::Duration::from_millis(400));
|
||||
}
|
||||
}
|
||||
@@ -460,6 +871,52 @@ mod tests {
|
||||
assert!(!app.lock().unwrap().embed_grow);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn user_prompt_recorded_once_and_injections_skipped() {
|
||||
let app: SharedApp = Arc::new(Mutex::new(App::new()));
|
||||
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into()));
|
||||
let body = json!({"tools": [{"name": "Bash"}], "messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "text", "text": "<system-reminder>noise</system-reminder>"},
|
||||
{"type": "text", "text": "fix the bug"}
|
||||
]}
|
||||
]});
|
||||
record_user_prompt(&app, "abc", &body);
|
||||
record_user_prompt(&app, "abc", &body); // resend → deduped
|
||||
{
|
||||
let a = app.lock().unwrap();
|
||||
let user: Vec<_> = a.sessions[0]
|
||||
.entries
|
||||
.iter()
|
||||
.filter(|e| e.kind == Kind::User)
|
||||
.collect();
|
||||
assert_eq!(user.len(), 1);
|
||||
assert_eq!(user[0].content, "fix the bug");
|
||||
}
|
||||
|
||||
// Tool-loop continuation (trailing tool_result, no text) → no entry.
|
||||
record_user_prompt(
|
||||
&app,
|
||||
"abc",
|
||||
&json!({"tools": [{"name": "Bash"}], "messages": [
|
||||
{"role": "user", "content": [
|
||||
{"type": "tool_result", "tool_use_id": "t1", "content": "ok"}
|
||||
]}
|
||||
]}),
|
||||
);
|
||||
// Side request without tools (topic detection etc.) → no entry.
|
||||
record_user_prompt(
|
||||
&app,
|
||||
"abc",
|
||||
&json!({"messages": [{"role": "user", "content": "fresh prompt"}]}),
|
||||
);
|
||||
let a = app.lock().unwrap();
|
||||
assert_eq!(
|
||||
a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count(),
|
||||
1
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_rows_scale_with_options() {
|
||||
let two = ask_question_rows(&json!({"questions": [
|
||||
@@ -488,6 +945,128 @@ mod tests {
|
||||
assert!(!app.lock().unwrap().embed_grow);
|
||||
}
|
||||
|
||||
fn ds(uuid: &str) -> crate::sessions::DiskSession {
|
||||
crate::sessions::DiskSession {
|
||||
uuid: uuid.into(),
|
||||
label: uuid.into(),
|
||||
modified: std::time::SystemTime::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn disk_stubs_dedupe_against_live_sessions() {
|
||||
let mut a = App::new();
|
||||
a.sessions.push(Session::new("aaa".into(), "m".into()));
|
||||
a.set_disk_sessions(vec![ds("aaa"), ds("bbb")]);
|
||||
// "aaa" is live → only "bbb" appears as a stub.
|
||||
assert_eq!(a.merged_len(), 2);
|
||||
assert!(a.select_key("bbb"));
|
||||
assert_eq!(a.selected, 1);
|
||||
assert_eq!(a.selected_key().as_deref(), Some("bbb"));
|
||||
assert!(a.select_key("aaa"));
|
||||
assert_eq!(a.selected, 0);
|
||||
assert!(!a.select_key("nope"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stub_selection_survives_rescan_reorder() {
|
||||
let mut a = App::new();
|
||||
a.sessions.push(Session::new("live".into(), "m".into()));
|
||||
a.set_disk_sessions(vec![ds("old"), ds("older")]);
|
||||
assert!(a.select_key("older"));
|
||||
// A new session file lands on top → "older" shifts down a slot.
|
||||
a.set_disk_sessions(vec![ds("new"), ds("old"), ds("older")]);
|
||||
assert_eq!(a.selected_key().as_deref(), Some("older"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn selected_key_resolves_live_then_stubs_and_clamps() {
|
||||
let mut a = App::new();
|
||||
assert_eq!(a.selected_key(), None);
|
||||
a.sessions.push(Session::new("live".into(), "m".into()));
|
||||
a.set_disk_sessions(vec![ds("disk")]);
|
||||
a.selected = 99; // clamped to the last merged slot
|
||||
assert_eq!(a.selected_key().as_deref(), Some("disk"));
|
||||
}
|
||||
|
||||
/// Three linear turns: a → b → c (no forks).
|
||||
fn linear_tree() -> crate::sessions::TurnTree {
|
||||
let rec = |uuid: &str, parent: Option<&str>, text: &str| {
|
||||
serde_json::json!({
|
||||
"type": "user", "uuid": uuid, "parentUuid": parent,
|
||||
"message": {"role": "user", "content": text}
|
||||
})
|
||||
.to_string()
|
||||
};
|
||||
crate::sessions::build_tree(vec![
|
||||
rec("u1", None, "a"),
|
||||
rec("u2", Some("u1"), "b"),
|
||||
rec("u3", Some("u2"), "c"),
|
||||
])
|
||||
}
|
||||
|
||||
fn turn_sel(a: &App) -> Option<usize> {
|
||||
a.expanded.as_ref().and_then(|e| e.sel)
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn nav_walks_through_expanded_turns() {
|
||||
let mut a = App::new();
|
||||
a.set_disk_sessions(vec![ds("top"), ds("mid"), ds("bot")]);
|
||||
a.select_key("mid");
|
||||
a.expanded = Some(Expanded {
|
||||
uuid: "mid".into(),
|
||||
tree: linear_tree(),
|
||||
sel: None,
|
||||
visual: None,
|
||||
});
|
||||
// Down: session row → its three turns → the next session row.
|
||||
a.nav(true);
|
||||
assert_eq!(turn_sel(&a), Some(0));
|
||||
assert!(a.turn_dirty, "turn highlight schedules a feed scroll");
|
||||
a.nav(true);
|
||||
a.nav(true);
|
||||
assert_eq!(turn_sel(&a), Some(2));
|
||||
a.nav(true);
|
||||
assert_eq!(turn_sel(&a), None);
|
||||
assert_eq!(a.selected_key().as_deref(), Some("bot"));
|
||||
// Up from below re-enters the tree at its *last* turn.
|
||||
a.nav(false);
|
||||
assert_eq!(a.selected_key().as_deref(), Some("mid"));
|
||||
assert_eq!(turn_sel(&a), Some(2));
|
||||
a.nav(false);
|
||||
a.nav(false);
|
||||
a.nav(false);
|
||||
assert_eq!(turn_sel(&a), None, "top turn exits to the session row");
|
||||
assert_eq!(a.selected_key().as_deref(), Some("mid"));
|
||||
a.nav(false);
|
||||
assert_eq!(a.selected_key().as_deref(), Some("top"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn visual_mode_pins_highlight_inside_turns() {
|
||||
let mut a = App::new();
|
||||
a.set_disk_sessions(vec![ds("only")]);
|
||||
a.select_key("only");
|
||||
a.expanded = Some(Expanded {
|
||||
uuid: "only".into(),
|
||||
tree: linear_tree(),
|
||||
sel: Some(1),
|
||||
visual: None,
|
||||
});
|
||||
a.toggle_visual();
|
||||
assert_eq!(a.expanded.as_ref().unwrap().visual, Some(1));
|
||||
a.nav(true);
|
||||
a.nav(true); // clamped at the last turn, must not leave the tree
|
||||
assert_eq!(turn_sel(&a), Some(2));
|
||||
a.nav(false);
|
||||
a.nav(false);
|
||||
a.nav(false); // clamped at the first turn
|
||||
assert_eq!(turn_sel(&a), Some(0));
|
||||
a.toggle_visual();
|
||||
assert_eq!(a.expanded.as_ref().unwrap().visual, None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_tool_id_is_ignored() {
|
||||
let app: SharedApp = Arc::new(Mutex::new(App::new()));
|
||||
|
||||
25
src/main.rs
25
src/main.rs
@@ -1,5 +1,7 @@
|
||||
mod app;
|
||||
mod markdown;
|
||||
mod proxy;
|
||||
mod sessions;
|
||||
mod sse;
|
||||
mod term;
|
||||
mod ui;
|
||||
@@ -8,10 +10,6 @@ use app::App;
|
||||
use std::sync::{Arc, Mutex};
|
||||
|
||||
fn main() -> anyhow::Result<()> {
|
||||
let port: u16 = std::env::var("CT_PORT")
|
||||
.ok()
|
||||
.and_then(|p| p.parse().ok())
|
||||
.unwrap_or(8484);
|
||||
let headless = std::env::args().any(|a| a == "--headless");
|
||||
|
||||
let app = Arc::new(Mutex::new(App::new()));
|
||||
@@ -19,11 +17,24 @@ fn main() -> anyhow::Result<()> {
|
||||
.enable_all()
|
||||
.build()?;
|
||||
|
||||
// Bind up front so every instance gets its own port: CT_PORT pins it
|
||||
// (hard error if taken), otherwise prefer 8484 and fall back to an
|
||||
// OS-assigned free port so multiple instances can coexist.
|
||||
let listener = rt.block_on(async {
|
||||
match std::env::var("CT_PORT").ok().and_then(|p| p.parse::<u16>().ok()) {
|
||||
Some(p) => tokio::net::TcpListener::bind(("127.0.0.1", p)).await,
|
||||
None => match tokio::net::TcpListener::bind(("127.0.0.1", 8484u16)).await {
|
||||
Ok(l) => Ok(l),
|
||||
Err(_) => tokio::net::TcpListener::bind(("127.0.0.1", 0u16)).await,
|
||||
},
|
||||
}
|
||||
})?;
|
||||
let port = listener.local_addr()?.port();
|
||||
|
||||
let papp = app.clone();
|
||||
let proxy_handle = rt.spawn(async move {
|
||||
if let Err(e) = proxy::run(papp.clone(), port).await {
|
||||
let mut a = papp.lock().unwrap();
|
||||
a.status = format!("proxy failed: {e}");
|
||||
if let Err(e) = proxy::run(papp.clone(), listener).await {
|
||||
app::lock_app(&papp).status = format!("proxy failed: {e}");
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
439
src/markdown.rs
Normal file
439
src/markdown.rs
Normal file
@@ -0,0 +1,439 @@
|
||||
//! Markdown → ratatui lines, wrapping the pinned `tui-markdown =0.3.5`.
|
||||
//!
|
||||
//! tui-markdown 0.3.5 never enables pulldown-cmark's table extension, so GFM
|
||||
//! pipe tables fall through as raw paragraph text, and it keeps the literal
|
||||
//! `### ` markers on headings. This module splits table blocks out of the
|
||||
//! text, renders them itself (box-drawing borders, width-fitted columns,
|
||||
//! word-wrapped cells — matching Claude Code's own table style), routes the
|
||||
//! rest through tui-markdown, and strips heading markers from its output.
|
||||
//!
|
||||
//! Re-rendered every frame on partial content: a table only renders as a
|
||||
//! table once its header + separator row have streamed in, so half-received
|
||||
//! tables show as plain text and self-heal, like the rest of the markdown.
|
||||
|
||||
use ratatui::style::{Modifier, Style, Stylize};
|
||||
use ratatui::text::{Line, Span};
|
||||
|
||||
const MIN_COL: usize = 3;
|
||||
|
||||
pub fn render<'a>(content: &'a str, width: u16) -> Vec<Line<'a>> {
|
||||
// Byte bounds of each line (end excludes the newline).
|
||||
let mut bounds: Vec<(usize, usize)> = Vec::new();
|
||||
let mut start = 0;
|
||||
for (i, b) in content.bytes().enumerate() {
|
||||
if b == b'\n' {
|
||||
bounds.push((start, i));
|
||||
start = i + 1;
|
||||
}
|
||||
}
|
||||
bounds.push((start, content.len()));
|
||||
|
||||
let line_at = |i: usize| &content[bounds[i].0..bounds[i].1];
|
||||
let mut out: Vec<Line<'a>> = Vec::new();
|
||||
let mut plain_from = 0usize;
|
||||
let mut i = 0;
|
||||
while i < bounds.len() {
|
||||
let table_start = is_pipe_row(line_at(i))
|
||||
&& i + 1 < bounds.len()
|
||||
&& is_separator_row(line_at(i + 1));
|
||||
if !table_start {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
push_plain(&mut out, &content[plain_from..bounds[i].0]);
|
||||
let mut j = i + 1;
|
||||
while j + 1 < bounds.len() && is_pipe_row(line_at(j + 1)) {
|
||||
j += 1;
|
||||
}
|
||||
if out.last().is_some_and(|l| l.width() > 0) {
|
||||
out.push(Line::default());
|
||||
}
|
||||
let rows: Vec<&str> = (i..=j).map(line_at).collect();
|
||||
render_table(&rows, width, &mut out);
|
||||
// Skip past the newline terminating the last table row, so the
|
||||
// following plain chunk doesn't start with a stray blank line
|
||||
// (push_plain adds its own separator).
|
||||
plain_from = (bounds[j].1 + 1).min(content.len());
|
||||
i = j + 1;
|
||||
}
|
||||
push_plain(&mut out, &content[plain_from.min(content.len())..]);
|
||||
out
|
||||
}
|
||||
|
||||
fn push_plain<'a>(out: &mut Vec<Line<'a>>, chunk: &'a str) {
|
||||
if chunk.trim().is_empty() {
|
||||
return;
|
||||
}
|
||||
if out.last().is_some_and(|l| l.width() > 0) {
|
||||
out.push(Line::default());
|
||||
}
|
||||
for mut line in tui_markdown::from_str(chunk).lines {
|
||||
strip_heading_marker(&mut line);
|
||||
out.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
/// tui-markdown emits headings as `["### ", "Heading"]` with a line-level
|
||||
/// heading style; drop the literal marker span, keep the styling.
|
||||
fn strip_heading_marker(line: &mut Line<'_>) {
|
||||
let is_marker = line.spans.first().is_some_and(|s| {
|
||||
let c = s.content.as_ref();
|
||||
c.len() >= 2 && c.ends_with(' ') && c[..c.len() - 1].bytes().all(|b| b == b'#')
|
||||
});
|
||||
if is_marker && line.style != Style::default() {
|
||||
line.spans.remove(0);
|
||||
}
|
||||
}
|
||||
|
||||
fn is_pipe_row(line: &str) -> bool {
|
||||
line.trim_start().starts_with('|')
|
||||
}
|
||||
|
||||
fn is_separator_row(line: &str) -> bool {
|
||||
let t = line.trim();
|
||||
t.starts_with('|')
|
||||
&& t.contains('-')
|
||||
&& t.chars().all(|c| matches!(c, '|' | '-' | ':' | ' ' | '\t'))
|
||||
}
|
||||
|
||||
fn split_row(line: &str) -> Vec<&str> {
|
||||
let t = line.trim();
|
||||
let t = t.strip_prefix('|').unwrap_or(t);
|
||||
let t = t.strip_suffix('|').unwrap_or(t);
|
||||
t.split('|').map(str::trim).collect()
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, PartialEq)]
|
||||
enum Align {
|
||||
Left,
|
||||
Center,
|
||||
Right,
|
||||
}
|
||||
|
||||
fn align_of(sep_cell: &str) -> Align {
|
||||
match (sep_cell.starts_with(':'), sep_cell.ends_with(':')) {
|
||||
(true, true) => Align::Center,
|
||||
(false, true) => Align::Right,
|
||||
_ => Align::Left,
|
||||
}
|
||||
}
|
||||
|
||||
/// A table cell, pre-tokenized into wrap units. A "word" can span style
|
||||
/// boundaries (e.g. `**bold**suffix`) without breaking mid-word.
|
||||
#[derive(Default)]
|
||||
struct Cell {
|
||||
words: Vec<Word>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct Word {
|
||||
parts: Vec<Span<'static>>,
|
||||
}
|
||||
|
||||
impl Word {
|
||||
fn width(&self) -> usize {
|
||||
self.parts.iter().map(Span::width).sum()
|
||||
}
|
||||
}
|
||||
|
||||
impl Cell {
|
||||
/// Width when laid out on a single line.
|
||||
fn natural(&self) -> usize {
|
||||
let w: usize = self.words.iter().map(Word::width).sum();
|
||||
w + self.words.len().saturating_sub(1)
|
||||
}
|
||||
|
||||
/// Greedy word-wrap to `width`; overlong words hard-break.
|
||||
fn wrap(&self, width: usize) -> Vec<Line<'static>> {
|
||||
let width = width.max(1);
|
||||
let mut lines: Vec<Vec<Span<'static>>> = Vec::new();
|
||||
let mut cur: Vec<Span<'static>> = Vec::new();
|
||||
let mut cur_w = 0usize;
|
||||
for word in &self.words {
|
||||
let ww = word.width();
|
||||
let sep = usize::from(cur_w > 0);
|
||||
if cur_w + sep + ww <= width {
|
||||
if sep == 1 {
|
||||
cur.push(Span::raw(" "));
|
||||
}
|
||||
cur.extend(word.parts.iter().cloned());
|
||||
cur_w += sep + ww;
|
||||
} else if ww <= width {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur.extend(word.parts.iter().cloned());
|
||||
cur_w = ww;
|
||||
} else {
|
||||
if !cur.is_empty() {
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur_w = 0;
|
||||
}
|
||||
for part in &word.parts {
|
||||
let mut buf = String::new();
|
||||
for ch in part.content.chars() {
|
||||
let chw = Span::raw(ch.to_string()).width();
|
||||
if cur_w + chw > width {
|
||||
if !buf.is_empty() {
|
||||
cur.push(Span::styled(std::mem::take(&mut buf), part.style));
|
||||
}
|
||||
lines.push(std::mem::take(&mut cur));
|
||||
cur_w = 0;
|
||||
}
|
||||
buf.push(ch);
|
||||
cur_w += chw;
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
cur.push(Span::styled(buf, part.style));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if !cur.is_empty() || lines.is_empty() {
|
||||
lines.push(cur);
|
||||
}
|
||||
lines.into_iter().map(Line::from).collect()
|
||||
}
|
||||
}
|
||||
|
||||
/// Parse one pipe row into cells with inline markdown rendered.
|
||||
fn cells_of(row: &str, header: bool) -> Vec<Cell> {
|
||||
split_row(row)
|
||||
.into_iter()
|
||||
.map(|raw| {
|
||||
let mut cell = Cell::default();
|
||||
let mut open = false; // current word continues across spans
|
||||
for sp in tui_markdown::from_str(raw)
|
||||
.lines
|
||||
.into_iter()
|
||||
.flat_map(|l| l.spans)
|
||||
{
|
||||
let style = if header {
|
||||
sp.style.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
sp.style
|
||||
};
|
||||
let mut buf = String::new();
|
||||
for ch in sp.content.chars() {
|
||||
if ch.is_whitespace() {
|
||||
if !buf.is_empty() {
|
||||
add_part(&mut cell, &mut open, std::mem::take(&mut buf), style);
|
||||
}
|
||||
open = false;
|
||||
} else {
|
||||
buf.push(ch);
|
||||
}
|
||||
}
|
||||
if !buf.is_empty() {
|
||||
add_part(&mut cell, &mut open, buf, style);
|
||||
}
|
||||
}
|
||||
cell
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn add_part(cell: &mut Cell, open: &mut bool, text: String, style: Style) {
|
||||
if !*open {
|
||||
cell.words.push(Word::default());
|
||||
*open = true;
|
||||
}
|
||||
cell.words
|
||||
.last_mut()
|
||||
.expect("word pushed above")
|
||||
.parts
|
||||
.push(Span::styled(text, style));
|
||||
}
|
||||
|
||||
fn render_table<'a>(rows: &[&str], width: u16, out: &mut Vec<Line<'a>>) {
|
||||
let header = cells_of(rows[0], true);
|
||||
let aligns: Vec<Align> = split_row(rows[1]).iter().map(|c| align_of(c)).collect();
|
||||
let body: Vec<Vec<Cell>> = rows[2..].iter().map(|r| cells_of(r, false)).collect();
|
||||
let cols = header
|
||||
.len()
|
||||
.max(body.iter().map(Vec::len).max().unwrap_or(0));
|
||||
if cols == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut natural = vec![1usize; cols];
|
||||
for (k, c) in header.iter().enumerate() {
|
||||
natural[k] = natural[k].max(c.natural());
|
||||
}
|
||||
for row in &body {
|
||||
for (k, c) in row.iter().enumerate() {
|
||||
natural[k] = natural[k].max(c.natural());
|
||||
}
|
||||
}
|
||||
|
||||
// chrome: cols+1 border glyphs + 2 padding spaces per column
|
||||
let avail = (width as usize).saturating_sub(cols + 1 + 2 * cols);
|
||||
if avail < cols * MIN_COL {
|
||||
// Too narrow to draw a table at all; emit the raw rows.
|
||||
for r in rows {
|
||||
out.push(Line::from((*r).to_owned()));
|
||||
}
|
||||
return;
|
||||
}
|
||||
let widths = fit(&natural, avail);
|
||||
|
||||
out.push(border(&widths, '┌', '┬', '┐'));
|
||||
push_row(out, &header, &widths, &[Align::Center].repeat(cols));
|
||||
out.push(border(&widths, '├', '┼', '┤'));
|
||||
for row in &body {
|
||||
push_row(out, row, &widths, &aligns);
|
||||
}
|
||||
out.push(border(&widths, '└', '┴', '┘'));
|
||||
}
|
||||
|
||||
/// Fit natural column widths into `avail`, shrinking proportionally with a
|
||||
/// floor of `MIN_COL`, then nudging to use the space exactly when shrunk.
|
||||
fn fit(natural: &[usize], avail: usize) -> Vec<usize> {
|
||||
let total: usize = natural.iter().sum();
|
||||
if total <= avail {
|
||||
return natural.to_vec();
|
||||
}
|
||||
let mut w: Vec<usize> = natural
|
||||
.iter()
|
||||
.map(|&n| (n * avail / total).clamp(MIN_COL, n.max(MIN_COL)))
|
||||
.collect();
|
||||
let mut sum: usize = w.iter().sum();
|
||||
while sum > avail {
|
||||
match (0..w.len()).filter(|&k| w[k] > MIN_COL).max_by_key(|&k| w[k]) {
|
||||
Some(k) => {
|
||||
w[k] -= 1;
|
||||
sum -= 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
while sum < avail {
|
||||
match (0..w.len())
|
||||
.filter(|&k| w[k] < natural[k])
|
||||
.max_by_key(|&k| natural[k] - w[k])
|
||||
{
|
||||
Some(k) => {
|
||||
w[k] += 1;
|
||||
sum += 1;
|
||||
}
|
||||
None => break,
|
||||
}
|
||||
}
|
||||
w
|
||||
}
|
||||
|
||||
fn border(widths: &[usize], l: char, m: char, r: char) -> Line<'static> {
|
||||
let mut s = String::new();
|
||||
s.push(l);
|
||||
for (k, w) in widths.iter().enumerate() {
|
||||
if k > 0 {
|
||||
s.push(m);
|
||||
}
|
||||
for _ in 0..w + 2 {
|
||||
s.push('─');
|
||||
}
|
||||
}
|
||||
s.push(r);
|
||||
Line::from(Span::from(s).dark_gray())
|
||||
}
|
||||
|
||||
fn push_row<'a>(out: &mut Vec<Line<'a>>, cells: &[Cell], widths: &[usize], aligns: &[Align]) {
|
||||
let empty = Cell::default();
|
||||
let wrapped: Vec<Vec<Line<'static>>> = (0..widths.len())
|
||||
.map(|k| cells.get(k).unwrap_or(&empty).wrap(widths[k]))
|
||||
.collect();
|
||||
let height = wrapped.iter().map(Vec::len).max().unwrap_or(1).max(1);
|
||||
for r in 0..height {
|
||||
let mut spans: Vec<Span<'static>> = vec![Span::from("│").dark_gray()];
|
||||
for k in 0..widths.len() {
|
||||
spans.push(Span::raw(" "));
|
||||
let line = wrapped[k].get(r);
|
||||
let lw = line.map(Line::width).unwrap_or(0);
|
||||
let pad = widths[k].saturating_sub(lw);
|
||||
let (lp, rp) = match aligns.get(k).copied().unwrap_or(Align::Left) {
|
||||
Align::Left => (0, pad),
|
||||
Align::Right => (pad, 0),
|
||||
Align::Center => (pad / 2, pad - pad / 2),
|
||||
};
|
||||
if lp > 0 {
|
||||
spans.push(Span::raw(" ".repeat(lp)));
|
||||
}
|
||||
if let Some(l) = line {
|
||||
spans.extend(l.spans.iter().cloned());
|
||||
}
|
||||
if rp > 0 {
|
||||
spans.push(Span::raw(" ".repeat(rp)));
|
||||
}
|
||||
spans.push(Span::raw(" "));
|
||||
spans.push(Span::from("│").dark_gray());
|
||||
}
|
||||
out.push(Line::from(spans));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn flat(lines: &[Line]) -> Vec<String> {
|
||||
lines
|
||||
.iter()
|
||||
.map(|l| l.spans.iter().map(|s| s.content.as_ref()).collect())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn table_renders_with_borders() {
|
||||
let md = "| A | B |\n|---|---|\n| 1 | 22 |\n";
|
||||
let got = flat(&render(md, 40));
|
||||
assert_eq!(
|
||||
got,
|
||||
vec![
|
||||
"┌───┬────┐",
|
||||
"│ A │ B │",
|
||||
"├───┼────┤",
|
||||
"│ 1 │ 22 │",
|
||||
"└───┴────┘",
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn narrow_table_wraps_cells_within_width() {
|
||||
let md = "| Option | Notes |\n|---|---|\n| Delayed data | Far fewer licensing restrictions apply here |\n";
|
||||
let width = 30u16;
|
||||
let lines = render(md, width);
|
||||
assert!(lines.iter().all(|l| l.width() <= width as usize));
|
||||
// still a table, not raw text
|
||||
assert!(flat(&lines)[0].starts_with('┌'));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn partial_table_stays_plain_until_separator_arrives() {
|
||||
// Header only — no separator row yet (mid-stream).
|
||||
let got = flat(&render("| A | B |", 40));
|
||||
assert_eq!(got, vec!["| A | B |"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn text_around_table_is_preserved_and_spaced() {
|
||||
let md = "before\n\n| A |\n|---|\n| 1 |\n\nafter";
|
||||
let got = flat(&render(md, 40));
|
||||
assert_eq!(got.first().map(String::as_str), Some("before"));
|
||||
assert_eq!(got.last().map(String::as_str), Some("after"));
|
||||
assert!(got.contains(&"".to_string())); // blank separator lines
|
||||
assert!(got.iter().any(|l| l.starts_with('┌')));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn heading_markers_are_stripped() {
|
||||
let got = flat(&render("### Your realistic options:", 80));
|
||||
assert_eq!(got, vec!["Your realistic options:"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn alignment_from_separator() {
|
||||
let md = "| Lhead | Rhead |\n|:------|------:|\n| a | b |\n";
|
||||
let got = flat(&render(md, 30));
|
||||
// body row: left col flush left, right col flush right
|
||||
assert_eq!(got[3], "│ a │ b │");
|
||||
}
|
||||
}
|
||||
95
src/proxy.rs
95
src/proxy.rs
@@ -1,4 +1,4 @@
|
||||
use crate::app::{attach_tool_results, SharedApp, Tap};
|
||||
use crate::app::{attach_tool_results, lock_app, record_user_prompt, SharedApp, Tap};
|
||||
use crate::sse::SseParser;
|
||||
use axum::body::Body;
|
||||
use axum::extract::{Request, State};
|
||||
@@ -10,14 +10,22 @@ use serde_json::Value;
|
||||
|
||||
const UPSTREAM: &str = "https://api.anthropic.com";
|
||||
|
||||
/// Upstream base URL; `CT_UPSTREAM` overrides for offline testing against a
|
||||
/// fake server (the relay itself is identical either way).
|
||||
fn upstream() -> String {
|
||||
std::env::var("CT_UPSTREAM").unwrap_or_else(|_| UPSTREAM.to_string())
|
||||
}
|
||||
|
||||
/// Extract the session UUID from `metadata.user_id`. Claude Code ≥2.1.x
|
||||
/// sends a JSON-ish blob containing `"session_id":"<uuid>"`; older builds
|
||||
/// used `user_<hash>_account_<uuid>_session_<uuid>`.
|
||||
fn session_key(u: &str) -> Option<&str> {
|
||||
if let Some(rest) = u.split(r#"session_id":""#).nth(1) {
|
||||
return rest.split('"').next();
|
||||
return rest.split('"').next().filter(|s| !s.is_empty());
|
||||
}
|
||||
u.split("session_").nth(1)
|
||||
// `.filter`: a trailing "session_" would otherwise yield Some("") — an
|
||||
// empty key that every malformed user_id would then collide on.
|
||||
u.split("session_").nth(1).filter(|s| !s.is_empty())
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
@@ -26,13 +34,12 @@ struct Ctx {
|
||||
app: SharedApp,
|
||||
}
|
||||
|
||||
pub async fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
|
||||
pub async fn run(app: SharedApp, listener: tokio::net::TcpListener) -> anyhow::Result<()> {
|
||||
let client = reqwest::Client::builder().build()?;
|
||||
let ctx = Ctx { client, app: app.clone() };
|
||||
let router = Router::new().fallback(forward).with_state(ctx);
|
||||
let listener = tokio::net::TcpListener::bind(("127.0.0.1", port)).await?;
|
||||
app.lock().unwrap().status =
|
||||
format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
|
||||
let port = listener.local_addr()?.port();
|
||||
lock_app(&app).status = format!("proxy http://127.0.0.1:{port} → api.anthropic.com");
|
||||
axum::serve(listener, router).await?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -42,7 +49,7 @@ async fn forward(State(ctx): State<Ctx>, req: Request) -> Response {
|
||||
match forward_inner(ctx, req).await {
|
||||
Ok(r) => r,
|
||||
Err(e) => {
|
||||
app.lock().unwrap().status = format!("upstream error: {e}");
|
||||
lock_app(&app).status = format!("upstream error: {e}");
|
||||
Response::builder()
|
||||
.status(502)
|
||||
.body(Body::from(format!("claude-thinking proxy error: {e}")))
|
||||
@@ -61,30 +68,32 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
.path_and_query()
|
||||
.map(|p| p.as_str().to_owned())
|
||||
.unwrap_or_else(|| "/".into());
|
||||
let url = format!("{UPSTREAM}{pq}");
|
||||
let url = format!("{}{pq}", upstream());
|
||||
|
||||
// Identify streaming /v1/messages requests and attach a tap.
|
||||
let mut tap: Option<Tap> = None;
|
||||
if parts.method == Method::POST && pq.starts_with("/v1/messages") {
|
||||
if let Ok(v) = serde_json::from_slice::<Value>(&body_bytes) {
|
||||
if v.get("stream").and_then(Value::as_bool).unwrap_or(false) {
|
||||
let model = v
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
let key = v
|
||||
.pointer("/metadata/user_id")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(session_key)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
// Tool results ride along in the request body; surface them
|
||||
// on the tool entries from the previous turn.
|
||||
attach_tool_results(&ctx.app, &key, &v);
|
||||
tap = Some(Tap::new(ctx.app.clone(), key, model));
|
||||
}
|
||||
}
|
||||
if parts.method == Method::POST
|
||||
&& pq.starts_with("/v1/messages")
|
||||
&& let Ok(v) = serde_json::from_slice::<Value>(&body_bytes)
|
||||
&& v.get("stream").and_then(Value::as_bool).unwrap_or(false)
|
||||
{
|
||||
let model = v
|
||||
.get("model")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("?")
|
||||
.to_string();
|
||||
let key = v
|
||||
.pointer("/metadata/user_id")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(session_key)
|
||||
.unwrap_or("unknown")
|
||||
.to_string();
|
||||
// Tool results ride along in the request body; surface them
|
||||
// on the tool entries from the previous turn.
|
||||
attach_tool_results(&ctx.app, &key, &v);
|
||||
tap = Some(Tap::new(ctx.app.clone(), key.clone(), model));
|
||||
// After Tap::new: the session must exist for the entry to land.
|
||||
record_user_prompt(&ctx.app, &key, &v);
|
||||
}
|
||||
|
||||
let mut rb = ctx.client.request(parts.method.clone(), &url);
|
||||
@@ -120,15 +129,32 @@ async fn forward_inner(ctx: Ctx, req: Request) -> anyhow::Result<Response> {
|
||||
}
|
||||
|
||||
// Stream the body back unbuffered; tee SSE bytes into the parser.
|
||||
//
|
||||
// The tap (mutex-protected shared state) is driven from a dedicated
|
||||
// tokio task so it never delays the forwarded bytes. The stream
|
||||
// closure clones each chunk into a bounded mpsc channel via try_send
|
||||
// (drop-on-full, best-effort per the tee invariant); the tap task
|
||||
// drains that channel independently, so the forwarded byte stream is
|
||||
// never held back by the app mutex.
|
||||
let body = match (is_sse, tap) {
|
||||
(true, Some(mut tap)) => {
|
||||
let mut parser = SseParser::default();
|
||||
let stream = resp.bytes_stream().map(move |chunk| {
|
||||
if let Ok(b) = &chunk {
|
||||
for (ev, data) in parser.feed(b) {
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<Vec<u8>>(64);
|
||||
tokio::spawn(async move {
|
||||
let mut parser = SseParser::default();
|
||||
while let Some(b) = rx.recv().await {
|
||||
for (ev, data) in parser.feed(&b) {
|
||||
tap.handle(&ev, &data);
|
||||
}
|
||||
}
|
||||
// rx closed means the stream ended; tap drops here, running
|
||||
// its Drop impl (marks last entry done, schedules pane clear).
|
||||
});
|
||||
let stream = resp.bytes_stream().map(move |chunk| {
|
||||
if let Ok(b) = &chunk {
|
||||
// Best-effort: if the tap task has fallen behind and the
|
||||
// channel is full, drop the copy rather than stall bytes.
|
||||
let _ = tx.try_send(b.to_vec());
|
||||
}
|
||||
chunk
|
||||
});
|
||||
Body::from_stream(stream)
|
||||
@@ -155,5 +181,8 @@ mod tests {
|
||||
Some("29bd3436-aaaa-bbbb-cccc-111122223333")
|
||||
);
|
||||
assert_eq!(session_key("no session here"), None);
|
||||
// Degenerate inputs must not produce an empty (colliding) key.
|
||||
assert_eq!(session_key("user_x_session_"), None);
|
||||
assert_eq!(session_key(r#"{"session_id":""}"#), None);
|
||||
}
|
||||
}
|
||||
|
||||
825
src/sessions.rs
Normal file
825
src/sessions.rs
Normal file
@@ -0,0 +1,825 @@
|
||||
//! Past-session discovery + the on-disk turn tree: keeps the session list
|
||||
//! populated with this directory's history, parses a session's JSONL into a
|
||||
//! tree of *turns* (Claude Code records carry `uuid`/`parentUuid`, and every
|
||||
//! Esc-Esc rewind leaves a branch point behind), and materializes new session
|
||||
//! files from a chosen set of turns (branching / cherry-picking).
|
||||
//!
|
||||
//! Claude Code stores one `.jsonl` file per session under
|
||||
//! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every `/`
|
||||
//! with `-` (the leading `/` becomes a leading `-`).
|
||||
|
||||
use crate::app::{
|
||||
flatten_result_content, is_injected_block, lock_app, Entry, Kind, Session, SharedApp,
|
||||
ToolResult,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::collections::HashMap;
|
||||
use std::io::BufRead;
|
||||
use std::path::PathBuf;
|
||||
use std::time::SystemTime;
|
||||
|
||||
#[derive(Clone, PartialEq)]
|
||||
pub struct DiskSession {
|
||||
pub uuid: String,
|
||||
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
|
||||
pub label: String,
|
||||
pub modified: SystemTime,
|
||||
}
|
||||
|
||||
/// Background scanner: keeps `App::disk_sessions` in sync with the project
|
||||
/// directory so the UI never does disk I/O for the session list. Polls ~1/s;
|
||||
/// labels are re-read only for files whose mtime changed, so the steady-state
|
||||
/// cost is one `read_dir` + a stat per file. The app mutex is only taken when
|
||||
/// the list actually changed.
|
||||
pub fn spawn_scanner(app: SharedApp) {
|
||||
std::thread::spawn(move || {
|
||||
// uuid → (mtime when read, label): skip re-parsing unchanged files.
|
||||
let mut labels: HashMap<String, (SystemTime, String)> = HashMap::new();
|
||||
let mut last: Vec<DiskSession> = Vec::new();
|
||||
loop {
|
||||
let list = scan(&mut labels).unwrap_or_default();
|
||||
if list != last {
|
||||
last = list.clone();
|
||||
lock_app(&app).set_disk_sessions(list);
|
||||
}
|
||||
std::thread::sleep(std::time::Duration::from_secs(1));
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// One scan of the project directory, newest first.
|
||||
fn scan(
|
||||
labels: &mut HashMap<String, (SystemTime, String)>,
|
||||
) -> Result<Vec<DiskSession>, String> {
|
||||
let dir = project_dir()?;
|
||||
let rd = std::fs::read_dir(&dir)
|
||||
.map_err(|e| format!("cannot read {}: {e}", dir.display()))?;
|
||||
let mut sessions: Vec<DiskSession> = rd
|
||||
.flatten()
|
||||
.filter_map(|e| {
|
||||
let path = e.path();
|
||||
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
|
||||
return None;
|
||||
}
|
||||
let uuid = path.file_stem()?.to_str()?.to_string();
|
||||
let modified = e.metadata().ok()?.modified().ok()?;
|
||||
let label = match labels.get(&uuid) {
|
||||
Some((m, l)) if *m == modified => l.clone(),
|
||||
_ => {
|
||||
let l = read_label(&path, &uuid);
|
||||
labels.insert(uuid.clone(), (modified, l.clone()));
|
||||
l
|
||||
}
|
||||
};
|
||||
Some(DiskSession { uuid, label, modified })
|
||||
})
|
||||
.collect();
|
||||
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
Ok(sessions)
|
||||
}
|
||||
|
||||
/// One turn of a session: a real user prompt plus everything that chains off
|
||||
/// it (assistant blocks, tool results, attachments…) until the next prompt.
|
||||
pub struct Turn {
|
||||
/// First line of the user's prompt (injected blocks filtered out).
|
||||
pub label: String,
|
||||
/// Indent for tree rendering: abandoned (non-trunk) branches sit one
|
||||
/// level under their fork point; the most recent continuation stays at
|
||||
/// its parent's depth.
|
||||
pub depth: usize,
|
||||
/// Parent turn (None for the session's first prompt).
|
||||
pub parent: Option<usize>,
|
||||
/// Raw JSONL lines belonging to this turn, in file order.
|
||||
pub lines: Vec<String>,
|
||||
}
|
||||
|
||||
/// A session's turns as a tree. Built from `uuid`/`parentUuid` chains:
|
||||
/// uuid-less records (mode, file-history-snapshot, last-prompt…) attach to
|
||||
/// the turn of the record preceding them in the file.
|
||||
pub struct TurnTree {
|
||||
pub turns: Vec<Turn>,
|
||||
/// Records before/outside any turn (mode, the caveat record, …).
|
||||
pub preamble: Vec<String>,
|
||||
/// Turn indices in render order: DFS where abandoned branches are listed
|
||||
/// (indented) right after their fork point and the trunk continues below.
|
||||
pub display: Vec<usize>,
|
||||
}
|
||||
|
||||
impl TurnTree {
|
||||
/// Children of `t` in creation (= chronological) order.
|
||||
fn children(&self, t: usize) -> impl Iterator<Item = usize> + '_ {
|
||||
(t + 1..self.turns.len()).filter(move |&c| self.turns[c].parent == Some(t))
|
||||
}
|
||||
|
||||
/// Follow the most recent child from `t` down to a leaf — the path a
|
||||
/// `claude --resume` would continue on from that point.
|
||||
pub fn trunk_leaf(&self, from: usize) -> usize {
|
||||
let mut t = from;
|
||||
while let Some(c) = self.children(t).max() {
|
||||
t = c;
|
||||
}
|
||||
t
|
||||
}
|
||||
|
||||
/// Root → `leaf` chain, inclusive.
|
||||
pub fn chain(&self, leaf: usize) -> Vec<usize> {
|
||||
let mut v = vec![leaf];
|
||||
let mut t = leaf;
|
||||
while let Some(p) = self.turns[t].parent {
|
||||
v.push(p);
|
||||
t = p;
|
||||
}
|
||||
v.reverse();
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
/// The user-typed prompt text of a record, if it starts a turn: a `user`
|
||||
/// record that is not meta/sidechain and carries non-injected text.
|
||||
fn prompt_text(v: &Value) -> Option<String> {
|
||||
if v.get("type").and_then(Value::as_str) != Some("user")
|
||||
|| v.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
||||
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
return None;
|
||||
}
|
||||
let text = match v.pointer("/message/content") {
|
||||
Some(Value::String(s)) => s.clone(),
|
||||
Some(Value::Array(blocks)) => blocks
|
||||
.iter()
|
||||
.filter(|b| b.get("type").and_then(Value::as_str) == Some("text"))
|
||||
.filter_map(|b| b.get("text").and_then(Value::as_str))
|
||||
.filter(|t| !is_injected_block(t))
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n"),
|
||||
_ => return None,
|
||||
};
|
||||
let text = text.trim();
|
||||
(!text.is_empty() && !is_injected_block(text)).then(|| text.to_string())
|
||||
}
|
||||
|
||||
/// Parse a session file into its turn tree. None when the file has no turns
|
||||
/// (nothing recorded yet, or unreadable).
|
||||
pub fn load_tree(uuid: &str) -> Option<TurnTree> {
|
||||
let path = project_dir().ok()?.join(format!("{uuid}.jsonl"));
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let reader = std::io::BufReader::new(f);
|
||||
let tree = build_tree(reader.lines().map_while(Result::ok));
|
||||
(!tree.turns.is_empty()).then_some(tree)
|
||||
}
|
||||
|
||||
/// Tree construction from raw JSONL lines (separated from I/O for tests).
|
||||
pub(crate) fn build_tree(lines: impl IntoIterator<Item = String>) -> TurnTree {
|
||||
let mut turns: Vec<Turn> = Vec::new();
|
||||
let mut preamble: Vec<String> = Vec::new();
|
||||
// record uuid → turn it belongs to (None = preamble).
|
||||
let mut turn_of: HashMap<String, Option<usize>> = HashMap::new();
|
||||
// positional bucket for uuid-less records: the previous record's turn.
|
||||
let mut cur: Option<usize> = None;
|
||||
for line in lines {
|
||||
let Ok(v) = serde_json::from_str::<Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
let uuid = v.get("uuid").and_then(Value::as_str).map(str::to_string);
|
||||
let bucket = match (&uuid, prompt_text(&v)) {
|
||||
(Some(_), Some(label)) => {
|
||||
// A real prompt starts a new turn, chained to the turn of its
|
||||
// parent record (rewinds make this an *earlier* turn).
|
||||
let parent = v
|
||||
.get("parentUuid")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|p| turn_of.get(p).copied())
|
||||
.flatten();
|
||||
turns.push(Turn {
|
||||
label: one_line(&label),
|
||||
depth: 0,
|
||||
parent,
|
||||
lines: Vec::new(),
|
||||
});
|
||||
Some(turns.len() - 1)
|
||||
}
|
||||
(Some(_), None) => match v.get("parentUuid").and_then(Value::as_str) {
|
||||
// Chain membership when the parent is known; otherwise fall
|
||||
// back to position (orphaned records exist in old files).
|
||||
Some(p) => turn_of.get(p).copied().unwrap_or(cur),
|
||||
None => None, // root record → preamble
|
||||
},
|
||||
(None, _) => cur, // uuid-less metadata rides with its neighbours
|
||||
};
|
||||
match bucket {
|
||||
Some(t) => turns[t].lines.push(line),
|
||||
None => preamble.push(line),
|
||||
}
|
||||
if let Some(u) = uuid {
|
||||
turn_of.insert(u, bucket);
|
||||
}
|
||||
cur = bucket;
|
||||
}
|
||||
// Render order + depths: abandoned branches indent under the fork point
|
||||
// and are listed first; the most recent child continues at parent depth.
|
||||
let mut children: Vec<Vec<usize>> = vec![Vec::new(); turns.len()];
|
||||
let mut roots: Vec<usize> = Vec::new();
|
||||
for (i, t) in turns.iter().enumerate() {
|
||||
match t.parent {
|
||||
Some(p) => children[p].push(i),
|
||||
None => roots.push(i),
|
||||
}
|
||||
}
|
||||
let mut display = Vec::with_capacity(turns.len());
|
||||
let mut stack: Vec<(usize, usize)> = roots.iter().rev().map(|&r| (r, 0)).collect();
|
||||
while let Some((t, d)) = stack.pop() {
|
||||
display.push(t);
|
||||
turns[t].depth = d;
|
||||
let ch = &children[t];
|
||||
for (i, &c) in ch.iter().enumerate().rev() {
|
||||
stack.push((c, if i + 1 == ch.len() { d } else { d + 1 }));
|
||||
}
|
||||
}
|
||||
TurnTree { turns, preamble, display }
|
||||
}
|
||||
|
||||
/// Write a brand-new session file made of `turn_idxs` (chronological order)
|
||||
/// plus the preamble: sessionId rewritten throughout, each turn's first
|
||||
/// record re-parented onto the previous turn's tail (the first one onto the
|
||||
/// preamble tail / null), so the result is a self-consistent linear session
|
||||
/// fully decoupled from its origin. Returns the new session uuid.
|
||||
pub fn materialize(tree: &TurnTree, turn_idxs: &[usize], title: &str) -> Result<String, String> {
|
||||
let dir = project_dir()?;
|
||||
materialize_in(&dir, tree, turn_idxs, title)
|
||||
}
|
||||
|
||||
fn materialize_in(
|
||||
dir: &std::path::Path,
|
||||
tree: &TurnTree,
|
||||
turn_idxs: &[usize],
|
||||
title: &str,
|
||||
) -> Result<String, String> {
|
||||
let new_uuid = uuid::Uuid::new_v4().to_string();
|
||||
let mut out = String::new();
|
||||
// Our own title record first: `read_label` (and Claude Code's picker)
|
||||
// prefer it, so the branch gets a meaningful name.
|
||||
out.push_str(
|
||||
&serde_json::json!({"type":"ai-title","aiTitle":title,"sessionId":new_uuid}).to_string(),
|
||||
);
|
||||
out.push('\n');
|
||||
|
||||
fn emit(
|
||||
line: &str,
|
||||
new_uuid: &str,
|
||||
head_of_turn: &mut bool,
|
||||
tail: &mut Option<String>,
|
||||
out: &mut String,
|
||||
) {
|
||||
let Ok(mut v) = serde_json::from_str::<Value>(line) else {
|
||||
return;
|
||||
};
|
||||
// The origin's title must not override ours.
|
||||
if v.get("type").and_then(Value::as_str) == Some("ai-title") {
|
||||
return;
|
||||
}
|
||||
if let Some(o) = v.as_object_mut() {
|
||||
o.insert("sessionId".into(), Value::String(new_uuid.to_string()));
|
||||
if let Some(u) = o.get("uuid").and_then(Value::as_str).map(str::to_string) {
|
||||
if *head_of_turn {
|
||||
o.insert(
|
||||
"parentUuid".into(),
|
||||
tail.clone().map_or(Value::Null, Value::String),
|
||||
);
|
||||
*head_of_turn = false;
|
||||
}
|
||||
*tail = Some(u);
|
||||
}
|
||||
}
|
||||
out.push_str(&v.to_string());
|
||||
out.push('\n');
|
||||
}
|
||||
|
||||
let mut tail: Option<String> = None;
|
||||
let mut no_rewire = false; // preamble keeps its own chain
|
||||
for l in &tree.preamble {
|
||||
emit(l, &new_uuid, &mut no_rewire, &mut tail, &mut out);
|
||||
}
|
||||
for &t in turn_idxs {
|
||||
let turn = tree
|
||||
.turns
|
||||
.get(t)
|
||||
.ok_or_else(|| format!("turn {t} out of range"))?;
|
||||
let mut head = true;
|
||||
for l in &turn.lines {
|
||||
emit(l, &new_uuid, &mut head, &mut tail, &mut out);
|
||||
}
|
||||
}
|
||||
let path = dir.join(format!("{new_uuid}.jsonl"));
|
||||
std::fs::write(&path, out).map_err(|e| format!("write {}: {e}", path.display()))?;
|
||||
Ok(new_uuid)
|
||||
}
|
||||
|
||||
/// A rendered transcript: the feed `Session` plus which tree path it shows.
|
||||
pub struct HistoryView {
|
||||
pub session: Session,
|
||||
/// Some(leaf turn) when rendered along a tree path; None = whole file
|
||||
/// in raw order (legacy view for un-expanded stubs).
|
||||
pub leaf: Option<usize>,
|
||||
/// (turn index, first entry index) along the path — feed auto-scroll.
|
||||
pub turn_entries: Vec<(usize, usize)>,
|
||||
}
|
||||
|
||||
/// Rebuild a feed `Session` from a past session's JSONL transcript so the
|
||||
/// feed isn't blank when resuming (no API traffic flows until the next turn).
|
||||
/// Live taps for the resumed session find this entry by key and append to it.
|
||||
pub fn load_history(uuid: &str) -> Option<Session> {
|
||||
load_view(uuid, None).map(|h| h.session)
|
||||
}
|
||||
|
||||
/// Path-aware transcript view: with `path = Some((tree, leaf))` only the
|
||||
/// preamble + the root→leaf chain is rendered (dead branches excluded) and
|
||||
/// per-turn entry offsets are recorded; with None, the whole file.
|
||||
pub fn load_view(uuid: &str, path: Option<(&TurnTree, usize)>) -> Option<HistoryView> {
|
||||
match path {
|
||||
None => {
|
||||
let fpath = project_dir().ok()?.join(format!("{uuid}.jsonl"));
|
||||
load_file_view(&fpath, uuid)
|
||||
}
|
||||
Some((tree, leaf)) => {
|
||||
let mut p = EntryParser::new();
|
||||
let mut turn_entries: Vec<(usize, usize)> = Vec::new();
|
||||
for l in &tree.preamble {
|
||||
p.line(l);
|
||||
}
|
||||
for &t in &tree.chain(leaf) {
|
||||
turn_entries.push((t, p.entries.len()));
|
||||
for l in &tree.turns[t].lines {
|
||||
p.line(l);
|
||||
}
|
||||
}
|
||||
p.into_view(uuid, Some(leaf), turn_entries)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Whole-file transcript view from an explicit path (the I/O-location seam:
|
||||
/// tests parse temp files without touching `HOME`).
|
||||
pub(crate) fn load_file_view(path: &std::path::Path, uuid: &str) -> Option<HistoryView> {
|
||||
let f = std::fs::File::open(path).ok()?;
|
||||
let mut p = EntryParser::new();
|
||||
for line in std::io::BufReader::new(f).lines().map_while(Result::ok) {
|
||||
p.line(&line);
|
||||
}
|
||||
p.into_view(uuid, None, Vec::new())
|
||||
}
|
||||
/// Incremental JSONL-record → feed-`Entry` translation (shared by the whole
|
||||
/// file and path views).
|
||||
struct EntryParser {
|
||||
entries: Vec<Entry>,
|
||||
model: String,
|
||||
/// tool_use id → entry index, to attach results from later user lines.
|
||||
tool_idx: HashMap<String, usize>,
|
||||
}
|
||||
|
||||
impl EntryParser {
|
||||
fn new() -> Self {
|
||||
Self {
|
||||
entries: Vec::new(),
|
||||
model: String::from("(resumed)"),
|
||||
tool_idx: HashMap::new(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Wrap the parsed entries into a `HistoryView` (None when empty).
|
||||
fn into_view(
|
||||
self,
|
||||
uuid: &str,
|
||||
leaf: Option<usize>,
|
||||
turn_entries: Vec<(usize, usize)>,
|
||||
) -> Option<HistoryView> {
|
||||
if self.entries.is_empty() {
|
||||
return None;
|
||||
}
|
||||
Some(HistoryView {
|
||||
session: Session {
|
||||
key: uuid.to_string(),
|
||||
model: self.model,
|
||||
entries: self.entries,
|
||||
active: 0,
|
||||
input_tokens: 0,
|
||||
output_tokens: 0,
|
||||
last_activity: std::time::Instant::now(),
|
||||
tool_ids: HashMap::new(),
|
||||
},
|
||||
leaf,
|
||||
turn_entries,
|
||||
})
|
||||
}
|
||||
|
||||
fn line(&mut self, line: &str) {
|
||||
let entries = &mut self.entries;
|
||||
let tool_idx = &mut self.tool_idx;
|
||||
let Ok(v) = serde_json::from_str::<Value>(line) else {
|
||||
return;
|
||||
};
|
||||
// Skip subagent transcripts and synthetic/meta user lines.
|
||||
if v.get("isSidechain").and_then(Value::as_bool) == Some(true)
|
||||
|| v.get("isMeta").and_then(Value::as_bool) == Some(true)
|
||||
{
|
||||
return;
|
||||
}
|
||||
match v.get("type").and_then(Value::as_str) {
|
||||
Some("assistant") => {
|
||||
if let Some(m) = v.pointer("/message/model").and_then(Value::as_str) {
|
||||
self.model = m.to_string();
|
||||
}
|
||||
let Some(blocks) = v.pointer("/message/content").and_then(Value::as_array)
|
||||
else {
|
||||
return;
|
||||
};
|
||||
for b in blocks {
|
||||
let entry = match b.get("type").and_then(Value::as_str) {
|
||||
Some("thinking") => Some(Entry {
|
||||
kind: Kind::Thinking,
|
||||
content: text_of(b, "thinking"),
|
||||
done: true,
|
||||
result: None,
|
||||
}),
|
||||
Some("redacted_thinking") => Some(Entry {
|
||||
kind: Kind::Thinking,
|
||||
content: "[redacted]".into(),
|
||||
done: true,
|
||||
result: None,
|
||||
}),
|
||||
Some("text") => Some(Entry {
|
||||
kind: Kind::Text,
|
||||
content: text_of(b, "text"),
|
||||
done: true,
|
||||
result: None,
|
||||
}),
|
||||
Some("tool_use" | "server_tool_use" | "mcp_tool_use") => {
|
||||
if let Some(id) = b.get("id").and_then(Value::as_str) {
|
||||
tool_idx.insert(id.to_string(), entries.len());
|
||||
}
|
||||
let input = b.get("input").map_or(String::new(), |i| {
|
||||
serde_json::to_string_pretty(i).unwrap_or_default()
|
||||
});
|
||||
Some(Entry {
|
||||
kind: Kind::Tool {
|
||||
name: b
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("tool")
|
||||
.to_string(),
|
||||
},
|
||||
content: input,
|
||||
done: true,
|
||||
result: None,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
if let Some(e) = entry {
|
||||
entries.push(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some("user") => match v.pointer("/message/content") {
|
||||
Some(Value::String(s)) => {
|
||||
if !s.is_empty() {
|
||||
entries.push(Entry {
|
||||
kind: Kind::Meta,
|
||||
content: format!("❯ {s}"),
|
||||
done: true,
|
||||
result: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(Value::Array(blocks)) => {
|
||||
for b in blocks {
|
||||
match b.get("type").and_then(Value::as_str) {
|
||||
Some("text") => {
|
||||
let t = text_of(b, "text");
|
||||
if !t.is_empty() {
|
||||
entries.push(Entry {
|
||||
kind: Kind::Meta,
|
||||
content: format!("❯ {t}"),
|
||||
done: true,
|
||||
result: None,
|
||||
});
|
||||
}
|
||||
}
|
||||
Some("tool_result") => {
|
||||
let Some(idx) = b
|
||||
.get("tool_use_id")
|
||||
.and_then(Value::as_str)
|
||||
.and_then(|id| tool_idx.remove(id))
|
||||
else {
|
||||
continue;
|
||||
};
|
||||
if let Some(e) = entries.get_mut(idx) {
|
||||
e.result = Some(ToolResult {
|
||||
content: flatten_result_content(b.get("content")),
|
||||
is_error: b
|
||||
.get("is_error")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
});
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn text_of(b: &Value, key: &str) -> String {
|
||||
b.get(key).and_then(Value::as_str).unwrap_or_default().to_string()
|
||||
}
|
||||
|
||||
/// Encode the current working directory the same way Claude Code does:
|
||||
/// each `/` → `-` (the leading `/` becomes a leading `-`).
|
||||
fn project_dir() -> Result<PathBuf, String> {
|
||||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_string())?;
|
||||
let cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?;
|
||||
let encoded = cwd
|
||||
.to_str()
|
||||
.ok_or_else(|| "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string())?
|
||||
.replace('/', "-");
|
||||
let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded);
|
||||
if !dir.is_dir() {
|
||||
return Err("no past sessions recorded for this directory".to_string());
|
||||
}
|
||||
Ok(dir)
|
||||
}
|
||||
|
||||
/// Read a human-readable label from the JSONL file. Prefers `ai-title`;
|
||||
/// falls back to the first non-empty `last-prompt` text; then uuid prefix.
|
||||
fn read_label(path: &std::path::Path, uuid: &str) -> String {
|
||||
let fallback = || uuid.chars().take(8).collect::<String>();
|
||||
let Ok(f) = std::fs::File::open(path) else {
|
||||
return fallback();
|
||||
};
|
||||
let reader = std::io::BufReader::new(f);
|
||||
let mut last_prompt: Option<String> = None;
|
||||
for line in reader.lines().map_while(Result::ok) {
|
||||
let Ok(v) = serde_json::from_str::<serde_json::Value>(&line) else {
|
||||
continue;
|
||||
};
|
||||
match v.get("type").and_then(|t| t.as_str()) {
|
||||
Some("ai-title") => {
|
||||
if let Some(t) = v.get("aiTitle").and_then(|t| t.as_str())
|
||||
&& !t.is_empty()
|
||||
{
|
||||
return one_line(t);
|
||||
}
|
||||
}
|
||||
Some("last-prompt") => {
|
||||
if let Some(p) = v.get("lastPrompt").and_then(|p| p.as_str())
|
||||
&& !p.is_empty()
|
||||
{
|
||||
last_prompt = Some(one_line(p));
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
last_prompt.unwrap_or_else(fallback)
|
||||
}
|
||||
|
||||
/// First line only, control characters dropped — labels go into a one-row
|
||||
/// list item, so embedded newlines/tabs would smear the layout.
|
||||
fn one_line(s: &str) -> String {
|
||||
s.lines()
|
||||
.next()
|
||||
.unwrap_or("")
|
||||
.chars()
|
||||
.filter(|c| !c.is_control())
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::io::Write;
|
||||
|
||||
/// A user-prompt record (turn start).
|
||||
fn prompt(uuid: &str, parent: Option<&str>, text: &str) -> String {
|
||||
serde_json::json!({
|
||||
"type": "user", "uuid": uuid, "parentUuid": parent,
|
||||
"sessionId": "orig",
|
||||
"message": {"role": "user", "content": text}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// An assistant text record.
|
||||
fn reply(uuid: &str, parent: &str, text: &str) -> String {
|
||||
serde_json::json!({
|
||||
"type": "assistant", "uuid": uuid, "parentUuid": parent,
|
||||
"sessionId": "orig",
|
||||
"message": {"model": "claude-x", "content": [{"type": "text", "text": text}]}
|
||||
})
|
||||
.to_string()
|
||||
}
|
||||
|
||||
/// A branched fixture: prompt A → prompt B (abandoned) and, after a
|
||||
/// rewind onto A's reply, prompt B2 → C (the trunk).
|
||||
fn branched() -> Vec<String> {
|
||||
vec![
|
||||
r#"{"type":"mode","mode":"normal","sessionId":"orig"}"#.into(), // preamble
|
||||
prompt("u1", None, "prompt A"),
|
||||
reply("u2", "u1", "reply A"),
|
||||
prompt("u3", Some("u2"), "prompt B"),
|
||||
reply("u4", "u3", "reply B"),
|
||||
prompt("u5", Some("u2"), "prompt B2"), // rewind: forks off reply A
|
||||
reply("u6", "u5", "reply B2"),
|
||||
prompt("u7", Some("u6"), "prompt C"),
|
||||
reply("u8", "u7", "reply C"),
|
||||
]
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tree_groups_turns_and_orders_branches() {
|
||||
let tree = build_tree(branched());
|
||||
assert_eq!(tree.turns.len(), 4);
|
||||
assert_eq!(tree.preamble.len(), 1, "mode record lands in the preamble");
|
||||
let labels: Vec<&str> = tree.turns.iter().map(|t| t.label.as_str()).collect();
|
||||
assert_eq!(labels, ["prompt A", "prompt B", "prompt B2", "prompt C"]);
|
||||
assert_eq!(tree.turns[1].parent, Some(0), "B forks off A");
|
||||
assert_eq!(tree.turns[2].parent, Some(0), "B2 forks off A");
|
||||
assert_eq!(tree.turns[3].parent, Some(2));
|
||||
// Display: A, then the abandoned B indented, then trunk B2 → C.
|
||||
assert_eq!(tree.display, [0, 1, 2, 3]);
|
||||
assert_eq!(tree.turns[0].depth, 0);
|
||||
assert_eq!(tree.turns[1].depth, 1, "abandoned branch indents");
|
||||
assert_eq!(tree.turns[2].depth, 0, "most recent child stays on trunk");
|
||||
assert_eq!(tree.turns[3].depth, 0);
|
||||
// Trunk resolution + chains.
|
||||
assert_eq!(tree.trunk_leaf(0), 3);
|
||||
assert_eq!(tree.trunk_leaf(1), 1, "dead branch ends at its own leaf");
|
||||
assert_eq!(tree.chain(3), [0, 2, 3]);
|
||||
assert_eq!(tree.chain(1), [0, 1]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn path_view_excludes_dead_branches_and_offsets_turns() {
|
||||
let tree = build_tree(branched());
|
||||
let h = load_view("some-uuid", Some((&tree, 3))).expect("view");
|
||||
let texts: Vec<&str> = h.session.entries.iter().map(|e| e.content.as_str()).collect();
|
||||
assert!(texts.iter().any(|t| t.contains("prompt B2")));
|
||||
assert!(
|
||||
!texts.iter().any(|t| *t == "reply B" || t.contains("prompt B\n") || t.ends_with("prompt B")),
|
||||
"dead branch content must not leak into the path view: {texts:?}"
|
||||
);
|
||||
assert_eq!(h.leaf, Some(3));
|
||||
// Turn offsets point at each turn's first entry (its ❯ prompt).
|
||||
assert_eq!(h.turn_entries.len(), 3);
|
||||
for &(t, e) in &h.turn_entries {
|
||||
assert!(
|
||||
h.session.entries[e].content.contains(&tree.turns[t].label),
|
||||
"offset {e} should land on the prompt of turn {t}"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn materialize_chain_and_stitch() {
|
||||
let tree = build_tree(branched());
|
||||
let dir = std::env::temp_dir().join(format!("ct-mat-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
|
||||
// Plain branch: chain through the abandoned B turn.
|
||||
let new = materialize_in(&dir, &tree, &tree.chain(1), "⑂ prompt B").unwrap();
|
||||
let body = std::fs::read_to_string(dir.join(format!("{new}.jsonl"))).unwrap();
|
||||
let recs: Vec<Value> = body.lines().map(|l| serde_json::from_str(l).unwrap()).collect();
|
||||
assert_eq!(recs[0]["type"], "ai-title");
|
||||
assert_eq!(recs[0]["aiTitle"], "⑂ prompt B");
|
||||
for r in &recs {
|
||||
assert_eq!(r["sessionId"], Value::String(new.clone()), "sessionId rewritten");
|
||||
}
|
||||
assert!(!body.contains("prompt B2"), "other branch excluded");
|
||||
let rebuilt = build_tree(body.lines().map(str::to_string));
|
||||
assert_eq!(rebuilt.turns.len(), 2);
|
||||
assert_eq!(rebuilt.turns[1].parent, Some(0), "chain stays linked");
|
||||
|
||||
// Visual stitch: only the C turn, fully decoupled.
|
||||
let new2 = materialize_in(&dir, &tree, &[3], "⑂ prompt C").unwrap();
|
||||
let body2 = std::fs::read_to_string(dir.join(format!("{new2}.jsonl"))).unwrap();
|
||||
let rebuilt2 = build_tree(body2.lines().map(str::to_string));
|
||||
assert_eq!(rebuilt2.turns.len(), 1);
|
||||
assert_eq!(rebuilt2.turns[0].label, "prompt C");
|
||||
assert_eq!(rebuilt2.turns[0].parent, None, "first turn re-parented to root");
|
||||
assert!(!body2.contains("prompt A"), "unselected turns dropped");
|
||||
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
fn write_jsonl(lines: &[&str]) -> std::path::PathBuf {
|
||||
use std::sync::atomic::{AtomicU32, Ordering};
|
||||
static N: AtomicU32 = AtomicU32::new(0);
|
||||
let path = std::env::temp_dir().join(format!(
|
||||
"ct-sessions-test-{}-{}.jsonl",
|
||||
std::process::id(),
|
||||
N.fetch_add(1, Ordering::Relaxed)
|
||||
));
|
||||
let mut f = std::fs::File::create(&path).unwrap();
|
||||
for l in lines {
|
||||
writeln!(f, "{l}").unwrap();
|
||||
}
|
||||
path
|
||||
}
|
||||
|
||||
/// Property check against whatever real session files exist for this
|
||||
/// directory (no-op elsewhere): every turn appears in `display` exactly
|
||||
/// once, depths are sane, and chains terminate (parents always precede
|
||||
/// children, so the tree is acyclic by construction — verify anyway).
|
||||
#[test]
|
||||
fn real_session_trees_uphold_invariants() {
|
||||
let Ok(dir) = project_dir() else { return };
|
||||
let Ok(rd) = std::fs::read_dir(&dir) else { return };
|
||||
for e in rd.flatten() {
|
||||
let path = e.path();
|
||||
if path.extension().and_then(|x| x.to_str()) != Some("jsonl") {
|
||||
continue;
|
||||
}
|
||||
let Ok(body) = std::fs::read_to_string(&path) else { continue };
|
||||
let tree = build_tree(body.lines().map(str::to_string));
|
||||
let mut seen = vec![false; tree.turns.len()];
|
||||
for &t in &tree.display {
|
||||
assert!(!seen[t], "{path:?}: turn {t} displayed twice");
|
||||
seen[t] = true;
|
||||
}
|
||||
assert!(seen.iter().all(|&s| s), "{path:?}: turn missing from display");
|
||||
for (i, t) in tree.turns.iter().enumerate() {
|
||||
assert!(!t.label.is_empty(), "{path:?}: empty turn label");
|
||||
if let Some(p) = t.parent {
|
||||
assert!(p < i, "{path:?}: parent {p} not before child {i}");
|
||||
}
|
||||
assert!(!tree.chain(i).is_empty());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_prefers_ai_title() {
|
||||
let p = write_jsonl(&[
|
||||
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
|
||||
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
|
||||
]);
|
||||
assert_eq!(read_label(&p, "deadbeef-0000"), "bug fixing session");
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn label_falls_back_to_last_prompt_then_uuid() {
|
||||
let p = write_jsonl(&[
|
||||
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
|
||||
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
|
||||
]);
|
||||
assert_eq!(read_label(&p, "deadbeef-0000"), "latest");
|
||||
std::fs::remove_file(p).ok();
|
||||
|
||||
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
|
||||
assert_eq!(read_label(&p, "deadbeef-0000"), "deadbeef");
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn history_rebuilds_entries_with_tool_results() {
|
||||
let p = write_jsonl(&[
|
||||
r#"{"type":"user","message":{"role":"user","content":"fix the bug"}}"#,
|
||||
r#"{"type":"assistant","message":{"model":"claude-x","content":[
|
||||
{"type":"thinking","thinking":"hmm"},
|
||||
{"type":"tool_use","id":"toolu_1","name":"Bash","input":{"command":"ls"}}
|
||||
]}}"#
|
||||
.replace('\n', " ")
|
||||
.leak(),
|
||||
r#"{"type":"user","message":{"role":"user","content":[
|
||||
{"type":"tool_result","tool_use_id":"toolu_1","content":"file_a","is_error":false}
|
||||
]}}"#
|
||||
.replace('\n', " ")
|
||||
.leak(),
|
||||
r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-x","content":[{"type":"text","text":"subagent noise"}]}}"#,
|
||||
r#"{"type":"assistant","message":{"model":"claude-x","content":[{"type":"text","text":"done"}]}}"#,
|
||||
]);
|
||||
// Parse via the explicit-path seam: no HOME mutation (an unsafe
|
||||
// set_var would race the env reads of concurrently running tests).
|
||||
let uuid = "11111111-2222-3333-4444-555555555555";
|
||||
let s = load_file_view(&p, uuid).map(|h| h.session);
|
||||
std::fs::remove_file(&p).ok();
|
||||
|
||||
let s = s.expect("history loaded");
|
||||
assert_eq!(s.key, uuid);
|
||||
assert_eq!(s.model, "claude-x");
|
||||
// ❯ prompt, thinking, tool, text — sidechain line skipped.
|
||||
assert_eq!(s.entries.len(), 4);
|
||||
assert_eq!(s.entries[0].content, "❯ fix the bug");
|
||||
assert!(matches!(s.entries[1].kind, Kind::Thinking));
|
||||
let tool = &s.entries[2];
|
||||
assert!(matches!(&tool.kind, Kind::Tool { name } if name == "Bash"));
|
||||
assert_eq!(tool.result.as_ref().unwrap().content, "file_a");
|
||||
assert_eq!(s.entries[3].content, "done");
|
||||
assert!(s.entries.iter().all(|e| e.done));
|
||||
}
|
||||
}
|
||||
22
src/sse.rs
22
src/sse.rs
@@ -7,9 +7,18 @@ pub struct SseParser {
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
/// Safety valve for a hung/malformed upstream that never sends an event
|
||||
/// terminator: cap the accumulation buffer so a misbehaving connection can't
|
||||
/// grow memory without bound. Tap-only — the relayed bytes are unaffected.
|
||||
const MAX_BUF: usize = 4 * 1024 * 1024;
|
||||
|
||||
impl SseParser {
|
||||
pub fn feed(&mut self, chunk: &[u8]) -> Vec<(String, Value)> {
|
||||
self.buf.extend_from_slice(chunk);
|
||||
if self.buf.len() > MAX_BUF {
|
||||
self.buf.clear();
|
||||
return Vec::new();
|
||||
}
|
||||
let mut out = Vec::new();
|
||||
while let Some((content_end, drain_end)) = find_event_end(&self.buf) {
|
||||
let event: Vec<u8> = self.buf.drain(..drain_end).collect();
|
||||
@@ -20,13 +29,18 @@ impl SseParser {
|
||||
if let Some(v) = line.strip_prefix("event:") {
|
||||
name = v.trim().to_string();
|
||||
} else if let Some(v) = line.strip_prefix("data:") {
|
||||
// SSE spec §9.2.6: multiple data lines are concatenated
|
||||
// with a U+000A LINE FEED between them.
|
||||
if !data.is_empty() {
|
||||
data.push('\n');
|
||||
}
|
||||
data.push_str(v.strip_prefix(' ').unwrap_or(v));
|
||||
}
|
||||
}
|
||||
if !data.is_empty() {
|
||||
if let Ok(v) = serde_json::from_str(&data) {
|
||||
out.push((name, v));
|
||||
}
|
||||
if !data.is_empty()
|
||||
&& let Ok(v) = serde_json::from_str(&data)
|
||||
{
|
||||
out.push((name, v));
|
||||
}
|
||||
}
|
||||
out
|
||||
|
||||
205
src/term.rs
205
src/term.rs
@@ -25,8 +25,9 @@ use wezterm_term::{
|
||||
};
|
||||
|
||||
/// Rows cropped above the last non-blank screen row — hides Claude Code's
|
||||
/// persistent hint rows ("? for shortcuts" + permission/mode line).
|
||||
const BOTTOM_CROP: usize = 2;
|
||||
/// persistent hint row ("? for shortcuts" / permission mode) while keeping
|
||||
/// the statusLine that renders directly under the input box.
|
||||
const BOTTOM_CROP: usize = 1;
|
||||
/// Extra PTY rows beyond the visible pane window, so the child has room to
|
||||
/// draw the rows we crop.
|
||||
const PTY_PAD: u16 = 4;
|
||||
@@ -54,16 +55,22 @@ pub struct EmbeddedTerm {
|
||||
/// Session UUID passed to `claude --session-id`; lets the tap recognise
|
||||
/// which proxied session belongs to this pane.
|
||||
pub session_id: String,
|
||||
rows: u16,
|
||||
/// Actual PTY rows (visible rows + pad when cropping is active).
|
||||
pty_rows: u16,
|
||||
cols: u16,
|
||||
}
|
||||
|
||||
impl EmbeddedTerm {
|
||||
/// Spawn `claude` in a fresh PTY, routed through our proxy.
|
||||
pub fn spawn(port: u16, rows: u16, cols: u16) -> anyhow::Result<Self> {
|
||||
/// Spawn `claude` in a fresh PTY, routed through our proxy. `model`, when
|
||||
/// non-empty, is passed as `--model <model>` (a Claude Code alias like
|
||||
/// `opus`/`sonnet`/`haiku` or a full model name).
|
||||
pub fn spawn(port: u16, rows: u16, cols: u16, model: &str) -> anyhow::Result<Self> {
|
||||
let session_id = uuid::Uuid::new_v4().to_string();
|
||||
let mut cmd = CommandBuilder::new("claude");
|
||||
cmd.args(["--session-id", &session_id]);
|
||||
if !model.is_empty() {
|
||||
cmd.args(["--model", model]);
|
||||
}
|
||||
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
cmd.cwd(cwd);
|
||||
@@ -71,6 +78,21 @@ impl EmbeddedTerm {
|
||||
Self::spawn_cmd(cmd, session_id, rows, cols)
|
||||
}
|
||||
|
||||
/// Spawn `claude --resume <session_id>` to continue a past session.
|
||||
/// A resumed session keeps its original session UUID in request
|
||||
/// metadata (verified against Claude Code 2.1.x), so the tap correlates
|
||||
/// traffic via the resumed UUID itself. `--session-id` must NOT be
|
||||
/// passed alongside `--resume` (rejected without `--fork-session`).
|
||||
pub fn spawn_resume(port: u16, rows: u16, cols: u16, session_id: &str) -> anyhow::Result<Self> {
|
||||
let mut cmd = CommandBuilder::new("claude");
|
||||
cmd.args(["--resume", session_id]);
|
||||
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
cmd.cwd(cwd);
|
||||
}
|
||||
Self::spawn_cmd(cmd, session_id.to_string(), rows, cols)
|
||||
}
|
||||
|
||||
fn spawn_cmd(
|
||||
cmd: CommandBuilder,
|
||||
session_id: String,
|
||||
@@ -125,7 +147,7 @@ impl EmbeddedTerm {
|
||||
});
|
||||
}
|
||||
|
||||
Ok(Self { term, master: pty.master, killer, exited, session_id, rows, cols })
|
||||
Ok(Self { term, master: pty.master, killer, exited, session_id, pty_rows: rows + PTY_PAD, cols })
|
||||
}
|
||||
|
||||
pub fn exited(&self) -> bool {
|
||||
@@ -133,16 +155,18 @@ impl EmbeddedTerm {
|
||||
}
|
||||
|
||||
/// Resize PTY + terminal model for a pane of `rows` visible rows.
|
||||
/// The PTY gets `PTY_PAD` extra rows: render() crops Claude Code's
|
||||
/// persistent status/hint rows, so the child needs room to draw them
|
||||
/// somewhere we don't show.
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||
if (rows, cols) == (self.rows, self.cols) || rows == 0 || cols == 0 {
|
||||
/// With `crop` (the compact pane), the PTY gets `PTY_PAD` extra rows:
|
||||
/// render() crops Claude Code's persistent status/hint rows, so the
|
||||
/// child needs room to draw them somewhere we don't show. Without
|
||||
/// `crop` (fullscreen), the PTY matches the pane exactly so nothing
|
||||
/// is ever cut off.
|
||||
pub fn resize(&mut self, rows: u16, cols: u16, crop: bool) {
|
||||
let rows = rows + if crop { PTY_PAD } else { 0 };
|
||||
if (rows, cols) == (self.pty_rows, self.cols) || rows == 0 || cols == 0 {
|
||||
return;
|
||||
}
|
||||
self.rows = rows;
|
||||
self.pty_rows = rows;
|
||||
self.cols = cols;
|
||||
let rows = rows + PTY_PAD;
|
||||
let _ = self.master.resize(PtySize { rows, cols, pixel_width: 0, pixel_height: 0 });
|
||||
self.term.lock().unwrap().resize(TerminalSize {
|
||||
rows: rows as usize,
|
||||
@@ -205,27 +229,38 @@ impl EmbeddedTerm {
|
||||
/// Paint a window of the child's screen into `area`. Returns the cursor
|
||||
/// position (absolute buffer coordinates) when the child wants it shown.
|
||||
///
|
||||
/// The window is content-anchored rather than the raw top of the screen,
|
||||
/// tuned to keep the pane prompt-only against Claude Code's UI:
|
||||
/// With `crop`, the window is content-anchored rather than the raw top of
|
||||
/// the screen, tuned to keep the pane prompt-only against Claude Code's UI:
|
||||
/// - it *ends* `BOTTOM_CROP` rows above the last non-blank row, hiding
|
||||
/// the persistent hint rows ("? for shortcuts", permission mode);
|
||||
/// the persistent hint row ("? for shortcuts", permission mode) but
|
||||
/// keeping the statusLine just below the input box;
|
||||
/// - it *starts* no higher than row 2 when content allows, hiding the
|
||||
/// status line ("✻ Worked for 1s" / spinner row sits right above the
|
||||
/// input box and stays visible since the window ends near it).
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer) -> Option<(u16, u16)> {
|
||||
///
|
||||
/// Without `crop` (fullscreen), the screen is shown verbatim from row 0
|
||||
/// so nothing is ever cut off.
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, crop: bool) -> Option<(u16, u16)> {
|
||||
let term = self.term.lock().unwrap();
|
||||
let screen = term.screen();
|
||||
let first = screen.phys_row(0);
|
||||
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
|
||||
let last = lines
|
||||
.iter()
|
||||
.rposition(|l| l.visible_cells().any(|c| !c.str().trim().is_empty()))
|
||||
.unwrap_or(0);
|
||||
let end = last.saturating_sub(BOTTOM_CROP);
|
||||
let start = (end + 1)
|
||||
.saturating_sub(area.height as usize)
|
||||
.max(2)
|
||||
.min(end);
|
||||
let (start, end) = if crop {
|
||||
let last = lines
|
||||
.iter()
|
||||
.rposition(|l| l.visible_cells().any(|c| !c.str().trim().is_empty()))
|
||||
.unwrap_or(0);
|
||||
let end = last.saturating_sub(BOTTOM_CROP);
|
||||
let start = (end + 1)
|
||||
.saturating_sub(area.height as usize)
|
||||
.max(2)
|
||||
.min(end);
|
||||
(start, end)
|
||||
} else {
|
||||
// The PTY is sized to the pane in fullscreen, but a resize may
|
||||
// not have landed yet — clamp to whatever fits.
|
||||
(0, lines.len().min(area.height as usize).saturating_sub(1))
|
||||
};
|
||||
for (y, line) in lines[start..=end].iter().enumerate() {
|
||||
if y as u16 >= area.height {
|
||||
break;
|
||||
@@ -296,11 +331,127 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Background scan that replaces `App::model_choices` with the live alias set
|
||||
/// read from the installed `claude` binary (see `discover_model_aliases`).
|
||||
/// Runs off the UI thread; on failure the seeded fallback list stays in place.
|
||||
pub fn spawn_model_discovery(app: crate::app::SharedApp) {
|
||||
std::thread::spawn(move || {
|
||||
if let Some(aliases) = discover_model_aliases() {
|
||||
// "default" (no --model flag) first, then the discovered aliases.
|
||||
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
|
||||
choices.extend(aliases.into_iter().map(|a| (a.clone(), a)));
|
||||
crate::app::lock_app(&app).model_choices = choices;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// Best-effort discovery of the model aliases the installed `claude` accepts
|
||||
/// (e.g. `opus`, `sonnet`, `haiku`, `fable`), so the `n` picker tracks new
|
||||
/// models without us hardcoding a list that drifts.
|
||||
///
|
||||
/// Claude Code ships as one self-contained executable with its (minified) JS
|
||||
/// bundle embedded; the alias set appears verbatim as a JSON array literal like
|
||||
/// `["sonnet","opus","haiku","fable"]`. We resolve the `claude` binary on PATH
|
||||
/// and scan its bytes for the longest such array anchored by `opus` + `sonnet`.
|
||||
/// This issues **no API request** (the project's core constraint) and never
|
||||
/// executes claude. Returns None if the binary can't be found/read or nothing
|
||||
/// matches — the caller keeps its built-in fallback list.
|
||||
fn discover_model_aliases() -> Option<Vec<String>> {
|
||||
let bytes = std::fs::read(claude_binary_path()?).ok()?;
|
||||
longest_alias_array(&bytes)
|
||||
}
|
||||
|
||||
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
|
||||
fn claude_binary_path() -> Option<std::path::PathBuf> {
|
||||
let path = std::env::var_os("PATH")?;
|
||||
std::env::split_paths(&path)
|
||||
.map(|d| d.join("claude"))
|
||||
.find(|c| c.is_file())
|
||||
.map(|c| std::fs::canonicalize(&c).unwrap_or(c))
|
||||
}
|
||||
|
||||
/// Scan a byte buffer for JSON array literals of short lowercase tokens and
|
||||
/// return the longest one that contains both `opus` and `sonnet` (the stable
|
||||
/// anchors of Claude Code's model-alias list).
|
||||
fn longest_alias_array(bytes: &[u8]) -> Option<Vec<String>> {
|
||||
let mut best: Option<Vec<String>> = None;
|
||||
let mut i = 0;
|
||||
while i < bytes.len() {
|
||||
if bytes[i] == b'['
|
||||
&& let Some((arr, end)) = parse_str_array(bytes, i)
|
||||
{
|
||||
let anchored = arr.iter().any(|s| s == "opus") && arr.iter().any(|s| s == "sonnet");
|
||||
if anchored && best.as_ref().is_none_or(|b| arr.len() > b.len()) {
|
||||
best = Some(arr);
|
||||
}
|
||||
i = end;
|
||||
continue;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
best
|
||||
}
|
||||
|
||||
/// Parse `["a","b",...]` of lowercase-`[a-z0-9-]` tokens starting at `start`
|
||||
/// (which must be `[`). Returns the tokens and the index just past the closing
|
||||
/// `]`, or None if the bytes there aren't exactly such an array.
|
||||
fn parse_str_array(bytes: &[u8], start: usize) -> Option<(Vec<String>, usize)> {
|
||||
let n = bytes.len();
|
||||
let mut i = start + 1; // past '['
|
||||
let mut out = Vec::new();
|
||||
loop {
|
||||
if i >= n {
|
||||
return None;
|
||||
}
|
||||
if bytes[i] == b']' {
|
||||
return (!out.is_empty()).then_some((out, i + 1));
|
||||
}
|
||||
if bytes[i] != b'"' {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
let tok_start = i;
|
||||
while i < n && bytes[i] != b'"' {
|
||||
let c = bytes[i];
|
||||
if !(c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-') {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
}
|
||||
let tok = bytes.get(tok_start..i)?;
|
||||
if tok.is_empty() || tok.len() > 24 {
|
||||
return None;
|
||||
}
|
||||
out.push(String::from_utf8(tok.to_vec()).ok()?);
|
||||
i += 1; // past closing quote
|
||||
match bytes.get(i)? {
|
||||
b',' => i += 1,
|
||||
b']' => return Some((out, i + 1)),
|
||||
_ => return None,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
#[test]
|
||||
fn picks_longest_anchored_alias_array() {
|
||||
let bytes = br#"junk["opus","sonnet"]more["sonnet","opus","haiku","fable"]tail"#;
|
||||
let got = longest_alias_array(bytes).unwrap();
|
||||
assert_eq!(got, ["sonnet", "opus", "haiku", "fable"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ignores_arrays_without_both_anchors() {
|
||||
// Missing "sonnet" → not a model-alias array.
|
||||
assert!(longest_alias_array(br#"["opus","haiku","fable"]"#).is_none());
|
||||
// Non-token content (uppercase/spaces) → rejected.
|
||||
assert!(longest_alias_array(br#"["Opus","sonnet"]"#).is_none());
|
||||
}
|
||||
|
||||
/// Full pipeline: PTY spawn → reader thread → wezterm-term model →
|
||||
/// ratatui buffer. Headless-safe: the *child* gets the tty, not us.
|
||||
#[test]
|
||||
@@ -312,7 +463,7 @@ mod tests {
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let mut buf = Buffer::empty(area);
|
||||
et.render(area, &mut buf);
|
||||
et.render(area, &mut buf, true);
|
||||
let row: String = (0..area.width).map(|x| buf[(x, 0)].symbol()).collect();
|
||||
if row.contains("hello-embed") {
|
||||
break;
|
||||
|
||||
Reference in New Issue
Block a user