auto resume
This commit is contained in:
75
CLAUDE.md
75
CLAUDE.md
@@ -68,8 +68,10 @@ 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
|
||||
App::disk_sessions fresh (~1/s poll, `read_meta` re-read only on
|
||||
mtime change — one pass yields the label *and* the session's
|
||||
last main-chain model, which `App::resume_model` turns into the
|
||||
`--model` a resume spawns with); 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
|
||||
@@ -140,7 +142,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
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`).
|
||||
cannot be combined with `--resume` (CLI rejects it without `--fork-session`);
|
||||
`--model` can, and every resume passes it.
|
||||
- **A resume continues on the session's own model**, not the CLI default:
|
||||
`App::resume_model` reads the model Claude Code recorded for the session's
|
||||
last main-chain assistant message (`DiskSession::model`, filled by the
|
||||
scanner's `read_meta` — subagent `isSidechain` records run their own model
|
||||
and `<synthetic>` error records carry no model, so both are skipped) and
|
||||
`app::model_arg_for_id` maps that id to a `--model` argument: a known alias
|
||||
(`sonnet`, `opus`, … from `App::model_choices`) wins over the dated snapshot
|
||||
id, so a retired snapshot can't pin the pane; an id with no alias inside is
|
||||
passed through verbatim (`--model` takes full names too). The transcript is
|
||||
authoritative, so a mid-session `/model` switch is honoured. `[1m]`
|
||||
(1M-context) picks are the one thing it cannot see — the transcript records
|
||||
the same base id either way — so `Session::spawn_model` remembers the exact
|
||||
argument the pane was spawned with in-process and wins **only** while it
|
||||
still names the model the transcript reports.
|
||||
- **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
|
||||
@@ -189,14 +206,22 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
below the bottom rule for a menu row, not just the row directly under it: the
|
||||
list can start after a blank/header row and only the highlighted item carries
|
||||
a glyph (unselected file rows are plain names), so checking one row collapsed
|
||||
the pane whenever that row wasn't the selected item. The framed region drives the pane height too:
|
||||
the pane whenever that row wasn't the selected item. Above the top rule the
|
||||
frame also swallows an **active task panel** (`text_is_task_row` /
|
||||
`task_block_top`): Claude Code parks the `N tasks (…)` header + `✔ ◼ ◻` rows
|
||||
(and its `… +N pending` overflow line) directly above the input box, so
|
||||
walking up over that block — tolerating one blank line and single wrapped /
|
||||
activity rows, capped at `MAX_TASK_BLOCK` — makes task status visible with no
|
||||
extra app state. Priority when the pane can't hold everything: the panel is
|
||||
dropped first (`CompactFrame::ess_top`, the one-context-row frame) so the
|
||||
line you're typing and an open menu never fall off screen. The framed region drives the pane height too:
|
||||
`compact_rows` (called from `ui::draw`) measures box-height + tail so the
|
||||
pane auto-expands as the prompt gains lines or a menu opens and shrinks back
|
||||
when idle (floor `MIN_COMPACT_INNER`, cap = screen − 6); `PTY_PAD` keeps the
|
||||
PTY taller than the visible window so the child can still draw the rows we
|
||||
crop. **The Compact PTY is sized to the *whole screen height*, not the
|
||||
visible pane** (`ui::draw` passes `f.area().height` to `resize` only for
|
||||
Compact): Compact's height is derived by measuring what Ink has already
|
||||
crop. **A cropped PTY is sized to the *whole screen height*, not the
|
||||
visible pane** (`ui::draw` passes `f.area().height` to `resize` for every
|
||||
view except Full): their height is derived by measuring what Ink has already
|
||||
drawn, and Ink only ever draws as many rows as the PTY reports, so tying the
|
||||
PTY to the (small) visible height is a feedback loop — an `@`/`/` menu or a
|
||||
big paste that suddenly needs many more rows than the current PTY+pad never
|
||||
@@ -212,9 +237,20 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
returns `None` on a transient mid-repaint (box border caught missing) so the
|
||||
last height is kept. Without this the height oscillates every frame during a
|
||||
subagent turn or `@`/`/` menu filtering, and each change resizes the PTY →
|
||||
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode
|
||||
pane, whose selection box renders *above* the input) top-anchors from row 2
|
||||
instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders
|
||||
Ink repaints → flicker. `PaneView::Interactive` (the tap-grown
|
||||
AskUserQuestion / ExitPlanMode pane, whose selection box renders *above* the
|
||||
input) is **measured the same way, never estimated**: `interactive_frame`
|
||||
anchors on the rule above the header-chip row (`← ☐ Header ✔ Submit →`),
|
||||
else the second-to-last rule, and runs to the last non-blank row;
|
||||
`EmbeddedTerm::interactive_rows` feeds that height through the same
|
||||
hysteresis. `App::ask_question_rows` (the row guess from the tool JSON) is
|
||||
only the fallback for the frames before Ink has drawn the box — it can't know
|
||||
how far the question text wraps, which is what used to crop the first
|
||||
paragraph. Because the Interactive PTY is now screen-tall, Claude Code lays
|
||||
the prompt out in full instead of switching to its own truncated form.
|
||||
`interactive_view_range` top-anchors on that frame and slides down only far
|
||||
enough to keep the `❯` option on screen when the prompt overflows the pane.
|
||||
`PaneView::Full` (fullscreen) renders
|
||||
the child's screen verbatim from row 0 with the PTY sized exactly to the
|
||||
pane. Permission-prompt boxes (rounded borders, not rules, and not in the
|
||||
API stream) aren't expanded in the compact pane — consistent with the
|
||||
@@ -235,7 +271,14 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
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
|
||||
anchored by `opus`+`sonnet`. The list then gets the **1M-context variants**
|
||||
appended (`sonnet[1m]` etc., Claude Code's `--model` spelling for the long
|
||||
context window): `term::long_context_tokens` collects every quoted
|
||||
`"<token>[1m]"` literal in the same byte scan, and only aliases that really
|
||||
have one are offered (today `opus`/`sonnet`/`fable` — *not* `haiku` or
|
||||
`mythos`), so the suffix is never assumed. `[1m]` needs no shell quoting:
|
||||
the pane spawns via `CommandBuilder` argv, not a shell.
|
||||
Tab/BackTab cycle sessions (`p` no longer
|
||||
mirrors BackTab). v visual range, b branch, Esc unwinds (visual → tree →
|
||||
quit). n/N jump the feed scroll to the next/previous user prompt
|
||||
(`App::prompt_jump`, applied in `draw` where entry heights are cached). The
|
||||
@@ -275,7 +318,15 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
||||
`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.
|
||||
fully offline end-to-end tests with zero API usage. That fake server is
|
||||
`dev/fake_upstream.py`: it answers every request with canned SSE, so a **real
|
||||
`claude` child** can be made to render its client-side tool UIs on demand
|
||||
(`dev/.fake_scenario` = `ask | plan | todo | taskupdate | text`, switchable
|
||||
mid-run) — this is how the pane's frame detector is developed against what
|
||||
Ink actually draws. Drive it through tmux (`.claude/skills/tui-verify`) and
|
||||
obey that skill's safety rule: **never `pkill`/`killall`**, tear down only
|
||||
your own named tmux session. The child writes real task files under
|
||||
`~/.claude/tasks/<its-session-id>/`; delete that directory afterwards.
|
||||
|
||||
## Not yet handled (known MVP limits)
|
||||
|
||||
|
||||
151
src/app.rs
151
src/app.rs
@@ -20,6 +20,11 @@ pub const FILTER_LABELS: [&str; 7] =
|
||||
/// 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).
|
||||
///
|
||||
/// The `<alias>[1m]` entries are Claude Code's spelling for the 1M-context
|
||||
/// variant of a model (only some aliases have one — `haiku` does not). The live
|
||||
/// set is verified against the binary by `term::long_context_tokens`; these are
|
||||
/// just the seeds.
|
||||
pub fn default_model_choices() -> Vec<(String, String)> {
|
||||
[
|
||||
("default", ""),
|
||||
@@ -27,12 +32,39 @@ pub fn default_model_choices() -> Vec<(String, String)> {
|
||||
("sonnet", "sonnet"),
|
||||
("haiku", "haiku"),
|
||||
("fable", "fable"),
|
||||
("opus[1m] (1M context)", "opus[1m]"),
|
||||
("sonnet[1m] (1M context)", "sonnet[1m]"),
|
||||
("fable[1m] (1M context)", "fable[1m]"),
|
||||
]
|
||||
.iter()
|
||||
.map(|(l, a)| (l.to_string(), a.to_string()))
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// Map an API model id (as recorded in a transcript, e.g.
|
||||
/// `claude-sonnet-4-5-20250929`) to a `--model` argument for `claude`.
|
||||
///
|
||||
/// A known alias (`sonnet`, `opus`, … — the same list the `a` picker uses)
|
||||
/// wins over the dated snapshot id: it keeps the intent ("this session ran on
|
||||
/// sonnet") without pinning a snapshot that may later be retired. An id with
|
||||
/// no alias inside is passed through verbatim (`--model` accepts full model
|
||||
/// names too). Placeholders Claude Code writes for its own records
|
||||
/// (`<synthetic>`) and our `(resumed)` filler yield None.
|
||||
pub fn model_arg_for_id(id: &str, aliases: &[String]) -> Option<String> {
|
||||
let id = id.trim();
|
||||
if id.is_empty() || id.starts_with('<') || id.starts_with('(') {
|
||||
return None;
|
||||
}
|
||||
// Longest match wins, so a short alias can never shadow a longer one.
|
||||
let mut hit: Option<&String> = None;
|
||||
for a in aliases.iter().filter(|a| !a.is_empty() && !a.contains('[')) {
|
||||
if id.contains(a.as_str()) && hit.is_none_or(|h| a.len() > h.len()) {
|
||||
hit = Some(a);
|
||||
}
|
||||
}
|
||||
Some(hit.map_or_else(|| id.to_string(), String::clone))
|
||||
}
|
||||
|
||||
pub struct App {
|
||||
pub sessions: Vec<Session>,
|
||||
pub selected: usize,
|
||||
@@ -197,6 +229,44 @@ impl App {
|
||||
true
|
||||
}
|
||||
|
||||
/// The `--model` argument a resume of `key` should spawn with, so the pane
|
||||
/// continues on the model that session last used instead of the CLI
|
||||
/// default. Empty string = pass no `--model` flag.
|
||||
///
|
||||
/// The on-disk transcript is the source of truth: Claude Code records the
|
||||
/// model of every assistant message, so a mid-session `/model` switch is
|
||||
/// visible there. The pane's own spawn argument is only consulted to keep a
|
||||
/// `[1m]` (1M-context) pick alive — the transcript records the base model id
|
||||
/// for both — and only while it still names the same model.
|
||||
pub fn resume_model(&self, key: &str) -> String {
|
||||
let aliases: Vec<String> =
|
||||
self.model_choices.iter().map(|(_, a)| a.clone()).collect();
|
||||
let disk = self
|
||||
.disk_sessions
|
||||
.iter()
|
||||
.find(|d| d.uuid == key)
|
||||
.and_then(|d| model_arg_for_id(&d.model, &aliases));
|
||||
let spawned = self
|
||||
.sessions
|
||||
.iter()
|
||||
.find(|s| s.key == key)
|
||||
.and_then(|s| s.spawn_model.clone())
|
||||
.filter(|m| !m.is_empty());
|
||||
match (disk, spawned) {
|
||||
(Some(d), Some(s)) if s.split('[').next() == Some(d.as_str()) => s,
|
||||
(Some(d), _) => d,
|
||||
(None, s) => s.unwrap_or_default(),
|
||||
}
|
||||
}
|
||||
|
||||
/// Remember the `--model` argument the pane for `key` was spawned with
|
||||
/// (see `resume_model`). No-op when the session row doesn't exist yet.
|
||||
pub fn set_spawn_model(&mut self, key: &str, model: &str) {
|
||||
if let Some(s) = self.sessions.iter_mut().find(|s| s.key == key) {
|
||||
s.spawn_model = Some(model.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// 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).
|
||||
@@ -412,7 +482,16 @@ impl App {
|
||||
};
|
||||
idxs.dedup();
|
||||
let title = format!("⑂ {}", e.tree.turns[*idxs.last().unwrap()].label);
|
||||
let src_uuid = e.uuid.clone();
|
||||
let new_uuid = crate::sessions::materialize(&e.tree, &idxs, &title)?;
|
||||
// The branch file carries the source session's assistant records, so it
|
||||
// resumes on the same model. The next scan re-reads it from disk; this
|
||||
// is only the value for the ~1s until then.
|
||||
let model = self
|
||||
.disk_sessions
|
||||
.iter()
|
||||
.find(|d| d.uuid == src_uuid)
|
||||
.map_or(String::new(), |d| d.model.clone());
|
||||
if let Some(e) = self.expanded.as_mut() {
|
||||
e.visual = None;
|
||||
e.sel = None; // the selection moves to the new stub
|
||||
@@ -424,6 +503,7 @@ impl App {
|
||||
crate::sessions::DiskSession {
|
||||
uuid: new_uuid.clone(),
|
||||
label: title,
|
||||
model,
|
||||
modified: std::time::SystemTime::now(),
|
||||
},
|
||||
);
|
||||
@@ -478,6 +558,11 @@ pub struct Session {
|
||||
/// Signature (joined tool names) of the tool set last surfaced as a
|
||||
/// `Kind::ToolDefs` entry; re-emitted only when the available tools change.
|
||||
pub last_tools_sig: Option<String>,
|
||||
/// The `--model` argument the embedded pane for this session was spawned
|
||||
/// with (`None` for external sessions, `Some("")` for the CLI default).
|
||||
/// In-process memory only; it travels with the row when the tap renames
|
||||
/// the key. `resume_model` uses it to keep a `[1m]` pick across a resume.
|
||||
pub spawn_model: Option<String>,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
@@ -493,6 +578,7 @@ impl Session {
|
||||
tool_ids: HashMap::new(),
|
||||
last_system_len: None,
|
||||
last_tools_sig: None,
|
||||
spawn_model: None,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1072,6 +1158,65 @@ mod tests {
|
||||
use super::*;
|
||||
use serde_json::json;
|
||||
|
||||
fn aliases() -> Vec<String> {
|
||||
default_model_choices().into_iter().map(|(_, a)| a).collect()
|
||||
}
|
||||
|
||||
/// A disk stub with a recorded model, as the scanner produces it.
|
||||
fn stub(uuid: &str, model: &str) -> crate::sessions::DiskSession {
|
||||
crate::sessions::DiskSession {
|
||||
uuid: uuid.into(),
|
||||
label: uuid.into(),
|
||||
model: model.into(),
|
||||
modified: std::time::SystemTime::now(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn model_id_maps_to_its_alias() {
|
||||
let al = aliases();
|
||||
let arg = |id: &str| model_arg_for_id(id, &al);
|
||||
assert_eq!(arg("claude-sonnet-4-5-20250929").as_deref(), Some("sonnet"));
|
||||
assert_eq!(arg("claude-opus-4-5-20251101").as_deref(), Some("opus"));
|
||||
assert_eq!(arg("claude-opus-5").as_deref(), Some("opus"));
|
||||
assert_eq!(arg("claude-3-5-haiku-20241022").as_deref(), Some("haiku"));
|
||||
// No known alias inside → the full id (accepted by `--model` too).
|
||||
assert_eq!(arg("claude-mythos-1-20260101").as_deref(), Some("claude-mythos-1-20260101"));
|
||||
// Placeholders yield nothing: no `--model` flag at all.
|
||||
assert_eq!(arg("<synthetic>"), None);
|
||||
assert_eq!(arg("(resumed)"), None);
|
||||
assert_eq!(arg(""), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_model_prefers_the_transcript() {
|
||||
let mut a = App::new();
|
||||
a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")];
|
||||
assert_eq!(a.resume_model("s1"), "opus");
|
||||
// Unknown session → no flag.
|
||||
assert_eq!(a.resume_model("nope"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn resume_model_keeps_a_1m_pick_until_the_model_changes() {
|
||||
let mut a = App::new();
|
||||
a.sessions.push(Session::new("s1".into(), "sonnet".into()));
|
||||
a.set_spawn_model("s1", "sonnet[1m]");
|
||||
// Transcript agrees on the model → the long-context spelling survives
|
||||
// (the transcript records the base id for both).
|
||||
a.disk_sessions = vec![stub("s1", "claude-sonnet-4-5-20250929")];
|
||||
assert_eq!(a.resume_model("s1"), "sonnet[1m]");
|
||||
// A mid-session `/model` switch shows up on disk and wins.
|
||||
a.disk_sessions = vec![stub("s1", "claude-opus-4-5-20251101")];
|
||||
assert_eq!(a.resume_model("s1"), "opus");
|
||||
// No transcript model yet (spawned, never answered) → the spawn arg.
|
||||
a.disk_sessions.clear();
|
||||
assert_eq!(a.resume_model("s1"), "sonnet[1m]");
|
||||
// The CLI default is remembered as "no flag", not as a model.
|
||||
a.set_spawn_model("s1", "");
|
||||
assert_eq!(a.resume_model("s1"), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn tool_result_attaches_to_entry() {
|
||||
let app: SharedApp = Arc::new(Mutex::new(App::new()));
|
||||
@@ -1497,11 +1642,7 @@ mod tests {
|
||||
}
|
||||
|
||||
fn ds(uuid: &str) -> crate::sessions::DiskSession {
|
||||
crate::sessions::DiskSession {
|
||||
uuid: uuid.into(),
|
||||
label: uuid.into(),
|
||||
modified: std::time::SystemTime::now(),
|
||||
}
|
||||
stub(uuid, "")
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -24,21 +24,26 @@ pub struct DiskSession {
|
||||
pub uuid: String,
|
||||
/// Best human-readable label: ai-title > last-prompt text > uuid prefix.
|
||||
pub label: String,
|
||||
/// API model id of the session's last main-chain assistant message
|
||||
/// (empty when the file has none yet). `App::resume_model` turns it into
|
||||
/// the `--model` argument a resume spawns with.
|
||||
pub model: 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.
|
||||
/// labels and models 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();
|
||||
// uuid → (mtime when read, label, model): skip re-parsing unchanged
|
||||
// files (one pass yields both — see `read_meta`).
|
||||
let mut meta: HashMap<String, (SystemTime, String, String)> = HashMap::new();
|
||||
let mut last: Vec<DiskSession> = Vec::new();
|
||||
loop {
|
||||
let list = scan(&mut labels).unwrap_or_default();
|
||||
let list = scan(&mut meta).unwrap_or_default();
|
||||
if list != last {
|
||||
last = list.clone();
|
||||
lock_app(&app).set_disk_sessions(list);
|
||||
@@ -50,7 +55,7 @@ pub fn spawn_scanner(app: SharedApp) {
|
||||
|
||||
/// One scan of the project directory, newest first.
|
||||
fn scan(
|
||||
labels: &mut HashMap<String, (SystemTime, String)>,
|
||||
meta: &mut HashMap<String, (SystemTime, String, String)>,
|
||||
) -> Result<Vec<DiskSession>, String> {
|
||||
let dir = project_dir()?;
|
||||
let rd = std::fs::read_dir(&dir)
|
||||
@@ -64,15 +69,15 @@ fn scan(
|
||||
}
|
||||
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 (label, model) = match meta.get(&uuid) {
|
||||
Some((m, l, md)) if *m == modified => (l.clone(), md.clone()),
|
||||
_ => {
|
||||
let l = read_label(&path, &uuid);
|
||||
labels.insert(uuid.clone(), (modified, l.clone()));
|
||||
l
|
||||
let read = read_meta(&path, &uuid);
|
||||
meta.insert(uuid.clone(), (modified, read.0.clone(), read.1.clone()));
|
||||
read
|
||||
}
|
||||
};
|
||||
Some(DiskSession { uuid, label, modified })
|
||||
Some(DiskSession { uuid, label, model, modified })
|
||||
})
|
||||
.collect();
|
||||
sessions.sort_by(|a, b| b.modified.cmp(&a.modified));
|
||||
@@ -408,6 +413,7 @@ impl EntryParser {
|
||||
tool_ids: HashMap::new(),
|
||||
last_system_len: None,
|
||||
last_tools_sig: None,
|
||||
spawn_model: None,
|
||||
},
|
||||
leaf,
|
||||
turn_entries,
|
||||
@@ -562,25 +568,37 @@ fn encode_cwd(path: &str) -> String {
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// 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 {
|
||||
/// Read a session's list metadata from its JSONL file in one pass:
|
||||
/// `(label, model)`.
|
||||
///
|
||||
/// The label prefers `ai-title`; it falls back to the last non-empty
|
||||
/// `last-prompt` text, then the uuid prefix. The model is the API model id of
|
||||
/// the last *main-chain* assistant message — what Claude Code itself recorded
|
||||
/// for the newest turn, so a resume can continue on it. Subagent records
|
||||
/// (`isSidechain`) carry their own model and are skipped, as are the
|
||||
/// `<synthetic>` ids Claude Code writes for API-error records.
|
||||
fn read_meta(path: &std::path::Path, uuid: &str) -> (String, String) {
|
||||
let fallback = || uuid.chars().take(8).collect::<String>();
|
||||
let Ok(f) = std::fs::File::open(path) else {
|
||||
return fallback();
|
||||
return (fallback(), String::new());
|
||||
};
|
||||
let reader = std::io::BufReader::new(f);
|
||||
let mut ai_title: Option<String> = None;
|
||||
let mut last_prompt: Option<String> = None;
|
||||
let mut model = String::new();
|
||||
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") => {
|
||||
// First title wins (the scan no longer stops there — it still
|
||||
// needs the model): a branch file we materialized carries our own
|
||||
// `⑂ …` title first, and Claude Code may append its own later.
|
||||
Some("ai-title") if ai_title.is_none() => {
|
||||
if let Some(t) = v.get("aiTitle").and_then(|t| t.as_str())
|
||||
&& !t.is_empty()
|
||||
{
|
||||
return one_line(t);
|
||||
ai_title = Some(one_line(t));
|
||||
}
|
||||
}
|
||||
Some("last-prompt") => {
|
||||
@@ -590,10 +608,19 @@ fn read_label(path: &std::path::Path, uuid: &str) -> String {
|
||||
last_prompt = Some(one_line(p));
|
||||
}
|
||||
}
|
||||
Some("assistant") => {
|
||||
if v.get("isSidechain").and_then(Value::as_bool) != Some(true)
|
||||
&& let Some(m) = v.pointer("/message/model").and_then(Value::as_str)
|
||||
&& !m.starts_with('<')
|
||||
{
|
||||
model = m.to_string();
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
last_prompt.unwrap_or_else(fallback)
|
||||
let label = ai_title.or(last_prompt).unwrap_or_else(fallback);
|
||||
(label, model)
|
||||
}
|
||||
|
||||
/// First line only, control characters dropped — labels go into a one-row
|
||||
@@ -787,8 +814,9 @@ mod tests {
|
||||
let p = write_jsonl(&[
|
||||
r#"{"type":"last-prompt","lastPrompt":"fix the bug"}"#,
|
||||
r#"{"type":"ai-title","aiTitle":"bug fixing session"}"#,
|
||||
r#"{"type":"ai-title","aiTitle":"retitled later"}"#,
|
||||
]);
|
||||
assert_eq!(read_label(&p, "deadbeef-0000"), "bug fixing session");
|
||||
assert_eq!(read_meta(&p, "deadbeef-0000").0, "bug fixing session");
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
|
||||
@@ -798,11 +826,30 @@ mod tests {
|
||||
r#"{"type":"last-prompt","lastPrompt":"first"}"#,
|
||||
r#"{"type":"last-prompt","lastPrompt":"latest\nmultiline"}"#,
|
||||
]);
|
||||
assert_eq!(read_label(&p, "deadbeef-0000"), "latest");
|
||||
assert_eq!(read_meta(&p, "deadbeef-0000").0, "latest");
|
||||
std::fs::remove_file(p).ok();
|
||||
|
||||
let p = write_jsonl(&[r#"{"type":"user"}"#, "not json"]);
|
||||
assert_eq!(read_label(&p, "deadbeef-0000"), "deadbeef");
|
||||
assert_eq!(read_meta(&p, "deadbeef-0000").0, "deadbeef");
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn meta_reads_the_last_main_chain_model() {
|
||||
let p = write_jsonl(&[
|
||||
r#"{"type":"assistant","message":{"model":"claude-opus-4-5-20251101","content":[]}}"#,
|
||||
r#"{"type":"assistant","message":{"model":"claude-sonnet-4-5-20250929","content":[]}}"#,
|
||||
// A subagent turn runs its own model — not the session's.
|
||||
r#"{"type":"assistant","isSidechain":true,"message":{"model":"claude-haiku-4-5-20251001","content":[]}}"#,
|
||||
// An API-error record carries a placeholder id, not a model.
|
||||
r#"{"type":"assistant","message":{"model":"<synthetic>","content":[]}}"#,
|
||||
]);
|
||||
assert_eq!(read_meta(&p, "deadbeef-0000").1, "claude-sonnet-4-5-20250929");
|
||||
std::fs::remove_file(p).ok();
|
||||
|
||||
// A session with no assistant record yet reports no model.
|
||||
let p = write_jsonl(&[r#"{"type":"user","message":{"role":"user","content":"hi"}}"#]);
|
||||
assert_eq!(read_meta(&p, "deadbeef-0000").1, "");
|
||||
std::fs::remove_file(p).ok();
|
||||
}
|
||||
|
||||
|
||||
533
src/term.rs
533
src/term.rs
@@ -115,11 +115,22 @@ impl EmbeddedTerm {
|
||||
/// but Claude Code is not guaranteed to (it can mint a fresh id on resume),
|
||||
/// so the tap still correlates by the injected pane token and *rebinds* the
|
||||
/// embed to whatever id the traffic actually reports. `--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> {
|
||||
/// NOT be passed alongside `--resume` (rejected without `--fork-session`);
|
||||
/// `--model` may be, and carries the session's last model forward
|
||||
/// (`App::resume_model`) so a resume doesn't drop back to the CLI default.
|
||||
pub fn spawn_resume(
|
||||
port: u16,
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
session_id: &str,
|
||||
model: &str,
|
||||
) -> anyhow::Result<Self> {
|
||||
let pane_token = uuid::Uuid::new_v4().to_string();
|
||||
let mut cmd = CommandBuilder::new("claude");
|
||||
cmd.args(["--resume", session_id]);
|
||||
if !model.is_empty() {
|
||||
cmd.args(["--model", model]);
|
||||
}
|
||||
cmd.env("ANTHROPIC_BASE_URL", format!("http://127.0.0.1:{port}"));
|
||||
cmd.env("ANTHROPIC_CUSTOM_HEADERS", format!("{PANE_TOKEN_HEADER}: {pane_token}"));
|
||||
if let Ok(cwd) = std::env::current_dir() {
|
||||
@@ -292,13 +303,29 @@ impl EmbeddedTerm {
|
||||
/// known height on `None` rather than snapping to a default, which is what
|
||||
/// stops the pane from flickering during busy output.
|
||||
pub fn compact_rows(&self) -> Option<u16> {
|
||||
let (top, bottom) = compact_frame(&self.screen_rows())?;
|
||||
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
|
||||
}
|
||||
|
||||
/// Inner rows the pane wants for the interactive prompt Claude Code draws
|
||||
/// for AskUserQuestion / ExitPlanMode (see `interactive_frame`). Measured
|
||||
/// from the child's screen for the same reason `compact_rows` is: the tap's
|
||||
/// row estimate from the tool JSON can only guess how far the question text
|
||||
/// and option descriptions wrap, and guessing short is what cropped the top
|
||||
/// of the prompt. `None` until the prompt has been drawn — the caller then
|
||||
/// keeps its previous height (or the tap's estimate as a first guess).
|
||||
pub fn interactive_rows(&self) -> Option<u16> {
|
||||
let (top, bottom) = interactive_frame(&self.screen_rows())?;
|
||||
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
|
||||
}
|
||||
|
||||
/// Visible text of every screen row, top to bottom.
|
||||
fn screen_rows(&self) -> Vec<String> {
|
||||
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 rows: Vec<String> = lines.iter().map(row_text).collect();
|
||||
let (top, bottom) = compact_frame(&rows)?;
|
||||
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
|
||||
lines.iter().map(row_text).collect()
|
||||
}
|
||||
|
||||
/// Paint a window of the child's screen into `area`. Returns the cursor
|
||||
@@ -310,9 +337,9 @@ impl EmbeddedTerm {
|
||||
/// statusLine, cropping the persistent hint/token/effort chrome below it;
|
||||
/// shows a whole `@`/`/` menu instead when one is open. Bottom-anchored
|
||||
/// if the pane is shorter than the framed region.
|
||||
/// - `Interactive`: the same pane grown by the tap for an
|
||||
/// AskUserQuestion / ExitPlanMode prompt, whose selection box renders
|
||||
/// *above* the input — top-anchored (from row 2) so it stays visible.
|
||||
/// - `Interactive`: the AskUserQuestion / ExitPlanMode prompt the tap grew
|
||||
/// the pane for — framed from its own top border (see
|
||||
/// `interactive_view_range`) so the question text is never cropped.
|
||||
/// - `Full` (fullscreen): the screen verbatim from row 0, nothing cut off.
|
||||
pub fn render(&self, area: Rect, buf: &mut Buffer, view: PaneView) -> Option<(u16, u16)> {
|
||||
let term = self.term.lock().unwrap();
|
||||
@@ -328,13 +355,7 @@ impl EmbeddedTerm {
|
||||
// not have landed yet — clamp to whatever fits.
|
||||
(0, lines.len().min(h).saturating_sub(1))
|
||||
}
|
||||
PaneView::Interactive => {
|
||||
// The prompt box renders above the input; keep showing from
|
||||
// near the top so it's visible, bottom-anchored if it overflows.
|
||||
let end = last;
|
||||
let start = (end + 1).saturating_sub(h).max(2).min(end);
|
||||
(start, end)
|
||||
}
|
||||
PaneView::Interactive => interactive_view_range(&rows, last, h),
|
||||
PaneView::Compact => compact_view_range(&rows, last, h),
|
||||
};
|
||||
for (y, line) in lines[start..=end].iter().enumerate() {
|
||||
@@ -410,6 +431,80 @@ fn text_is_menu_item(t: &str) -> bool {
|
||||
["+ ", "* ", "❯ ", "› "].iter().any(|m| t.starts_with(m)) || t.starts_with('/')
|
||||
}
|
||||
|
||||
/// Status glyphs Claude Code prints in front of a task/todo row: pending
|
||||
/// (`squareSmall`), in-progress (`squareSmallFilled`) and completed (`tick`).
|
||||
/// The same `☐`/`◻` glyph also leads an AskUserQuestion header chip, which is
|
||||
/// what `interactive_frame` keys on.
|
||||
const TASK_GLYPHS: [char; 6] = ['◻', '◼', '✔', '✓', '☐', '☒'];
|
||||
|
||||
/// A row of Claude Code's task/todo panel — the block it keeps directly above
|
||||
/// the input box while a task list is alive. Two shapes exist:
|
||||
/// - standalone (turn finished): a `N tasks (x done, y open)` header followed
|
||||
/// by up to 5 `◻/◼/✔ subject` rows and a dim `… +3 pending` overflow row;
|
||||
/// - in-flight (turn running): the same rows hung under the spinner row with a
|
||||
/// `⎿` tool-result gutter.
|
||||
///
|
||||
/// `⎿` alone is *every* tool result's gutter, so it only counts here when a
|
||||
/// task glyph follows it — otherwise a plain `⎿ Read 20 lines` would grow the
|
||||
/// pane on every tool call.
|
||||
fn text_is_task_row(t: &str) -> bool {
|
||||
let t = t.trim_start();
|
||||
let after_gutter = t.strip_prefix('⎿').map(str::trim_start).unwrap_or(t);
|
||||
if after_gutter.starts_with(TASK_GLYPHS) {
|
||||
return true;
|
||||
}
|
||||
// Dim overflow tail: "… +3 pending, 2 completed".
|
||||
if t.starts_with('…') {
|
||||
return true;
|
||||
}
|
||||
// Standalone header: "268 tasks (0 done, 268 open)".
|
||||
let digits: String = t.chars().take_while(char::is_ascii_digit).collect();
|
||||
!digits.is_empty() && t[digits.len()..].starts_with(" tasks (")
|
||||
}
|
||||
|
||||
/// Walk up from `from` (the row just above the input box) over Claude Code's
|
||||
/// task panel and return its topmost row, or None when no task block sits
|
||||
/// there. One blank row is tolerated on the way in (the panel is drawn with a
|
||||
/// `marginTop`), and the walk is bounded so a screen full of glyph-ish text
|
||||
/// can't swallow the whole pane.
|
||||
fn task_block_top(rows: &[String], from: usize) -> Option<usize> {
|
||||
/// Panel worst case: header + 5 task rows + 5 activity rows + overflow.
|
||||
const MAX_TASK_BLOCK: usize = 14;
|
||||
let mut i = from;
|
||||
if rows[i].trim().is_empty() {
|
||||
i = i.checked_sub(1)?;
|
||||
}
|
||||
if !text_is_task_row(&rows[i]) {
|
||||
return None;
|
||||
}
|
||||
let floor = i.saturating_sub(MAX_TASK_BLOCK);
|
||||
while i > floor {
|
||||
if text_is_task_row(&rows[i - 1]) {
|
||||
i -= 1;
|
||||
continue;
|
||||
}
|
||||
// An in-progress task can carry a dim activity line under it, and a
|
||||
// long subject wraps — neither starts with a glyph. Step over a single
|
||||
// such row when a real task row sits above it.
|
||||
if i >= floor + 2 && !rows[i - 1].trim().is_empty() && text_is_task_row(&rows[i - 2]) {
|
||||
i -= 2;
|
||||
continue;
|
||||
}
|
||||
break;
|
||||
}
|
||||
Some(i)
|
||||
}
|
||||
|
||||
/// Step one row further up when `i` lands on a blank row, so the frame's top
|
||||
/// context row carries text (the panel is drawn with a blank `marginTop` row
|
||||
/// above it, and showing that blank instead of the spinner row wastes a line).
|
||||
fn skip_blank_up(rows: &[String], i: usize) -> usize {
|
||||
match i.checked_sub(1) {
|
||||
Some(prev) if rows[i].trim().is_empty() && !rows[prev].trim().is_empty() => prev,
|
||||
_ => i,
|
||||
}
|
||||
}
|
||||
|
||||
/// Locate Claude Code's input box in `rows` (the visible text of each screen
|
||||
/// row) and return the inclusive range `(top, bottom)` the compact pane should
|
||||
/// show: one context row above the box (the spinner / "✻ Worked…" row when
|
||||
@@ -425,13 +520,26 @@ fn text_is_menu_item(t: &str) -> bool {
|
||||
/// many lines tall (a long or pasted prompt), which is exactly the auto-expand
|
||||
/// we want.
|
||||
fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
|
||||
compact_frame_ex(rows).map(|(top, bottom, _)| (top, bottom))
|
||||
compact_frame_ex(rows).map(|f| (f.top, f.bottom))
|
||||
}
|
||||
|
||||
/// Same as `compact_frame`, but also reports whether the frame ends on an open
|
||||
/// `@`/`/` menu (as opposed to the idle statusLine) — `render` needs this to
|
||||
/// pick which end of the region to sacrifice when it doesn't fit the pane.
|
||||
fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> {
|
||||
/// Where the compact pane's window sits, plus what `compact_view_range` needs
|
||||
/// to decide which end to sacrifice when the region is taller than the pane.
|
||||
struct CompactFrame {
|
||||
/// First row to show: the task panel's top when one is up, else the single
|
||||
/// context row above the input box.
|
||||
top: usize,
|
||||
/// The context row above the input box — the top the pane falls back to
|
||||
/// when the full region doesn't fit. The task panel is a nice-to-have;
|
||||
/// the input box is not.
|
||||
ess_top: usize,
|
||||
bottom: usize,
|
||||
/// The region ends on an open `@`/`/` menu rather than the statusLine.
|
||||
menu_open: bool,
|
||||
}
|
||||
|
||||
/// Same as `compact_frame`, but keeps the fields `compact_view_range` needs.
|
||||
fn compact_frame_ex(rows: &[String]) -> Option<CompactFrame> {
|
||||
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
|
||||
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
|
||||
if rules.len() < 2 {
|
||||
@@ -439,7 +547,16 @@ fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> {
|
||||
}
|
||||
let bot_div = rules[rules.len() - 1];
|
||||
let top_div = rules[rules.len() - 2];
|
||||
let view_top = top_div.saturating_sub(1);
|
||||
// Normally one context row above the box (the spinner / "✻ Worked…" row).
|
||||
// While a task list is alive Claude Code parks its task panel exactly
|
||||
// there, so the frame swallows the whole panel plus the context row above
|
||||
// it — that panel *is* the status of the run, and the compact pane is the
|
||||
// only place the user sees it (the feed shows the API stream, not CC's UI).
|
||||
let ctx_top = top_div.saturating_sub(1);
|
||||
let view_top = match top_div.checked_sub(1).and_then(|i| task_block_top(rows, i)) {
|
||||
Some(t) => skip_blank_up(rows, t.saturating_sub(1)),
|
||||
None => ctx_top,
|
||||
};
|
||||
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
|
||||
// list. Scan the *whole* region under the rule for a menu row, not just the
|
||||
// one immediately below it: the list can start after a blank separator or a
|
||||
@@ -450,30 +567,117 @@ fn compact_frame_ex(rows: &[String]) -> Option<(usize, usize, bool)> {
|
||||
// match `text_is_menu_item`, so scanning stays free of false positives.
|
||||
let menu_open = last > bot_div && (bot_div + 1..=last).any(|i| text_is_menu_item(&rows[i]));
|
||||
let view_bottom = if menu_open { last } else { (bot_div + 1).min(last) };
|
||||
Some((view_top, view_bottom, menu_open))
|
||||
Some(CompactFrame { top: view_top, ess_top: ctx_top, bottom: view_bottom, menu_open })
|
||||
}
|
||||
|
||||
/// Pick the `(start, end)` window `render` shows for `PaneView::Compact`,
|
||||
/// given the pane's available inner height `h`. Delegates to
|
||||
/// `compact_frame_ex` for *where* the box/menu is, and decides which end to
|
||||
/// sacrifice when the framed region is taller than the pane:
|
||||
/// - menu open: top-anchor. The input box (what's being typed) sits at the
|
||||
/// top of the region and the match list runs to the bottom, so overflow
|
||||
/// must crop the *menu's tail*, not the input box — bottom-anchoring here
|
||||
/// would hide the very thing the user is typing behind a wall of filenames.
|
||||
/// - no menu: bottom-anchor on the statusLine, as before, so a long pasted
|
||||
/// prompt keeps its tail + cursor visible and only context rows are cropped.
|
||||
/// `compact_frame_ex` for *where* the box/menu/task panel is, and decides what
|
||||
/// to sacrifice when the framed region is taller than the pane:
|
||||
/// - the task panel goes first. It is context about the run; the input box is
|
||||
/// what the user is driving, so an overflowing region falls back to
|
||||
/// `ess_top` (the single context row above the box) before cropping
|
||||
/// anything else.
|
||||
/// - menu open: top-anchor from there. The input box sits at the top of what
|
||||
/// remains and the match list runs to the bottom, so overflow must crop the
|
||||
/// *menu's tail* — bottom-anchoring would hide the line being typed behind a
|
||||
/// wall of filenames.
|
||||
/// - no menu: bottom-anchor on the statusLine, so a long pasted prompt keeps
|
||||
/// its tail + cursor visible and only context rows are cropped.
|
||||
/// - no box located yet (startup banner, or a transient mid-repaint):
|
||||
/// bottom-anchor the raw content.
|
||||
fn compact_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) {
|
||||
match compact_frame_ex(rows) {
|
||||
Some((top, bottom, true)) => (top, bottom.min(top + h.saturating_sub(1))),
|
||||
Some((top, bottom, false)) => {
|
||||
let start = (bottom + 1).saturating_sub(h).max(top).min(bottom);
|
||||
(start, bottom)
|
||||
}
|
||||
None => (last.saturating_sub(h.saturating_sub(1)), last),
|
||||
let Some(f) = compact_frame_ex(rows) else {
|
||||
return (last.saturating_sub(h.saturating_sub(1)), last);
|
||||
};
|
||||
// Drop the task panel before cropping the box itself.
|
||||
let top = if f.bottom + 1 - f.top > h { f.ess_top } else { f.top };
|
||||
if f.menu_open {
|
||||
return (top, f.bottom.min(top + h.saturating_sub(1)));
|
||||
}
|
||||
let start = (f.bottom + 1).saturating_sub(h).max(top).min(f.bottom);
|
||||
(start, f.bottom)
|
||||
}
|
||||
|
||||
/// Locate the interactive prompt Claude Code draws for AskUserQuestion /
|
||||
/// ExitPlanMode and return the inclusive row range the pane should show:
|
||||
/// one context row above the prompt's top border down to its hint row
|
||||
/// ("Enter to select · ↑/↓ to navigate · Esc to cancel"), which is the last
|
||||
/// non-blank row on screen.
|
||||
///
|
||||
/// Shape of the question prompt in CC 2.1.x — note it *replaces* the input box
|
||||
/// (there is no `❯` box on screen while it waits), and it draws its own borders
|
||||
/// with the same `────` rules:
|
||||
///
|
||||
/// ```text
|
||||
/// ● Let me check how you want this framed. <- context row
|
||||
/// ───────────────────────────────────────── <- top border
|
||||
/// ☐ Framing <- header chip
|
||||
/// <- (blank)
|
||||
/// The question text, wrapped over as many
|
||||
/// rows as it needs.
|
||||
///
|
||||
/// ❯ 1. Option label <- selected option
|
||||
/// option description
|
||||
/// 2. …
|
||||
/// ───────────────────────────────────────── <- separator above the tail
|
||||
/// 5. Chat about this
|
||||
///
|
||||
/// Enter to select · ↑/↓ to navigate · Esc to cancel
|
||||
/// ```
|
||||
///
|
||||
/// So the top border is *not* the last rule (that one is the separator near the
|
||||
/// bottom). It is found by the header chip that follows it, and only failing
|
||||
/// that by rule position. Returns None when no prompt is on screen yet — the
|
||||
/// tap flips the pane to `Interactive` the moment the tool call completes,
|
||||
/// which is a beat *before* Claude Code has drawn anything, so the caller keeps
|
||||
/// its previous height until this starts reporting.
|
||||
fn interactive_frame(rows: &[String]) -> Option<(usize, usize)> {
|
||||
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
|
||||
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
|
||||
// Preferred anchor: the rule immediately above the header chip row
|
||||
// (`☐ Framing`), which is the prompt box's own top border.
|
||||
let chip = rules
|
||||
.iter()
|
||||
.rev()
|
||||
.find(|&&i| rows.get(i + 1).is_some_and(|t| t.trim_start().starts_with(TASK_GLYPHS)));
|
||||
let top_div = match chip {
|
||||
Some(&i) => i,
|
||||
// No chip (ExitPlanMode, or a chip-less variant): the trailing pair of
|
||||
// rules brackets the prompt body, so take the upper one.
|
||||
None if rules.len() >= 2 => rules[rules.len() - 2],
|
||||
None => *rules.last()?,
|
||||
};
|
||||
Some((skip_blank_up(rows, top_div.saturating_sub(1)), last))
|
||||
}
|
||||
|
||||
/// Pick the `(start, end)` window `render` shows for `PaneView::Interactive`,
|
||||
/// given the pane's inner height `h`.
|
||||
///
|
||||
/// The prompt is top-anchored: its question text is the part that explains what
|
||||
/// is being asked, and cropping it (what a bottom anchor does) is exactly the
|
||||
/// reported bug. When even the top-anchored window can't reach the highlighted
|
||||
/// option, the window slides down just far enough to keep that option — plus
|
||||
/// the hint row below it — in view, so the prompt is always operable.
|
||||
fn interactive_view_range(rows: &[String], last: usize, h: usize) -> (usize, usize) {
|
||||
let Some((top, bottom)) = interactive_frame(rows) else {
|
||||
// Nothing framed yet: show from near the top, bottom-anchored.
|
||||
let start = (last + 1).saturating_sub(h).max(2).min(last);
|
||||
return (start, last);
|
||||
};
|
||||
let h = h.max(1);
|
||||
if bottom - top < h {
|
||||
return (top, bottom);
|
||||
}
|
||||
// Overflow: keep the selected option (`❯ 2. …`) visible.
|
||||
let sel = (top..=bottom).rev().find(|&i| rows[i].trim_start().starts_with('❯'));
|
||||
let mut end = top + h - 1;
|
||||
if let Some(sel) = sel
|
||||
&& sel > end
|
||||
{
|
||||
end = (sel + 1).min(bottom);
|
||||
}
|
||||
(end + 1 - h, end)
|
||||
}
|
||||
|
||||
impl Drop for EmbeddedTerm {
|
||||
@@ -521,29 +725,45 @@ fn conv_color(c: ColorAttribute) -> Option<Color> {
|
||||
/// 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)));
|
||||
if let Some(choices) = discover_model_choices() {
|
||||
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.
|
||||
/// Best-effort discovery of the models the installed `claude` accepts (aliases
|
||||
/// like `opus`, `sonnet`, `haiku`, `fable`, plus their `<alias>[1m]`
|
||||
/// long-context variants), so the `a` 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>> {
|
||||
/// `["sonnet","opus","haiku","fable"]` and each long-context variant as its own
|
||||
/// quoted `"sonnet[1m]"` literal. We resolve the `claude` binary on PATH and
|
||||
/// read it once. 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_choices() -> Option<Vec<(String, String)>> {
|
||||
let bytes = std::fs::read(claude_binary_path()?).ok()?;
|
||||
longest_alias_array(&bytes)
|
||||
let aliases = longest_alias_array(&bytes)?;
|
||||
Some(model_choices_from(&bytes, &aliases))
|
||||
}
|
||||
|
||||
/// Assemble picker entries `(label, --model arg)`: `default` (no `--model`
|
||||
/// flag) first, then every alias, then the `<alias>[1m]` long-context variants
|
||||
/// the binary actually ships (see `long_context_tokens`).
|
||||
fn model_choices_from(bytes: &[u8], aliases: &[String]) -> Vec<(String, String)> {
|
||||
let long = long_context_tokens(bytes);
|
||||
let mut choices: Vec<(String, String)> = vec![("default".into(), String::new())];
|
||||
choices.extend(aliases.iter().map(|a| (a.clone(), a.clone())));
|
||||
choices.extend(
|
||||
aliases
|
||||
.iter()
|
||||
.map(|a| format!("{a}[1m]"))
|
||||
.filter(|v| long.contains(v))
|
||||
.map(|v| (format!("{v} (1M context)"), v)),
|
||||
);
|
||||
choices
|
||||
}
|
||||
|
||||
/// Resolve `claude` on `PATH` to a readable file path (symlinks followed).
|
||||
@@ -577,6 +797,42 @@ fn longest_alias_array(bytes: &[u8]) -> Option<Vec<String>> {
|
||||
best
|
||||
}
|
||||
|
||||
/// Collect every quoted `"<token>[1m]"` literal in the buffer. `[1m]` is Claude
|
||||
/// Code's suffix for the 1M-context variant of a model, accepted by `--model`
|
||||
/// both on aliases (`sonnet[1m]`) and on full ids (`claude-opus-4-8[1m]`). Only
|
||||
/// some models have one, so we take the set from the binary instead of assuming
|
||||
/// every alias supports it. One pass: find each `[1m]"` and walk back over the
|
||||
/// token to its opening quote.
|
||||
fn long_context_tokens(bytes: &[u8]) -> std::collections::HashSet<String> {
|
||||
const SUFFIX: &[u8] = b"[1m]\"";
|
||||
let mut out = std::collections::HashSet::new();
|
||||
let mut i = 0;
|
||||
while i + SUFFIX.len() <= bytes.len() {
|
||||
if &bytes[i..i + SUFFIX.len()] != SUFFIX {
|
||||
i += 1;
|
||||
continue;
|
||||
}
|
||||
let mut s = i;
|
||||
while s > 0 && is_alias_byte(bytes[s - 1]) {
|
||||
s -= 1;
|
||||
}
|
||||
// Needs a non-empty token behind an opening quote.
|
||||
if s < i && s > 0 && bytes[s - 1] == b'"' {
|
||||
let end = i + SUFFIX.len() - 1; // keep `[1m]`, drop the quote
|
||||
if let Ok(tok) = std::str::from_utf8(&bytes[s..end]) {
|
||||
out.insert(tok.to_string());
|
||||
}
|
||||
}
|
||||
i += SUFFIX.len();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
/// Bytes allowed inside a model alias/id token.
|
||||
fn is_alias_byte(c: u8) -> bool {
|
||||
c.is_ascii_lowercase() || c.is_ascii_digit() || c == b'-'
|
||||
}
|
||||
|
||||
/// 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.
|
||||
@@ -597,8 +853,7 @@ fn parse_str_array(bytes: &[u8], start: usize) -> Option<(Vec<String>, usize)> {
|
||||
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'-') {
|
||||
if !is_alias_byte(bytes[i]) {
|
||||
return None;
|
||||
}
|
||||
i += 1;
|
||||
@@ -629,6 +884,27 @@ mod tests {
|
||||
assert_eq!(got, ["sonnet", "opus", "haiku", "fable"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collects_quoted_1m_variants() {
|
||||
let bytes = br#"x"sonnet[1m]"y"claude-opus-4-8[1m]"z"[1m]"q"#;
|
||||
let got = long_context_tokens(bytes);
|
||||
assert!(got.contains("sonnet[1m]"));
|
||||
assert!(got.contains("claude-opus-4-8[1m]"));
|
||||
// The bare `"[1m]"` label string carries no model name, so it is dropped.
|
||||
assert_eq!(got.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn appends_1m_choices_for_aliases_that_have_them() {
|
||||
let bytes = br#"["sonnet","opus","haiku"] "sonnet[1m]" "opus[1m]""#;
|
||||
let aliases = longest_alias_array(bytes).unwrap();
|
||||
let got = model_choices_from(bytes, &aliases);
|
||||
let args: Vec<&str> = got.iter().map(|c| c.1.as_str()).collect();
|
||||
// default (no flag), the plain aliases, then only the real 1M variants.
|
||||
assert_eq!(args, ["", "sonnet", "opus", "haiku", "sonnet[1m]", "opus[1m]"]);
|
||||
assert_eq!(got[4].0, "sonnet[1m] (1M context)");
|
||||
}
|
||||
|
||||
/// Build a `rows` fixture (visible text per screen row) from string slices.
|
||||
fn rows(v: &[&str]) -> Vec<String> {
|
||||
v.iter().map(|s| s.to_string()).collect()
|
||||
@@ -737,6 +1013,157 @@ mod tests {
|
||||
assert_eq!(compact_view_range(&screen, last, 20), (1, 10));
|
||||
}
|
||||
|
||||
/// The AskUserQuestion prompt exactly as Claude Code 2.1.229 draws it
|
||||
/// (captured from a real child through a fake upstream — see
|
||||
/// `dev/fake_upstream.py`). Note it replaces the input box: no `❯` box, and
|
||||
/// the *last* rule is a separator near the bottom, not the top border.
|
||||
fn ask_prompt_screen() -> Vec<String> {
|
||||
rows(&[
|
||||
"", // 0
|
||||
"❯ go", // 1
|
||||
"", // 2
|
||||
"● Let me check how you want this framed.", // 3: context row
|
||||
RULE, // 4: top border
|
||||
" ☐ Framing", // 5: header chip
|
||||
"", // 6
|
||||
"The compact pane currently crops the top of this", // 7: question
|
||||
"prompt. Which framing should the pane use?", // 8
|
||||
"", // 9
|
||||
"❯ 1. Measure the box", // 10: selected
|
||||
" Frame from the question box's own top border.", // 11
|
||||
" 2. Fixed 75% height", // 12
|
||||
" Always give the pane three quarters.", // 13
|
||||
" 3. Estimate from JSON", // 14
|
||||
" Keep guessing the row count.", // 15
|
||||
" 4. Type something.", // 16
|
||||
RULE, // 17: separator
|
||||
" 5. Chat about this", // 18
|
||||
"", // 19
|
||||
"Enter to select · ↑/↓ to navigate · Esc to cancel", // 20: hint
|
||||
"", "",
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_ask_prompt_from_its_own_top_border() {
|
||||
// Regression: the old code bottom-anchored on the last non-blank row,
|
||||
// so a pane shorter than the prompt cropped the *question text* — the
|
||||
// part that says what is being asked. The frame now starts one context
|
||||
// row above the prompt's top border and runs to the hint row.
|
||||
assert_eq!(interactive_frame(&ask_prompt_screen()), Some((3, 20)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overflowing_ask_prompt_keeps_the_selected_option() {
|
||||
let screen = ask_prompt_screen();
|
||||
// Tall enough: the whole prompt, top-anchored on the context row.
|
||||
assert_eq!(interactive_view_range(&screen, 20, 20), (3, 20));
|
||||
// Too short: still starts at the top (question first), cropping the
|
||||
// tail — the selected option (row 10) is inside the window.
|
||||
assert_eq!(interactive_view_range(&screen, 20, 9), (3, 11));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ask_prompt_window_slides_to_a_far_down_selection() {
|
||||
// Same prompt with the highlight on the last option: a strictly
|
||||
// top-anchored window would leave the user steering a selection they
|
||||
// cannot see, so the window slides down just far enough to show it.
|
||||
let mut screen = ask_prompt_screen();
|
||||
screen[10] = " 1. Measure the box".into();
|
||||
screen[16] = "❯ 4. Type something.".into();
|
||||
let (start, end) = interactive_view_range(&screen, 20, 8);
|
||||
assert!((start..=end).contains(&16), "selected option must be visible");
|
||||
assert_eq!((start, end), (10, 17));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_prompt_on_screen_yields_none() {
|
||||
// Only the idle input box: `interactive_frame` still reports the box
|
||||
// (both callers only use it while the tap says a prompt is up), but a
|
||||
// blank screen has nothing to frame at all.
|
||||
assert_eq!(interactive_frame(&rows(&["", "", ""])), None);
|
||||
}
|
||||
|
||||
/// The task panel Claude Code parks above the input box while a task list
|
||||
/// is alive (standalone form, turn finished).
|
||||
fn task_panel_screen() -> Vec<String> {
|
||||
rows(&[
|
||||
"● Done.", // 0
|
||||
"", // 1
|
||||
"✻ Crunched for 41s", // 2: context row
|
||||
"", // 3: panel marginTop
|
||||
" 5 tasks (1 done, 1 in progress, 3 open)", // 4: panel header
|
||||
" ✔ Capture ground truth screens", // 5
|
||||
" ◼ Fix interactive pane sizing", // 6
|
||||
" measuring the rendered box…", // 7: activity row
|
||||
" ◻ Expand pane while a task list is active", // 8
|
||||
" … +2 pending", // 9: overflow tail
|
||||
"", // 10
|
||||
&format!("{RULE} minimal ──"), // 11: top rule
|
||||
"❯", // 12: input
|
||||
RULE, // 13: bottom rule
|
||||
"Session: ▓▓░ Context | Opus", // 14: statusLine
|
||||
"⏵⏵ bypass permissions", // 15: chrome (cropped)
|
||||
])
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn frames_task_panel_above_the_input_box() {
|
||||
// The pane grows over the whole panel (plus the context row above it)
|
||||
// so the run's task status is visible, instead of showing the single
|
||||
// context row that used to land on the panel's blank margin.
|
||||
assert_eq!(compact_frame(&task_panel_screen()), Some((2, 14)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_panel_frame_survives_the_in_flight_shape() {
|
||||
// While the turn runs the same rows hang under the spinner row with a
|
||||
// `⎿` gutter and carry no header.
|
||||
let screen = rows(&[
|
||||
"● Setting up the task list.", // 0
|
||||
"", // 1
|
||||
"· Swirling… (9s · ↓ 2.3k tokens)", // 2: context row
|
||||
" ⎿ ◻ Capture ground truth screens", // 3
|
||||
" ◻ Fix interactive pane sizing", // 4
|
||||
" … +59 pending", // 5
|
||||
"", // 6
|
||||
&format!("{RULE} minimal ──"), // 7: top rule
|
||||
"❯", // 8
|
||||
RULE, // 9
|
||||
"Session: ▓▓░ Context | Opus", // 10
|
||||
]);
|
||||
assert_eq!(compact_frame(&screen), Some((2, 10)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn task_panel_yields_to_the_input_box_when_the_pane_is_short() {
|
||||
// The panel is context about the run; the input box is what the user
|
||||
// drives. A pane too short for both must drop the panel, not the box.
|
||||
let screen = task_panel_screen();
|
||||
// Room for everything: panel included (rows 2..14).
|
||||
assert_eq!(compact_view_range(&screen, 15, 13), (2, 14));
|
||||
// Room for 5 rows: falls back to one context row above the box, so the
|
||||
// input line (row 12) and the statusLine (row 14) stay visible.
|
||||
assert_eq!(compact_view_range(&screen, 15, 5), (10, 14));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plain_tool_result_gutter_does_not_grow_the_pane() {
|
||||
// `⎿` is every tool result's gutter. Only a task glyph behind it counts
|
||||
// as the task panel — otherwise the pane would grow on every Read/Bash.
|
||||
let screen = rows(&[
|
||||
"● Reading the file.", // 0
|
||||
" ⎿ Read 20 lines", // 1
|
||||
"", // 2
|
||||
&format!("{RULE} minimal ──"), // 3: top rule
|
||||
"❯", // 4
|
||||
RULE, // 5
|
||||
"Session: ▓▓░ Context | Opus", // 6
|
||||
]);
|
||||
// One context row above the box, as before.
|
||||
assert_eq!(compact_frame(&screen), Some((2, 6)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn no_input_box_yields_none() {
|
||||
// Startup banner only — no rules, so the caller falls back.
|
||||
|
||||
68
src/ui.rs
68
src/ui.rs
@@ -445,6 +445,10 @@ fn bind_fresh_pane(eui: &mut EmbedUi, app: &SharedApp, t: EmbeddedTerm, model: &
|
||||
if !a.sessions.iter().any(|s| s.key == sid) {
|
||||
a.sessions.push(Session::new(sid.clone(), model.to_string()));
|
||||
}
|
||||
// Remember the exact `--model` argument: the transcript only records
|
||||
// the base model id, so this is the one place a `[1m]` (1M-context)
|
||||
// pick can survive into a later resume.
|
||||
a.set_spawn_model(&sid, model);
|
||||
}
|
||||
bind_new_pane(eui, app, t, Some(&sid));
|
||||
}
|
||||
@@ -496,9 +500,13 @@ fn show_embed_new(eui: &mut EmbedUi, app: &SharedApp, model: &str) {
|
||||
/// Spawn (or replace) the embedded pane resuming a past session by UUID.
|
||||
/// Any existing pane (live or dead) is killed and replaced — this is the
|
||||
/// only expensive path, and it only runs from an explicit ctrl-↓ / `c`.
|
||||
///
|
||||
/// The resume carries the session's own model forward (`App::resume_model`,
|
||||
/// read from its transcript) instead of falling back to the CLI default.
|
||||
fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
|
||||
kill_current_embed(eui, app);
|
||||
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id) {
|
||||
let model = app.lock().unwrap().resume_model(session_id);
|
||||
match EmbeddedTerm::spawn_resume(eui.port, 20, 80, session_id, &model) {
|
||||
Ok(t) => {
|
||||
{
|
||||
// Pre-fill the feed from the on-disk transcript so it isn't
|
||||
@@ -514,6 +522,15 @@ fn show_embed_resume(eui: &mut EmbedUi, app: &SharedApp, session_id: &str) {
|
||||
{
|
||||
a.sessions.push(s);
|
||||
}
|
||||
a.set_spawn_model(session_id, &model);
|
||||
a.status = if model.is_empty() {
|
||||
format!("resumed {}", &session_id[..8.min(session_id.len())])
|
||||
} else {
|
||||
format!(
|
||||
"resumed {} on {model}",
|
||||
&session_id[..8.min(session_id.len())]
|
||||
)
|
||||
};
|
||||
}
|
||||
bind_new_pane(eui, app, t, Some(session_id));
|
||||
}
|
||||
@@ -939,17 +956,20 @@ fn draw(
|
||||
// Whole screen minus the footer and the 1-row Min(1) the feed area
|
||||
// keeps (layout below still reserves it).
|
||||
PaneView::Full => total.saturating_sub(2),
|
||||
// Both cropped views size themselves from what the child actually
|
||||
// drew, smoothed by the same hysteresis so the PTY isn't resized
|
||||
// (→ Ink repaint → flicker) on every per-frame wobble.
|
||||
PaneView::Interactive => {
|
||||
let h = a
|
||||
// Until the prompt appears on screen (the tap grows the pane a
|
||||
// beat early) fall back to the tap's estimate from the tool
|
||||
// JSON, then to three quarters of the screen.
|
||||
let measured = eui.term.as_ref().and_then(EmbeddedTerm::interactive_rows).or(a
|
||||
.embed_grow_rows
|
||||
.map(|r| r.saturating_add(3)) // + borders + statusLine row
|
||||
.unwrap_or((total as u32 * 75 / 100) as u16);
|
||||
h.clamp(EMBED_MIN.min(cap), cap)
|
||||
.map(|r| r.saturating_add(3))); // + borders + hint row
|
||||
let inner = eui.compact_height(measured);
|
||||
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
|
||||
}
|
||||
PaneView::Compact => {
|
||||
// Hysteresis smooths the per-frame measurement so the PTY
|
||||
// isn't resized (→ Ink repaint → flicker) on every wobble
|
||||
// during subagent turns / `@`/`/` menu filtering.
|
||||
let measured = eui.term.as_ref().and_then(EmbeddedTerm::compact_rows);
|
||||
let inner = eui.compact_height(measured);
|
||||
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
|
||||
@@ -1391,21 +1411,25 @@ fn draw(
|
||||
// no chrome crop); the compact/interactive pane crops Claude Code
|
||||
// chrome, so the PTY gets pad rows to draw what we hide.
|
||||
//
|
||||
// Compact is the one case where the PTY must NOT track the visible
|
||||
// pane height: that height is itself derived from measuring what's
|
||||
// already on the child's screen (`compact_rows`), so tying the PTY
|
||||
// to it creates a feedback loop — an `@`/`/` menu (or a big paste)
|
||||
// that suddenly needs many more rows than the *current* PTY+pad
|
||||
// never gets measured, because Ink only ever draws as many rows as
|
||||
// the PTY currently reports, so the pane can get stuck small
|
||||
// forever. Give Compact a PTY roomy enough for the whole screen
|
||||
// (rendering still only shows the cropped window via
|
||||
// `compact_view_range`), so Ink always has enough space to draw a
|
||||
// full box + menu in one shot.
|
||||
let pty_rows = if pane_view == PaneView::Compact {
|
||||
f.area().height
|
||||
} else {
|
||||
// Only fullscreen ties the PTY to the visible pane. Every cropped
|
||||
// view derives its height by measuring what's already on the
|
||||
// child's screen, so tying the PTY to that height is a feedback
|
||||
// loop — Ink only ever draws as many rows as the PTY reports, so a
|
||||
// view that suddenly needs more rows than PTY+pad can never draw
|
||||
// them, can never be measured, and stays stuck small.
|
||||
//
|
||||
// Compact hits this with an `@`/`/` menu or a big paste.
|
||||
// Interactive hits it harder: Claude Code *lays out* its
|
||||
// AskUserQuestion / ExitPlanMode prompt against the reported rows
|
||||
// and switches to a truncated rendering when they are few, so a
|
||||
// pane-sized PTY made the child itself hide parts of the prompt —
|
||||
// no amount of framing on our side could bring them back. A
|
||||
// screen-tall PTY lets Ink draw the prompt in full; the render
|
||||
// window still shows only the framed region.
|
||||
let pty_rows = if pane_view == PaneView::Full {
|
||||
inner.height
|
||||
} else {
|
||||
f.area().height
|
||||
};
|
||||
et.resize(pty_rows, inner.width, pane_view != PaneView::Full);
|
||||
if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {
|
||||
|
||||
Reference in New Issue
Block a user