claude expand fixes and line numbers
This commit is contained in:
98
.claude/skills/tui-verify/SKILL.md
Normal file
98
.claude/skills/tui-verify/SKILL.md
Normal file
@@ -0,0 +1,98 @@
|
|||||||
|
---
|
||||||
|
name: tui-verify
|
||||||
|
description: Drive and visually verify this TUI (claude-cloak, or any terminal app / embedded claude pane) by running it inside a headless tmux session, sending real keystrokes, and capturing the rendered screen as text. Use whenever you need to confirm a UI/pane/rendering change actually works end-to-end — not just that tests pass — e.g. "verify the pane expands", "does @ show the menu", "check the layout at a small size". Claude Code's own TUI cannot run in a non-tty, so tmux is the way to give it a real terminal.
|
||||||
|
---
|
||||||
|
|
||||||
|
# Verifying a TUI with tmux
|
||||||
|
|
||||||
|
The app renders to a real terminal and cannot run in a plain pipe. tmux gives it
|
||||||
|
a genuine tty in the background, lets you type into it, and lets you read back
|
||||||
|
exactly what's on screen as plain text — so you can *see* a rendering change
|
||||||
|
instead of guessing from tests.
|
||||||
|
|
||||||
|
## The safety rule (read first)
|
||||||
|
|
||||||
|
**Never use `pkill`, `killall`, `kill -9 <pattern>`, or any broad
|
||||||
|
process-matching kill.** The user runs their own real claude-cloak instances
|
||||||
|
(including the one hosting your session) on the same machine. `pkill -f
|
||||||
|
claude-cloak` will kill all of them. Only ever tear down tmux sessions **you
|
||||||
|
created, by their exact name**:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux kill-session -t <your-session-name> # only your own named session
|
||||||
|
```
|
||||||
|
|
||||||
|
Pick a distinctive session name (e.g. `ccverify`) so you never touch anything
|
||||||
|
else. Before starting, `tmux ls` to see what's already there and avoid name
|
||||||
|
clashes — never kill a session you didn't create.
|
||||||
|
|
||||||
|
## Core loop
|
||||||
|
|
||||||
|
1. **Build** the thing you're testing (`cargo build --release`).
|
||||||
|
2. **Launch** it in a detached tmux session, sized to the scenario you care
|
||||||
|
about. Small sizes (e.g. `-x 100 -y 20`) are what expose layout/overflow
|
||||||
|
bugs — a big terminal hides them.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux new-session -d -s ccverify -x 100 -y 20 "./target/release/claude-cloak"
|
||||||
|
sleep 2 # give it time to draw the first frame
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Capture** the rendered screen as text:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux capture-pane -t ccverify -p
|
||||||
|
```
|
||||||
|
|
||||||
|
Add `-e` to include ANSI escapes (then `| cat -A`) when you need to inspect
|
||||||
|
colors/attributes — e.g. which menu row is highlighted:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux capture-pane -t ccverify -p -e | cat -A
|
||||||
|
```
|
||||||
|
|
||||||
|
4. **Send keystrokes**, then `sleep` briefly (the app redraws on its own ~30fps
|
||||||
|
tick — give it 1–2s) and capture again:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tmux send-keys -t ccverify "a" # a literal key
|
||||||
|
tmux send-keys -t ccverify "Down" "Enter" # named keys
|
||||||
|
tmux send-keys -t ccverify "C-u" # ctrl-u (clear line)
|
||||||
|
tmux send-keys -t ccverify "@src" # a literal string
|
||||||
|
```
|
||||||
|
|
||||||
|
5. **Tear down your session by name** when done (see safety rule).
|
||||||
|
|
||||||
|
## send-keys gotchas
|
||||||
|
|
||||||
|
- Each space-separated token is interpreted as a **key name** (`Down`, `Enter`,
|
||||||
|
`Space`, `C-f`, `Escape`). So `send-keys "Down Down Down"` sends three Down
|
||||||
|
keys — but if the app has meanwhile changed state (e.g. a menu collapsed to a
|
||||||
|
single match and closed), the same tokens can land as literal text. If you see
|
||||||
|
literal `Down Down Down` in an input box, that's this — resend as needed.
|
||||||
|
- To send a literal string that contains spaces or key-like words, send it as
|
||||||
|
one quoted argument and/or use `-l` (literal): `tmux send-keys -l -t s "Down"`.
|
||||||
|
- Control/modifier chords: `C-u`, `C-f`, `C-q`, `S-Tab` (shift-tab). This app
|
||||||
|
avoids Alt on purpose — don't test Alt chords.
|
||||||
|
- After any keystroke, **sleep before capturing.** Captures taken mid-repaint
|
||||||
|
show a transient frame (a half-drawn box), which can look like a bug that
|
||||||
|
isn't there. 1–1.5s is usually enough.
|
||||||
|
|
||||||
|
## Reading the claude-cloak screen
|
||||||
|
|
||||||
|
- The bottom footer line shows the bound proxy port and the active keybinding
|
||||||
|
hints — a quick sanity check that the app is alive and in the expected mode.
|
||||||
|
- The embedded pane is the lower bordered box titled `claude · <id>`. Its
|
||||||
|
compact framing (`src/term.rs`) shows one context row, the input box (bracketed
|
||||||
|
by `──── … ──` rules), and either the statusLine (idle) or an `@`/`/` menu.
|
||||||
|
- To spawn an embedded claude pane for pane tests: press `a` (model picker),
|
||||||
|
navigate with `Down`, `Enter` to spawn. It resumes-then-clears to a fresh
|
||||||
|
chat. `ctrl-f` toggles fullscreen (shows the child's raw screen verbatim —
|
||||||
|
useful to see what Ink actually drew vs. what we crop).
|
||||||
|
|
||||||
|
## Why this beats tests alone
|
||||||
|
|
||||||
|
Unit tests over `compact_frame` / `compact_view_range` pin the framing math, but
|
||||||
|
they can't catch a PTY-sizing feedback loop where Ink never draws the rows we'd
|
||||||
|
measure — that only shows up when a real `claude` child renders into a real PTY.
|
||||||
|
Run the tmux loop for any change to pane sizing, cropping, cursor, or layout.
|
||||||
26
CLAUDE.md
26
CLAUDE.md
@@ -185,12 +185,34 @@ UI thread redraws on its own tick (no channel; just the mutex).
|
|||||||
hint/token/effort chrome below it. When an `@`/`/` menu is open it has
|
hint/token/effort chrome below it. When an `@`/`/` menu is open it has
|
||||||
replaced that chrome with a list (`text_is_menu_item`, CC-2.1.x glyphs —
|
replaced that chrome with a list (`text_is_menu_item`, CC-2.1.x glyphs —
|
||||||
retune there if an update changes them), so the frame extends to the last
|
retune there if an update changes them), so the frame extends to the last
|
||||||
non-blank row instead. The framed region drives the pane height too:
|
non-blank row instead. The menu is detected by scanning the *whole* region
|
||||||
|
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:
|
||||||
`compact_rows` (called from `ui::draw`) measures box-height + tail so the
|
`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
|
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
|
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
|
PTY taller than the visible window so the child can still draw the rows we
|
||||||
crop. `PaneView::Interactive` (the tap-grown AskUserQuestion / ExitPlanMode
|
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
|
||||||
|
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
|
||||||
|
gets the room to draw them, so `compact_rows` can't measure the growth and
|
||||||
|
the pane stays stuck small. A screen-tall PTY lets Ink lay out the full
|
||||||
|
box+menu in one shot; `compact_view_range` still shows only the cropped
|
||||||
|
window. When that window is shorter than an open menu it **top-anchors on the
|
||||||
|
input box** (crop the menu's tail, never the line you're typing) — the idle
|
||||||
|
no-menu case still bottom-anchors on the statusLine. That per-frame measurement is smoothed by hysteresis
|
||||||
|
(`EmbedUi::compact_height` / `smooth_compact`, seeded at
|
||||||
|
`DEFAULT_COMPACT_INNER`): the pane grows instantly but shrinks only after the
|
||||||
|
smaller height has held for `SHRINK_DELAY` (400ms), and `compact_rows`
|
||||||
|
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
|
pane, whose selection box renders *above* the input) top-anchors from row 2
|
||||||
instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders
|
instead so the prompt stays visible. `PaneView::Full` (fullscreen) renders
|
||||||
the child's screen verbatim from row 0 with the PTY sized exactly to the
|
the child's screen verbatim from row 0 with the PTY sized exactly to the
|
||||||
|
|||||||
126
src/app.rs
126
src/app.rs
@@ -668,42 +668,56 @@ pub fn record_user_prompt(app: &SharedApp, key: &str, body: &Value) {
|
|||||||
.get("tools")
|
.get("tools")
|
||||||
.and_then(Value::as_array)
|
.and_then(Value::as_array)
|
||||||
.is_some_and(|t| !t.is_empty());
|
.is_some_and(|t| !t.is_empty());
|
||||||
let Some(last) = body
|
let Some(messages) = body.get("messages").and_then(Value::as_array) else {
|
||||||
.get("messages")
|
|
||||||
.and_then(Value::as_array)
|
|
||||||
.and_then(|m| m.last())
|
|
||||||
else {
|
|
||||||
return;
|
return;
|
||||||
};
|
};
|
||||||
if last.get("role").and_then(Value::as_str) != Some("user") {
|
// The turn's prompt is the *trailing run* of user-role messages, not just
|
||||||
|
// the last message. Claude Code often appends injected
|
||||||
|
// `<system-reminder>`s (and other machinery) as their own `user` messages
|
||||||
|
// *after* the real prompt; inspecting only `messages.last()` then sees a
|
||||||
|
// reminder-only message, yields empty prompt text, and drops the turn
|
||||||
|
// entirely — the "user messages don't show up reliably" bug (it fires only
|
||||||
|
// when CC happens to append such a trailing message). A tool-loop
|
||||||
|
// continuation ends in a single user message of `tool_result` blocks (no
|
||||||
|
// text), so it still contributes nothing and records no spurious entry.
|
||||||
|
let trailing_user: Vec<&Value> = messages
|
||||||
|
.iter()
|
||||||
|
.rev()
|
||||||
|
.take_while(|m| m.get("role").and_then(Value::as_str) == Some("user"))
|
||||||
|
.collect();
|
||||||
|
if trailing_user.is_empty() {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Gather reminders + prompt across every text block of the message.
|
// Gather reminders + prompt across every text block of every trailing user
|
||||||
|
// message. `take_while` walked newest→oldest, so reverse back to document
|
||||||
|
// order for a prompt that legitimately spans multiple messages.
|
||||||
let mut reminders: Vec<String> = Vec::new();
|
let mut reminders: Vec<String> = Vec::new();
|
||||||
let mut prompts: Vec<String> = Vec::new();
|
let mut prompts: Vec<String> = Vec::new();
|
||||||
match last.get("content") {
|
for m in trailing_user.into_iter().rev() {
|
||||||
Some(Value::String(s)) => {
|
match m.get("content") {
|
||||||
let (r, p) = extract_user_text(s);
|
Some(Value::String(s)) => {
|
||||||
reminders.extend(r);
|
let (r, p) = extract_user_text(s);
|
||||||
if !p.is_empty() {
|
reminders.extend(r);
|
||||||
prompts.push(p);
|
if !p.is_empty() {
|
||||||
}
|
prompts.push(p);
|
||||||
}
|
|
||||||
Some(Value::Array(blocks)) => {
|
|
||||||
for b in blocks {
|
|
||||||
if b.get("type").and_then(Value::as_str) != Some("text") {
|
|
||||||
continue;
|
|
||||||
}
|
}
|
||||||
if let Some(t) = b.get("text").and_then(Value::as_str) {
|
}
|
||||||
let (r, p) = extract_user_text(t);
|
Some(Value::Array(blocks)) => {
|
||||||
reminders.extend(r);
|
for b in blocks {
|
||||||
if !p.is_empty() {
|
if b.get("type").and_then(Value::as_str) != Some("text") {
|
||||||
prompts.push(p);
|
continue;
|
||||||
|
}
|
||||||
|
if let Some(t) = b.get("text").and_then(Value::as_str) {
|
||||||
|
let (r, p) = extract_user_text(t);
|
||||||
|
reminders.extend(r);
|
||||||
|
if !p.is_empty() {
|
||||||
|
prompts.push(p);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
_ => return,
|
|
||||||
}
|
}
|
||||||
let text = prompts.join("\n");
|
let text = prompts.join("\n");
|
||||||
let text = text.trim();
|
let text = text.trim();
|
||||||
@@ -1315,6 +1329,68 @@ mod tests {
|
|||||||
assert_eq!(rem[0].content, "be careful");
|
assert_eq!(rem[0].content, "be careful");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn prompt_survives_trailing_reminder_message() {
|
||||||
|
// Claude Code sometimes appends an injected <system-reminder> as its
|
||||||
|
// *own* trailing user message *after* the real prompt. Inspecting only
|
||||||
|
// messages.last() saw the reminder-only message, got empty text, and
|
||||||
|
// dropped the whole turn — the "user messages don't show up reliably"
|
||||||
|
// bug. The prompt (in the second-to-last message) must still land.
|
||||||
|
let app: SharedApp = Arc::new(Mutex::new(App::new()));
|
||||||
|
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None));
|
||||||
|
record_user_prompt(
|
||||||
|
&app,
|
||||||
|
"abc",
|
||||||
|
&json!({"tools": [{"name": "Bash"}], "messages": [
|
||||||
|
{"role": "assistant", "content": "prior reply"},
|
||||||
|
{"role": "user", "content": [{"type": "text", "text": "what files are here?"}]},
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "text", "text": "<system-reminder>todo list updated</system-reminder>"}
|
||||||
|
]}
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
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, "the prompt must survive a trailing reminder message");
|
||||||
|
assert_eq!(user[0].content, "what files are here?");
|
||||||
|
let rem: Vec<_> = a.sessions[0]
|
||||||
|
.entries
|
||||||
|
.iter()
|
||||||
|
.filter(|e| e.kind == Kind::Reminder)
|
||||||
|
.collect();
|
||||||
|
assert_eq!(rem.len(), 1);
|
||||||
|
assert_eq!(rem[0].content, "todo list updated");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn tool_result_continuation_records_no_prompt() {
|
||||||
|
// A tool-loop continuation ends in a user message of tool_result blocks
|
||||||
|
// (no text). It must not manufacture a spurious User entry even though
|
||||||
|
// the trailing-run scan now looks past the single last message.
|
||||||
|
let app: SharedApp = Arc::new(Mutex::new(App::new()));
|
||||||
|
drop(Tap::new(app.clone(), "abc".into(), "claude-x".into(), None));
|
||||||
|
record_user_prompt(
|
||||||
|
&app,
|
||||||
|
"abc",
|
||||||
|
&json!({"tools": [{"name": "Bash"}], "messages": [
|
||||||
|
{"role": "user", "content": "run ls"},
|
||||||
|
{"role": "assistant", "content": [
|
||||||
|
{"type": "tool_use", "id": "t1", "name": "Bash", "input": {}}
|
||||||
|
]},
|
||||||
|
{"role": "user", "content": [
|
||||||
|
{"type": "tool_result", "tool_use_id": "t1", "content": "a.txt"}
|
||||||
|
]}
|
||||||
|
]}),
|
||||||
|
);
|
||||||
|
let a = app.lock().unwrap();
|
||||||
|
let users = a.sessions[0].entries.iter().filter(|e| e.kind == Kind::User).count();
|
||||||
|
assert_eq!(users, 0, "tool_result continuation must not record a prompt");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn extract_user_text_splits_reminders_from_prompt() {
|
fn extract_user_text_splits_reminders_from_prompt() {
|
||||||
let (rem, prompt) = extract_user_text("<system-reminder>x</system-reminder>");
|
let (rem, prompt) = extract_user_text("<system-reminder>x</system-reminder>");
|
||||||
|
|||||||
@@ -5,8 +5,9 @@
|
|||||||
//! files from a chosen set of turns (branching / cherry-picking).
|
//! files from a chosen set of turns (branching / cherry-picking).
|
||||||
//!
|
//!
|
||||||
//! Claude Code stores one `.jsonl` file per session under
|
//! Claude Code stores one `.jsonl` file per session under
|
||||||
//! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every `/`
|
//! `~/.claude/projects/<encoded-cwd>/`. The path encoding replaces every
|
||||||
//! with `-` (the leading `/` becomes a leading `-`).
|
//! character that isn't an ASCII letter or digit with `-` (so `/`, `.`, `_`,
|
||||||
|
//! spaces … all become `-`) — see `encode_cwd`.
|
||||||
|
|
||||||
use crate::app::{
|
use crate::app::{
|
||||||
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
|
flatten_result_content, lock_app, strip_injected, Entry, Kind, Session, SharedApp,
|
||||||
@@ -533,14 +534,18 @@ fn push_user_text(entries: &mut Vec<Entry>, text: &str) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
/// Encode the current working directory the same way Claude Code does:
|
/// Encode the current working directory the same way Claude Code does:
|
||||||
/// each `/` → `-` (the leading `/` becomes a leading `-`).
|
/// every character that is not an ASCII letter or digit becomes `-` (so `/`,
|
||||||
|
/// `.`, `_`, spaces, … all collapse to `-`, and the leading `/` becomes a
|
||||||
|
/// leading `-`). Consecutive specials are *not* merged — `/.config` maps to
|
||||||
|
/// `--config`, matching Claude Code's `path.replace(/[^a-zA-Z0-9]/g, '-')`.
|
||||||
fn project_dir() -> Result<PathBuf, String> {
|
fn project_dir() -> Result<PathBuf, String> {
|
||||||
let home = std::env::var("HOME").map_err(|_| "HOME is not set".to_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 cwd = std::env::current_dir().map_err(|e| format!("cannot read cwd: {e}"))?;
|
||||||
let encoded = cwd
|
let encoded = encode_cwd(
|
||||||
.to_str()
|
cwd.to_str().ok_or_else(|| {
|
||||||
.ok_or_else(|| "cwd is not valid UTF-8; cannot map it to a Claude project dir".to_string())?
|
"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);
|
let dir = PathBuf::from(home).join(".claude").join("projects").join(encoded);
|
||||||
if !dir.is_dir() {
|
if !dir.is_dir() {
|
||||||
return Err("no past sessions recorded for this directory".to_string());
|
return Err("no past sessions recorded for this directory".to_string());
|
||||||
@@ -548,6 +553,15 @@ fn project_dir() -> Result<PathBuf, String> {
|
|||||||
Ok(dir)
|
Ok(dir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// The path → project-folder mapping, split out so it can be unit-tested
|
||||||
|
/// without touching `HOME`/cwd. Mirrors Claude Code's
|
||||||
|
/// `dir.replace(/[^a-zA-Z0-9]/g, '-')`.
|
||||||
|
fn encode_cwd(path: &str) -> String {
|
||||||
|
path.chars()
|
||||||
|
.map(|c| if c.is_ascii_alphanumeric() { c } else { '-' })
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
/// Read a human-readable label from the JSONL file. Prefers `ai-title`;
|
/// 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.
|
/// falls back to the first non-empty `last-prompt` text; then uuid prefix.
|
||||||
fn read_label(path: &std::path::Path, uuid: &str) -> String {
|
fn read_label(path: &std::path::Path, uuid: &str) -> String {
|
||||||
@@ -756,6 +770,18 @@ mod tests {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn encode_cwd_matches_claude_code() {
|
||||||
|
// Plain path: only slashes fold.
|
||||||
|
assert_eq!(encode_cwd("/home/jonas/projects/claude-cloak"), "-home-jonas-projects-claude-cloak");
|
||||||
|
// Dot folds to `-`; a leading `/.` yields a double dash (no merging).
|
||||||
|
assert_eq!(encode_cwd("/home/jonas/dotfiles/nvim/.config/nvim"), "-home-jonas-dotfiles-nvim--config-nvim");
|
||||||
|
assert_eq!(encode_cwd("/home/jonas/sources/llama.cpp"), "-home-jonas-sources-llama-cpp");
|
||||||
|
// Underscore folds too; case and digits are preserved.
|
||||||
|
assert_eq!(encode_cwd("/home/jonas/Downloads/ldd_wine"), "-home-jonas-Downloads-ldd-wine");
|
||||||
|
assert_eq!(encode_cwd("/a/b1_c2.d3"), "-a-b1-c2-d3");
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn label_prefers_ai_title() {
|
fn label_prefers_ai_title() {
|
||||||
let p = write_jsonl(&[
|
let p = write_jsonl(&[
|
||||||
|
|||||||
137
src/term.rs
137
src/term.rs
@@ -34,8 +34,9 @@ const PTY_PAD: u16 = 4;
|
|||||||
/// input box (top rule + one input line + bottom rule) and the statusLine.
|
/// input box (top rule + one input line + bottom rule) and the statusLine.
|
||||||
const MIN_COMPACT_INNER: u16 = 5;
|
const MIN_COMPACT_INNER: u16 = 5;
|
||||||
/// Fallback inner height when the input box hasn't been located yet (e.g. the
|
/// Fallback inner height when the input box hasn't been located yet (e.g. the
|
||||||
/// startup banner before the prompt is drawn).
|
/// startup banner before the prompt is drawn). The UI seeds its hysteresis
|
||||||
const DEFAULT_COMPACT_INNER: u16 = 7;
|
/// state with this until `compact_rows` first locates the box.
|
||||||
|
pub const DEFAULT_COMPACT_INNER: u16 = 7;
|
||||||
|
|
||||||
/// How the compact/fullscreen pane frames the child's screen. The compact pane
|
/// How the compact/fullscreen pane frames the child's screen. The compact pane
|
||||||
/// is prompt-only (the feed above shows the transcript): `Compact` dynamically
|
/// is prompt-only (the feed above shows the transcript): `Compact` dynamically
|
||||||
@@ -280,21 +281,24 @@ impl EmbeddedTerm {
|
|||||||
/// region (one context row above the box, the input box itself however
|
/// region (one context row above the box, the input box itself however
|
||||||
/// many lines it has grown to, and the statusLine — or a full `@`/`/`
|
/// many lines it has grown to, and the statusLine — or a full `@`/`/`
|
||||||
/// menu when one is open). The UI uses this to size the pane so the
|
/// menu when one is open). The UI uses this to size the pane so the
|
||||||
/// prompt auto-expands as you type and never scrolls out of view. Falls
|
/// prompt auto-expands as you type and never scrolls out of view.
|
||||||
/// back to a small default before the box has been drawn. Independent of
|
/// Independent of the terminal's row count, so resizing the pane to this
|
||||||
/// the terminal's row count, so resizing the pane to this value can't feed
|
/// value can't feed back into the measurement.
|
||||||
/// back into the measurement.
|
///
|
||||||
pub fn compact_rows(&self) -> u16 {
|
/// Returns `None` when no input box can be located this frame — the
|
||||||
|
/// startup banner, but also a *transient* mid-repaint (a subagent turn or
|
||||||
|
/// a filtering `@`/`/` menu redraws heavily, so a single frame can catch
|
||||||
|
/// the box mid-rewrite with a border missing). The caller keeps its last
|
||||||
|
/// 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 term = self.term.lock().unwrap();
|
let term = self.term.lock().unwrap();
|
||||||
let screen = term.screen();
|
let screen = term.screen();
|
||||||
let first = screen.phys_row(0);
|
let first = screen.phys_row(0);
|
||||||
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
|
let lines = screen.lines_in_phys_range(first..first + screen.physical_rows);
|
||||||
let rows: Vec<String> = lines.iter().map(row_text).collect();
|
let rows: Vec<String> = lines.iter().map(row_text).collect();
|
||||||
let n = match compact_frame(&rows) {
|
let (top, bottom) = compact_frame(&rows)?;
|
||||||
Some((top, bottom)) => (bottom - top + 1) as u16,
|
Some(((bottom - top + 1) as u16).max(MIN_COMPACT_INNER))
|
||||||
None => DEFAULT_COMPACT_INNER,
|
|
||||||
};
|
|
||||||
n.max(MIN_COMPACT_INNER)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Paint a window of the child's screen into `area`. Returns the cursor
|
/// Paint a window of the child's screen into `area`. Returns the cursor
|
||||||
@@ -331,16 +335,7 @@ impl EmbeddedTerm {
|
|||||||
let start = (end + 1).saturating_sub(h).max(2).min(end);
|
let start = (end + 1).saturating_sub(h).max(2).min(end);
|
||||||
(start, end)
|
(start, end)
|
||||||
}
|
}
|
||||||
PaneView::Compact => {
|
PaneView::Compact => compact_view_range(&rows, last, h),
|
||||||
let (top, bottom) = compact_frame(&rows)
|
|
||||||
// No box found yet: bottom-anchor the raw content.
|
|
||||||
.unwrap_or((last.saturating_sub(h.saturating_sub(1)), last));
|
|
||||||
// Bottom-anchor `bottom` (the statusLine / last menu row);
|
|
||||||
// if the pane can't fit the whole region, drop context rows
|
|
||||||
// from the top rather than the prompt.
|
|
||||||
let start = (bottom + 1).saturating_sub(h).max(top).min(bottom);
|
|
||||||
(start, bottom)
|
|
||||||
}
|
|
||||||
};
|
};
|
||||||
for (y, line) in lines[start..=end].iter().enumerate() {
|
for (y, line) in lines[start..=end].iter().enumerate() {
|
||||||
if y as u16 >= area.height {
|
if y as u16 >= area.height {
|
||||||
@@ -430,6 +425,13 @@ fn text_is_menu_item(t: &str) -> bool {
|
|||||||
/// many lines tall (a long or pasted prompt), which is exactly the auto-expand
|
/// many lines tall (a long or pasted prompt), which is exactly the auto-expand
|
||||||
/// we want.
|
/// we want.
|
||||||
fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
|
fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
|
||||||
|
compact_frame_ex(rows).map(|(top, bottom, _)| (top, 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)> {
|
||||||
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
|
let last = rows.iter().rposition(|t| !t.trim().is_empty())?;
|
||||||
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
|
let rules: Vec<usize> = (0..=last).filter(|&i| text_is_rule(&rows[i])).collect();
|
||||||
if rules.len() < 2 {
|
if rules.len() < 2 {
|
||||||
@@ -438,12 +440,40 @@ fn compact_frame(rows: &[String]) -> Option<(usize, usize)> {
|
|||||||
let bot_div = rules[rules.len() - 1];
|
let bot_div = rules[rules.len() - 1];
|
||||||
let top_div = rules[rules.len() - 2];
|
let top_div = rules[rules.len() - 2];
|
||||||
let view_top = top_div.saturating_sub(1);
|
let view_top = top_div.saturating_sub(1);
|
||||||
let view_bottom = if last > bot_div && text_is_menu_item(&rows[bot_div + 1]) {
|
// An open `@`/`/` menu replaces the chrome below the bottom rule with a
|
||||||
last
|
// list. Scan the *whole* region under the rule for a menu row, not just the
|
||||||
} else {
|
// one immediately below it: the list can start after a blank separator or a
|
||||||
(bot_div + 1).min(last)
|
// header row, and only the highlighted item carries a recognisable glyph
|
||||||
};
|
// (unselected file rows are plain indented names), so checking a single row
|
||||||
Some((view_top, view_bottom))
|
// missed the menu whenever that row happened not to be the selected one.
|
||||||
|
// The persistent chrome rows (statusLine / hint / tokens / effort) never
|
||||||
|
// 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))
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 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.
|
||||||
|
/// - 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),
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Drop for EmbeddedTerm {
|
impl Drop for EmbeddedTerm {
|
||||||
@@ -656,6 +686,57 @@ mod tests {
|
|||||||
assert_eq!(compact_frame(&screen), Some((1, 7)));
|
assert_eq!(compact_frame(&screen), Some((1, 7)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn shows_menu_when_first_row_isnt_the_selected_item() {
|
||||||
|
// Real `@` menus render only the highlighted item with a glyph; the
|
||||||
|
// rows above it are plain indented filenames. A blank separator can
|
||||||
|
// also sit between the bottom rule and the list. The frame must still
|
||||||
|
// extend to the whole menu (regression: only rows[bot_div+1] was
|
||||||
|
// checked, so the pane collapsed unless the first item was selected).
|
||||||
|
let screen = rows(&[
|
||||||
|
"", "",
|
||||||
|
&format!("{RULE} minimal ──"), // 2: top rule
|
||||||
|
"❯ @s", // 3: input
|
||||||
|
RULE, // 4: bottom rule
|
||||||
|
"", // 5: blank separator
|
||||||
|
" src/app.rs", // 6: unselected item (no glyph)
|
||||||
|
"❯ src/ui.rs", // 7: selected item
|
||||||
|
" src/term.rs", // 8: last non-blank
|
||||||
|
"", "",
|
||||||
|
]);
|
||||||
|
assert_eq!(compact_frame(&screen), Some((1, 8)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn overflowing_menu_keeps_input_box_visible() {
|
||||||
|
// Regression: when a long `@`/`/` match list doesn't fit in the pane's
|
||||||
|
// (capped) height, the pane must keep the input box on screen and
|
||||||
|
// truncate the menu's tail — not the reverse. Bottom-anchoring here
|
||||||
|
// (as the idle statusLine case does) hid the line you're typing behind
|
||||||
|
// a wall of filenames, which is what this bug report was about.
|
||||||
|
let screen = rows(&[
|
||||||
|
"", "",
|
||||||
|
&format!("{RULE} minimal ──"), // 2: top rule
|
||||||
|
"❯ @s", // 3: input — must stay visible
|
||||||
|
RULE, // 4: bottom rule
|
||||||
|
"+ src/", // 5
|
||||||
|
"+ src/app.rs", // 6
|
||||||
|
"+ src/main.rs", // 7
|
||||||
|
"+ src/sse.rs", // 8
|
||||||
|
"+ src/term.rs", // 9
|
||||||
|
"+ src/ui.rs", // 10
|
||||||
|
]);
|
||||||
|
let last = 10;
|
||||||
|
// Pane only has room for 5 rows (top-anchored: context row through the
|
||||||
|
// bottom rule + first item), so the last two menu items get cropped —
|
||||||
|
// but the input box (rows 1..=4) must still be in view.
|
||||||
|
let (start, end) = compact_view_range(&screen, last, 5);
|
||||||
|
assert_eq!((start, end), (1, 5));
|
||||||
|
|
||||||
|
// A pane tall enough for everything still shows the whole menu.
|
||||||
|
assert_eq!(compact_view_range(&screen, last, 20), (1, 10));
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn no_input_box_yields_none() {
|
fn no_input_box_yields_none() {
|
||||||
// Startup banner only — no rules, so the caller falls back.
|
// Startup banner only — no rules, so the caller falls back.
|
||||||
|
|||||||
179
src/ui.rs
179
src/ui.rs
@@ -46,8 +46,23 @@ struct EmbedUi {
|
|||||||
/// loop diffs it and emits `SetCursorStyle` only on change, so the outer
|
/// loop diffs it and emits `SetCursorStyle` only on change, so the outer
|
||||||
/// terminal mirrors the child (bar in insert mode, block in vim normal).
|
/// terminal mirrors the child (bar in insert mode, block in vim normal).
|
||||||
cursor_shape: Option<crate::term::CursorShape>,
|
cursor_shape: Option<crate::term::CursorShape>,
|
||||||
|
/// Currently-applied compact-pane inner height, smoothed with hysteresis
|
||||||
|
/// (`compact_height`): grows immediately, shrinks only after the smaller
|
||||||
|
/// value has held for `SHRINK_DELAY`. Remeasuring Claude Code's live screen
|
||||||
|
/// every frame otherwise oscillates during subagent turns / `@`/`/` menu
|
||||||
|
/// filtering, and each change resizes the PTY → Ink repaints → flicker.
|
||||||
|
compact_inner: u16,
|
||||||
|
/// A pending shrink target and when it was first seen; adopted once it has
|
||||||
|
/// been stable for `SHRINK_DELAY`. Reset whenever the pane grows or the
|
||||||
|
/// measured height matches what's applied.
|
||||||
|
shrink_pending: Option<(u16, Instant)>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How long a smaller compact-pane height must persist before the pane
|
||||||
|
/// actually shrinks. Long enough to ride out the repaint churn of a subagent
|
||||||
|
/// turn or a filtering `@`/`/` menu, short enough to feel responsive.
|
||||||
|
const SHRINK_DELAY: Duration = Duration::from_millis(400);
|
||||||
|
|
||||||
/// Mouse text selection over the whole screen (mimics Claude Code: drag to
|
/// Mouse text selection over the whole screen (mimics Claude Code: drag to
|
||||||
/// select with a reversed-video highlight, the underlying screen text is
|
/// select with a reversed-video highlight, the underlying screen text is
|
||||||
/// copied to the system clipboard on release via OSC 52).
|
/// copied to the system clipboard on release via OSC 52).
|
||||||
@@ -66,6 +81,47 @@ impl EmbedUi {
|
|||||||
&& self.claude_focused
|
&& self.claude_focused
|
||||||
&& self.term.as_ref().is_some_and(|t| !t.exited())
|
&& self.term.as_ref().is_some_and(|t| !t.exited())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Smoothed compact-pane inner height. `measured` is this frame's raw
|
||||||
|
/// reading (`None` when the input box couldn't be located — a transient
|
||||||
|
/// mid-repaint, so keep what we have). Grows immediately so the prompt
|
||||||
|
/// never scrolls out of view, but shrinks only after the smaller height
|
||||||
|
/// has held for `SHRINK_DELAY`. Without this the height oscillates every
|
||||||
|
/// frame while a subagent streams or an `@`/`/` menu filters, and each
|
||||||
|
/// change resizes the PTY, making Claude Code repaint and flicker.
|
||||||
|
fn compact_height(&mut self, measured: Option<u16>) -> u16 {
|
||||||
|
smooth_compact(&mut self.compact_inner, &mut self.shrink_pending, measured)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hysteresis for the compact pane height (see `EmbedUi::compact_height`).
|
||||||
|
/// Grows `applied` immediately; commits a smaller `measured` only once that
|
||||||
|
/// target has held for `SHRINK_DELAY`; keeps `applied` unchanged on `None`.
|
||||||
|
/// Pure over its `&mut` state so it can be unit-tested without an `EmbedUi`.
|
||||||
|
fn smooth_compact(
|
||||||
|
applied: &mut u16,
|
||||||
|
pending: &mut Option<(u16, Instant)>,
|
||||||
|
measured: Option<u16>,
|
||||||
|
) -> u16 {
|
||||||
|
if let Some(m) = measured {
|
||||||
|
if m >= *applied {
|
||||||
|
*applied = m;
|
||||||
|
*pending = None;
|
||||||
|
} else {
|
||||||
|
// Want to shrink to `m`; only commit once it has been stable.
|
||||||
|
match *pending {
|
||||||
|
Some((target, since)) if target == m => {
|
||||||
|
if since.elapsed() >= SHRINK_DELAY {
|
||||||
|
*applied = m;
|
||||||
|
*pending = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// First sight of this smaller target (or the target moved).
|
||||||
|
_ => *pending = Some((m, Instant::now())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
*applied
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Per-entry feed render cache. Entries are append-only and an entry's
|
/// Per-entry feed render cache. Entries are append-only and an entry's
|
||||||
@@ -306,6 +362,8 @@ pub fn run(app: SharedApp, port: u16) -> anyhow::Result<()> {
|
|||||||
past_embeds: HashSet::new(),
|
past_embeds: HashSet::new(),
|
||||||
force_resume: None,
|
force_resume: None,
|
||||||
cursor_shape: None,
|
cursor_shape: None,
|
||||||
|
compact_inner: crate::term::DEFAULT_COMPACT_INNER,
|
||||||
|
shrink_pending: None,
|
||||||
};
|
};
|
||||||
let res = event_loop(&mut terminal, app, &mut eui);
|
let res = event_loop(&mut terminal, app, &mut eui);
|
||||||
let _ = execute!(
|
let _ = execute!(
|
||||||
@@ -889,7 +947,11 @@ fn draw(
|
|||||||
h.clamp(EMBED_MIN.min(cap), cap)
|
h.clamp(EMBED_MIN.min(cap), cap)
|
||||||
}
|
}
|
||||||
PaneView::Compact => {
|
PaneView::Compact => {
|
||||||
let inner = eui.term.as_ref().map_or(EMBED_MIN, EmbeddedTerm::compact_rows);
|
// 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)
|
inner.saturating_add(2).clamp(EMBED_MIN.min(cap), cap)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1328,7 +1390,24 @@ fn draw(
|
|||||||
// Fullscreen shows the child's screen verbatim (PTY sized exactly,
|
// Fullscreen shows the child's screen verbatim (PTY sized exactly,
|
||||||
// no chrome crop); the compact/interactive pane crops Claude Code
|
// no chrome crop); the compact/interactive pane crops Claude Code
|
||||||
// chrome, so the PTY gets pad rows to draw what we hide.
|
// chrome, so the PTY gets pad rows to draw what we hide.
|
||||||
et.resize(inner.height, inner.width, pane_view != PaneView::Full);
|
//
|
||||||
|
// 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 {
|
||||||
|
inner.height
|
||||||
|
};
|
||||||
|
et.resize(pty_rows, inner.width, pane_view != PaneView::Full);
|
||||||
if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {
|
if let Some(pos) = et.render(inner, f.buffer_mut(), pane_view) {
|
||||||
f.set_cursor_position(pos);
|
f.set_cursor_position(pos);
|
||||||
want_cursor = Some(et.cursor_shape());
|
want_cursor = Some(et.cursor_shape());
|
||||||
@@ -1785,7 +1864,8 @@ fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec<Line<'a>>, widt
|
|||||||
"⚙ Write ".yellow().bold(),
|
"⚙ Write ".yellow().bold(),
|
||||||
path.to_string().bold(),
|
path.to_string().bold(),
|
||||||
]));
|
]));
|
||||||
push_numbered(out, content, None, width);
|
let gutter = content.lines().count().max(1).to_string().len();
|
||||||
|
push_numbered(out, content, None, width, 1, gutter);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
"edit" => {
|
"edit" => {
|
||||||
@@ -1797,25 +1877,61 @@ fn render_file_tool<'a>(name: &str, input: &Value, out: &mut Vec<Line<'a>>, widt
|
|||||||
if input.get("replace_all").and_then(Value::as_bool) == Some(true) {
|
if input.get("replace_all").and_then(Value::as_bool) == Some(true) {
|
||||||
head.push(" (replace_all)".dark_gray());
|
head.push(" (replace_all)".dark_gray());
|
||||||
}
|
}
|
||||||
|
let start = edit_line_number(path, old, new);
|
||||||
|
if let Some(n) = start {
|
||||||
|
head.push(format!(" (line {n})").dark_gray());
|
||||||
|
}
|
||||||
out.push(Line::from(head));
|
out.push(Line::from(head));
|
||||||
push_numbered(out, old, Some(DIFF_DEL), width);
|
let start = start.unwrap_or(1);
|
||||||
push_numbered(out, new, Some(DIFF_ADD), width);
|
// Shared gutter width so the old/new blocks line up with each
|
||||||
|
// other even when one side has more lines than the other.
|
||||||
|
let last_line = old.lines().count().max(new.lines().count()).max(1);
|
||||||
|
let gutter = (start + last_line - 1).to_string().len();
|
||||||
|
push_numbered(out, old, Some(DIFF_DEL), width, start, gutter);
|
||||||
|
push_numbered(out, new, Some(DIFF_ADD), width, start, gutter);
|
||||||
true
|
true
|
||||||
}
|
}
|
||||||
_ => false,
|
_ => false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Pushes `text` line by line with a line-number gutter. With a `bg`, the
|
/// Best-effort lookup of the 1-based line number where an edit lands in the
|
||||||
/// whole row (gutter included) is white-on-bg and padded to `width` so the
|
/// file on disk. The tool's own `old_string`/`new_string` are unaware of line
|
||||||
/// background forms a solid block; without one, the gutter is dark gray.
|
/// numbers (they're a plain substring replacement), and the `push_numbered`
|
||||||
fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, width: u16) {
|
/// gutter used to always start both diff sides at 1, which didn't match the
|
||||||
let gutter = text.lines().count().max(1).to_string().len();
|
/// real file at all. Tries `new_string` first: Claude Code executes tools
|
||||||
|
/// client-side, not through the proxy, so by render time the edit has
|
||||||
|
/// virtually always already landed on disk. Falls back to `old_string`
|
||||||
|
/// (pre-edit state) for the rare case the edit hasn't run yet. `None`
|
||||||
|
/// (numbering falls back to 1) when the file can't be read or neither string
|
||||||
|
/// is found, e.g. a later edit already changed the surrounding text.
|
||||||
|
fn edit_line_number(path: &str, old: &str, new: &str) -> Option<usize> {
|
||||||
|
let content = std::fs::read_to_string(path).ok()?;
|
||||||
|
let offset = content.find(new).or_else(|| content.find(old))?;
|
||||||
|
Some(content[..offset].matches('\n').count() + 1)
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Pushes `text` line by line with a line-number gutter, numbered from
|
||||||
|
/// `start` (the line it actually occupies in the file, when known — see
|
||||||
|
/// [`edit_line_number`] — otherwise 1) and right-aligned to a fixed `gutter`
|
||||||
|
/// width so an old/new diff pair lines up even when one side has more lines
|
||||||
|
/// than the other. With a `bg`, the whole row (gutter included) is
|
||||||
|
/// white-on-bg and padded to `width` so the background forms a solid block;
|
||||||
|
/// without one, the gutter is dark gray.
|
||||||
|
fn push_numbered<'a>(
|
||||||
|
out: &mut Vec<Line<'a>>,
|
||||||
|
text: &str,
|
||||||
|
bg: Option<Color>,
|
||||||
|
width: u16,
|
||||||
|
start: usize,
|
||||||
|
gutter: usize,
|
||||||
|
) {
|
||||||
for (i, l) in text.lines().enumerate() {
|
for (i, l) in text.lines().enumerate() {
|
||||||
let l = sanitize(l);
|
let l = sanitize(l);
|
||||||
|
let n = start + i;
|
||||||
match bg {
|
match bg {
|
||||||
Some(bg) => {
|
Some(bg) => {
|
||||||
let mut row = format!("{:>gutter$} │ {l}", i + 1);
|
let mut row = format!("{n:>gutter$} │ {l}");
|
||||||
let pad = (width as usize).saturating_sub(row.chars().count());
|
let pad = (width as usize).saturating_sub(row.chars().count());
|
||||||
row.extend(std::iter::repeat_n(' ', pad));
|
row.extend(std::iter::repeat_n(' ', pad));
|
||||||
out.push(Line::from(Span::styled(
|
out.push(Line::from(Span::styled(
|
||||||
@@ -1824,7 +1940,7 @@ fn push_numbered<'a>(out: &mut Vec<Line<'a>>, text: &str, bg: Option<Color>, wid
|
|||||||
)));
|
)));
|
||||||
}
|
}
|
||||||
None => out.push(Line::from(vec![
|
None => out.push(Line::from(vec![
|
||||||
format!("{:>gutter$} │ ", i + 1).dark_gray(),
|
format!("{n:>gutter$} │ ").dark_gray(),
|
||||||
Span::raw(l),
|
Span::raw(l),
|
||||||
])),
|
])),
|
||||||
}
|
}
|
||||||
@@ -1866,9 +1982,46 @@ fn sanitize_md(s: &str) -> String {
|
|||||||
|
|
||||||
#[cfg(test)]
|
#[cfg(test)]
|
||||||
mod tests {
|
mod tests {
|
||||||
use super::{base64, color_on, entry_lines, sanitize_md, truncate_str, wrap_words};
|
use super::{
|
||||||
|
base64, color_on, entry_lines, sanitize_md, smooth_compact, truncate_str, wrap_words,
|
||||||
|
SHRINK_DELAY,
|
||||||
|
};
|
||||||
use crate::app::{Entry, Kind};
|
use crate::app::{Entry, Kind};
|
||||||
use ratatui::style::Color;
|
use ratatui::style::Color;
|
||||||
|
use std::time::Instant;
|
||||||
|
|
||||||
|
/// The compact pane grows on the frame the prompt gets taller, but a
|
||||||
|
/// smaller reading is held back until it has been stable for SHRINK_DELAY —
|
||||||
|
/// so the per-frame wobble during subagent turns / menu filtering doesn't
|
||||||
|
/// resize the PTY (which would make Claude Code repaint and flicker). A
|
||||||
|
/// transient `None` reading keeps the last height.
|
||||||
|
#[test]
|
||||||
|
fn compact_height_grows_fast_shrinks_slow() {
|
||||||
|
let mut applied = 7u16;
|
||||||
|
let mut pending = None;
|
||||||
|
|
||||||
|
// Grow: adopted immediately, no pending shrink.
|
||||||
|
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12);
|
||||||
|
assert!(pending.is_none());
|
||||||
|
|
||||||
|
// Transient miss: height unchanged.
|
||||||
|
assert_eq!(smooth_compact(&mut applied, &mut pending, None), 12);
|
||||||
|
|
||||||
|
// A smaller reading is not applied yet — it only arms a pending shrink.
|
||||||
|
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12);
|
||||||
|
assert!(pending.is_some());
|
||||||
|
|
||||||
|
// A bounce back up while shrink is pending cancels it.
|
||||||
|
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(12)), 12);
|
||||||
|
assert!(pending.is_none());
|
||||||
|
|
||||||
|
// Re-arm the shrink, then backdate its timestamp past the delay so the
|
||||||
|
// next matching reading commits it (no real sleep needed).
|
||||||
|
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 12);
|
||||||
|
pending = Some((6, Instant::now() - SHRINK_DELAY - std::time::Duration::from_millis(1)));
|
||||||
|
assert_eq!(smooth_compact(&mut applied, &mut pending, Some(6)), 6);
|
||||||
|
assert!(pending.is_none());
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn sanitize_md_expands_tabs_keeps_newlines() {
|
fn sanitize_md_expands_tabs_keeps_newlines() {
|
||||||
|
|||||||
Reference in New Issue
Block a user