From 18bb92c1aeaabd7a8ecdf399630d7e2cefdca45b Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Thu, 1 Oct 2026 10:45:02 +0800 Subject: [PATCH] fix(terminal): lay the composer over Pi's own input MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The box docked under Pi's pane, so Pi's own ruled editor and its status rows stayed on screen above it: two inputs. Pi now gets the covering box Claude, Codex and Gemini have. Its editor is the lowest pair of full-width rules — the upper one carries the spinner while a turn runs — with the directory and usage rows under it, and the box covers from the upper rule down. Pi's selectors take the editor's place between the same rules but open on a blank row (or, for /settings, on a `>` search line) and run on for several, so the box steps aside for them and comes back once they close. --- src/terminal/composer.rs | 139 ++++++++++++++++++++++++++++++++++++++- 1 file changed, 138 insertions(+), 1 deletion(-) diff --git a/src/terminal/composer.rs b/src/terminal/composer.rs index 49b8f712..9126a2c7 100644 --- a/src/terminal/composer.rs +++ b/src/terminal/composer.rs @@ -353,7 +353,10 @@ fn reads_like_gemini(agent: CLIAgent) -> bool { /// Whether the box can be laid over this agent's input. fn covers(agent: CLIAgent) -> bool { - matches!(agent, CLIAgent::Claude | CLIAgent::Codex | CLIAgent::Gemini) + matches!( + agent, + CLIAgent::Claude | CLIAgent::Codex | CLIAgent::Gemini | CLIAgent::Pi + ) } fn is_rule(row: &str, width: usize) -> bool { @@ -368,6 +371,8 @@ fn is_rule(row: &str, width: usize) -> bool { /// - **Codex** starts its input line with `›`, on a padded block. /// - **Gemini** shades it as a block whose first line is `> ` — see /// [`gemini_input_top`] for its other drawings. +/// - **Pi** draws it between two rules, over its status rows — see +/// [`pi_input_top`]. /// /// `None` is the agent showing something else there — which is exactly when /// the box must get out of the way. @@ -411,10 +416,49 @@ pub(super) fn input_area(agent: CLIAgent, rows: &[String], width: usize) -> Opti CLIAgent::Gemini => { gemini_input_top(rows, floor, width).map(|top| InputArea { top, mode: None }) } + CLIAgent::Pi => pi_input_top(rows, floor, width).map(|top| InputArea { top, mode: None }), _ => None, } } +/// How many rows Pi draws under its input at most: the directory and usage +/// rows, an extension's status, and its own completion list, which opens +/// there. +const PI_FOOTER_MAX_ROWS: usize = 10; + +/// Where Pi's input starts: its upper rule. +/// +/// Pi draws its editor between two full-width rules, the upper one carrying +/// the turn's spinner (`── ⠹ Working ──`) while it works, with the +/// directory and usage rows under the lower one. Its selectors — `/model`, +/// `/resume`, `/tree`, `/login` — take the editor's place between the same +/// rules, but open on a blank row and run on for several, and `/settings` +/// opens on its `>` search line; the editor is one row when empty, and opens +/// on its text otherwise. Only the lowest pair of rules counts: anything +/// ruled higher up is the transcript's. +fn pi_input_top(rows: &[String], floor: usize, width: usize) -> Option { + let rule = |row: &str| { + let t = row.trim(); + t.starts_with("──") + && t.ends_with('─') + && t.chars().filter(|&c| c == '─').count() >= width * 3 / 5 + }; + let rules: Vec = (floor..rows.len()).filter(|&i| rule(&rows[i])).collect(); + let (&lower, higher) = rules.split_last()?; + let &upper = higher.last()?; + let footer = rows[lower + 1..] + .iter() + .filter(|r| !r.trim().is_empty()) + .count(); + let inside = &rows[upper + 1..lower]; + let opens_on_text = |row: &String| { + let t = row.trim_end(); + !t.trim().is_empty() && t != ">" && !t.starts_with("> ") + }; + let editor = inside.len() == 1 || inside.first().is_some_and(opens_on_text); + (footer <= PI_FOOTER_MAX_ROWS && editor).then_some(upper) +} + /// How many rows Gemini's footer — the workspace, sandbox and model, under /// its labels — takes below the input at most. const GEMINI_FOOTER_MAX_ROWS: usize = 4; @@ -2860,6 +2904,99 @@ mod tests { ); } + const PI_FOOTER: [&str; 2] = [ + "/private/tmp/t7b5/proj (main)", + "↑29k ↓52 1.9%/200k (auto) claude-sonnet-4-6", + ]; + + fn pi_screen(lines: &[&str]) -> Vec { + let mut rows = screen(lines); + rows.extend(PI_FOOTER.iter().map(|r| r.to_string())); + rows + } + + #[test] + fn pis_input_is_its_lowest_ruled_editor() { + // Empty, idle. + let rows = pi_screen(&[" 只回复两个字:好的", "", " 好的", "", RULE, "", RULE]); + assert_eq!( + input_area(CLIAgent::Pi, &rows, 40), + Some(InputArea { top: 4, mode: None }) + ); + // Several lines in it, while a turn runs and the spinner rides the + // upper rule. + let working = "── ⠼ Working ──────────────────────────────"; + let rows = pi_screen(&["", working, "line one", "line two", RULE]); + assert_eq!( + input_area(CLIAgent::Pi, &rows, 40), + Some(InputArea { top: 1, mode: None }) + ); + // Its completion list opens between the editor and the status rows. + let rows = pi_screen(&[ + RULE, + "/mod", + RULE, + "→ model — Select model", + " scoped-models Enable/disable models for Ctrl+P cycling", + ]); + assert_eq!( + input_area(CLIAgent::Pi, &rows, 40), + Some(InputArea { top: 0, mode: None }) + ); + } + + /// Pi's selectors take the editor's place between its rules: none of + /// them is the input. + #[test] + fn pis_selectors_are_not_its_input() { + let model = pi_screen(&[ + " 好的", + "", + RULE, + "", + "Only showing models from configured providers. Use /login to add providers.", + "", + ">", + "", + "→ ✓ claude-sonnet-4-6 [relay] · default", + " gpt-5.5 [relay]", + "", + " Enter to select · Ctrl+S to set as default · Escape/Ctrl+C to cancel", + RULE, + ]); + assert_eq!(input_area(CLIAgent::Pi, &model, 40), None); + let settings = pi_screen(&[ + " Operation aborted", + "", + RULE, + ">", + "", + "→ Auto-compact true", + " Auto-resize images true", + " (1/32)", + "", + " Type to search · Enter/Space to change · Esc to cancel", + RULE, + ]); + assert_eq!(input_area(CLIAgent::Pi, &settings, 40), None); + let resume = pi_screen(&[ + RULE, + "", + "Resume Session (Current Folder) ◉ Current Folder | ○ All", + "tab scope · re: regex · \"phrase\" exact", + "", + ">", + "", + "› Reply with just: ok1 18 now", + RULE, + ]); + assert_eq!(input_area(CLIAgent::Pi, &resume, 40), None); + // A ruled block far above the bottom is the transcript's. + let mut scrolled = screen(&[RULE, "quoted", RULE]); + scrolled.extend((0..12).map(|i| format!(" reply line {i}"))); + assert_eq!(input_area(CLIAgent::Pi, &scrolled, 40), None); + } + #[test] fn model_ids_read_the_way_people_say_them() { assert_eq!(model_label("claude-opus-5-5"), "Opus 5.5");