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:
Jonas H
2026-06-12 11:27:34 +02:00
parent d34496da0b
commit 37cf183377
9 changed files with 3147 additions and 247 deletions

122
CLAUDE.md
View File

@@ -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.

View File

@@ -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()));

View File

@@ -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
View 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 │");
}
}

View File

@@ -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
View 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));
}
}

View File

@@ -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

View File

@@ -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;

1030
src/ui.rs

File diff suppressed because it is too large Load Diff