Watch
1
0
Fork
You've already forked souveraine
0

tui: cockpit pane scroll, word-aware input wrap, footer cleanup

Cockpit thinking/subconscious panes now scroll independently under mouse
wheel. Word-aware input wrapping preserves cursor position. Footer
stripped of posture label, scroll hint, S-Ret indicator — only
conv ID and pressure remain.
This commit is contained in:
Fimeg 2026-05-18 21:31:02 -04:00
commit ab1167e99d
2 changed files with 126 additions and 55 deletions

View file

@ -524,13 +524,14 @@ impl App {
MouseEventKind::Down(MouseButton::Left) => { MouseEventKind::Down(MouseButton::Left) => {
chat.copy_message_at(m.column, m.row); chat.copy_message_at(m.column, m.row);
} }
// Wheel rides the same scroll field the // Wheel scrolls whichever pane the cursor
// arrow keys move — a notch is three lines. // is over — thinking, subconscious, or the
// message history. A notch is three lines.
MouseEventKind::ScrollUp => { MouseEventKind::ScrollUp => {
chat.scroll = chat.scroll.saturating_add(3); chat.wheel_scroll(m.column, m.row, true);
} }
MouseEventKind::ScrollDown => { MouseEventKind::ScrollDown => {
chat.scroll = chat.scroll.saturating_sub(3); chat.wheel_scroll(m.column, m.row, false);
} }
_ => {} _ => {}
} }
@ -1224,18 +1225,9 @@ impl App {
chat.input.pop(); chat.input.pop();
chat.update_completion(); chat.update_completion();
} }
KeyCode::Up => { // Arrow / page keys no longer scroll the message history — that
chat.scroll = chat.scroll.saturating_add(1); // is mouse-wheel only now. The keys fall through as no-ops so
} // they're free for input-cursor movement later.
KeyCode::Down => {
chat.scroll = chat.scroll.saturating_sub(1);
}
KeyCode::PageUp => {
chat.scroll = chat.scroll.saturating_add(10);
}
KeyCode::PageDown => {
chat.scroll = chat.scroll.saturating_sub(10);
}
KeyCode::Tab => { KeyCode::Tab => {
chat.toggle_cockpit(); chat.toggle_cockpit();
} }

View file

@ -13,7 +13,7 @@
//! - Surfacing items: centered yellow bubble with `[surfacing]` header //! - Surfacing items: centered yellow bubble with `[surfacing]` header
//! (Constitution Article II.2). //! (Constitution Article II.2).
use std::cell::RefCell; use std::cell::{Cell, RefCell};
use std::sync::Arc; use std::sync::Arc;
use std::time::{Duration, Instant}; use std::time::{Duration, Instant};
@ -342,6 +342,15 @@ pub struct MsgLayout {
pub spans: Vec<(usize, usize, usize)>, pub spans: Vec<(usize, usize, usize)>,
} }
/// Screen rects of the two cockpit panes from the last draw — lets a mouse
/// wheel notch land on whichever pane the cursor is hovering. Built fresh
/// every draw of `draw_cockpit`; left zeroed while the cockpit is closed.
#[derive(Default)]
pub struct CockpitLayout {
pub thinking: Rect,
pub subconscious: Rect,
}
pub struct ChatState { pub struct ChatState {
pub backend: Arc<dyn Backend>, pub backend: Arc<dyn Backend>,
pub mode: String, pub mode: String,
@ -386,6 +395,14 @@ pub struct ChatState {
pub thinking: Vec<String>, pub thinking: Vec<String>,
/// Recent subconscious surfacings + reflections for the cockpit pane. /// Recent subconscious surfacings + reflections for the cockpit pane.
pub cockpit_log: Vec<CockpitEntry>, pub cockpit_log: Vec<CockpitEntry>,
/// Entries scrolled up from the bottom in the cockpit's thinking pane.
/// `Cell` so `draw` (`&ChatState`) can clamp it back against the count.
pub thinking_scroll: Cell<u16>,
/// Entries scrolled up from the bottom in the cockpit's subconscious pane.
pub subconscious_scroll: Cell<u16>,
/// Screen rects of the two cockpit panes — wheel hit-testing. `RefCell`
/// so `draw` fills it; zeroed while the cockpit is closed.
pub cockpit_layout: RefCell<CockpitLayout>,
/// Monotonic tick counter for animation timings. /// Monotonic tick counter for animation timings.
pub tick: u64, pub tick: u64,
/// When the current turn started (for spinner animation). /// When the current turn started (for spinner animation).
@ -548,6 +565,9 @@ impl ChatState {
cockpit: false, cockpit: false,
thinking: Vec::new(), thinking: Vec::new(),
cockpit_log, cockpit_log,
thinking_scroll: Cell::new(0),
subconscious_scroll: Cell::new(0),
cockpit_layout: RefCell::new(CockpitLayout::default()),
tick: 0, tick: 0,
turn_started: None, turn_started: None,
last_event_at: Instant::now(), last_event_at: Instant::now(),
@ -1571,6 +1591,27 @@ Tab toggles the cockpit pane. `t` (on empty input) toggles tool expansion.";
self.cockpit = !self.cockpit; self.cockpit = !self.cockpit;
} }
/// Route a mouse wheel notch (three lines) to whichever pane the cursor
/// is over: the thinking pane, the subconscious pane, or — failing both —
/// the message history. `up` scrolls back into history.
pub fn wheel_scroll(&mut self, col: u16, row: u16, up: bool) {
let step = |v: u16| if up { v.saturating_add(3) } else { v.saturating_sub(3) };
let (thinking, subconscious) = {
let cl = self.cockpit_layout.borrow();
(cl.thinking, cl.subconscious)
};
let hit = |r: Rect| {
col >= r.x && col < r.x + r.width && row >= r.y && row < r.y + r.height
};
if self.cockpit && hit(thinking) {
self.thinking_scroll.set(step(self.thinking_scroll.get()));
} else if self.cockpit && hit(subconscious) {
self.subconscious_scroll.set(step(self.subconscious_scroll.get()));
} else {
self.scroll = step(self.scroll);
}
}
/// Update slash-command completion state based on current input. /// Update slash-command completion state based on current input.
/// Call after each input mutation. Stays live during `busy` so the /// Call after each input mutation. Stays live during `busy` so the
/// user can autocomplete `/btw` mid-turn. /// user can autocomplete `/btw` mid-turn.
@ -2721,19 +2762,44 @@ fn wrap_words(text: &str, width: usize) -> Vec<String> {
out out
} }
fn count_visual_lines(text: &str, wrap_width: usize) -> usize { /// Word-aware wrap for the input box. Given one logical line's characters,
if text.is_empty() { /// returns the `(start, end)` char-index chunks that tile it left to right.
return 1; /// Every character is kept exactly once and in order — so the blinking
/// cursor still lands where the user typed — and the only decision is
/// *where* to break: at the last space before the edge, so a word still
/// being typed slides whole onto the next line instead of being cut.
/// A single word longer than the box is hard-split, as it must be.
fn wrap_input_line(chars: &[char], width: usize) -> Vec<(usize, usize)> {
let width = width.max(1);
let mut chunks = Vec::new();
let mut start = 0;
while start < chars.len() {
let hard_end = (start + width).min(chars.len());
let end = if hard_end == chars.len() {
hard_end
} else {
match chars[start..hard_end].iter().rposition(|c| c.is_whitespace()) {
// Break just after the last space — the unfinished word moves down.
Some(rel) => start + rel + 1,
// No space in reach: a word longer than the box, hard-split it.
None => hard_end,
}
};
chunks.push((start, end));
start = end;
} }
if chunks.is_empty() {
chunks.push((0, 0));
}
chunks
}
fn count_visual_lines(text: &str, wrap_width: usize) -> usize {
let w = wrap_width.max(1); let w = wrap_width.max(1);
let mut count = 0; let mut count = 0;
for line in text.split('\n') { for line in text.split('\n') {
let chars = line.chars().count(); let chars: Vec<char> = line.chars().collect();
if chars == 0 { count += wrap_input_line(&chars, w).len();
count += 1;
} else {
count += (chars + w - 1) / w;
}
} }
count.max(1) count.max(1)
} }
@ -2767,14 +2833,12 @@ fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
let logical: Vec<&str> = state.input.split('\n').collect(); let logical: Vec<&str> = state.input.split('\n').collect();
for (li, logical_line) in logical.iter().enumerate() { for (li, logical_line) in logical.iter().enumerate() {
// Space-preserving wrap: split at inner_width regardless of word // Word-aware wrap: break at the last space before the edge so a word
// boundaries. This keeps trailing spaces visible so the cursor // still being typed slides whole onto the next line. Every character
// position matches what the user typed. // is preserved exactly, so the cursor stays where the user typed.
let mut pos = 0;
let chars: Vec<char> = logical_line.chars().collect(); let chars: Vec<char> = logical_line.chars().collect();
let mut chunk_start = 0; let chunks = wrap_input_line(&chars, inner_width);
while chunk_start < chars.len() { for (pos, &(chunk_start, chunk_end)) in chunks.iter().enumerate() {
let chunk_end = (chunk_start + inner_width).min(chars.len());
let chunk: String = chars[chunk_start..chunk_end].iter().collect(); let chunk: String = chars[chunk_start..chunk_end].iter().collect();
let is_first = li == 0 && pos == 0; let is_first = li == 0 && pos == 0;
let prefix: Span<'static> = if is_first { let prefix: Span<'static> = if is_first {
@ -2782,14 +2846,12 @@ fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
} else { } else {
Span::raw(" ") Span::raw(" ")
}; };
let is_last = li == logical.len() - 1 && chunk_end >= chars.len(); let is_last = li == logical.len() - 1 && pos == chunks.len() - 1;
let mut spans = vec![prefix, Span::styled(chunk, Style::default().fg(Color::White))]; let mut spans = vec![prefix, Span::styled(chunk, Style::default().fg(Color::White))];
if is_last { if is_last {
spans.push(Span::styled(cursor_ch.to_string(), Style::default().fg(prefix_color))); spans.push(Span::styled(cursor_ch.to_string(), Style::default().fg(prefix_color)));
} }
lines.push(Line::from(spans)); lines.push(Line::from(spans));
chunk_start = chunk_end;
pos += 1;
} }
} }
@ -2999,15 +3061,31 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
.constraints([Constraint::Percentage(50), Constraint::Percentage(50)]) .constraints([Constraint::Percentage(50), Constraint::Percentage(50)])
.split(area); .split(area);
// Record pane rects so a mouse wheel notch can target whichever pane
// the cursor is hovering (see ChatState::wheel_scroll).
*state.cockpit_layout.borrow_mut() = CockpitLayout {
thinking: panes[0],
subconscious: panes[1],
};
// Thinking pane — interleave blank separators between entries so // Thinking pane — interleave blank separators between entries so
// consecutive reasoning blocks don't run into each other visually. // consecutive reasoning blocks don't run into each other visually.
// Round-separator sentinel lines (pushed by ToolCall handler) render // Round-separator sentinel lines (pushed by ToolCall handler) render
// dimmer so the eye finds the break without it being loud. // dimmer so the eye finds the break without it being loud.
//
// `thinking_scroll` is entries hidden *below* the viewport — the wheel
// walks back through history. Clamped here against the live count so a
// wheel that ran past the top settles instead of emptying the pane.
let thinking_window = panes[0].height as usize;
let thinking_max = state.thinking.len().saturating_sub(thinking_window);
let thinking_scroll = (state.thinking_scroll.get() as usize).min(thinking_max);
state.thinking_scroll.set(thinking_scroll as u16);
let thinking_entries: Vec<&String> = state let thinking_entries: Vec<&String> = state
.thinking .thinking
.iter() .iter()
.rev() .rev()
.take(panes[0].height as usize) .skip(thinking_scroll)
.take(thinking_window)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter() .into_iter()
.rev() .rev()
@ -3030,6 +3108,11 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
))); )));
} }
} }
let thinking_title = if thinking_scroll > 0 {
format!(" thinking ↑{} ", thinking_scroll)
} else {
" thinking ".to_string()
};
let thinking = Paragraph::new(thinking_view) let thinking = Paragraph::new(thinking_view)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block( .block(
@ -3037,16 +3120,20 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(state.palette.agent_dim)) .border_style(Style::default().fg(state.palette.agent_dim))
.title(Span::styled(" thinking ", Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::BOLD))), .title(Span::styled(thinking_title, Style::default().fg(state.palette.agent_dim).add_modifier(Modifier::BOLD))),
); );
f.render_widget(thinking, panes[0]); f.render_widget(thinking, panes[0]);
// Subconscious pane (surfacings, reflections, archivist) // Subconscious pane (surfacings, reflections, archivist)
let visible_height = panes[1].height.saturating_sub(2) as usize; let visible_height = panes[1].height.saturating_sub(2) as usize;
let sub_max = state.cockpit_log.len().saturating_sub(visible_height);
let sub_scroll = (state.subconscious_scroll.get() as usize).min(sub_max);
state.subconscious_scroll.set(sub_scroll as u16);
let visible_entries: Vec<&CockpitEntry> = state let visible_entries: Vec<&CockpitEntry> = state
.cockpit_log .cockpit_log
.iter() .iter()
.rev() .rev()
.skip(sub_scroll)
.take(visible_height) .take(visible_height)
.collect::<Vec<_>>() .collect::<Vec<_>>()
.into_iter() .into_iter()
@ -3079,6 +3166,11 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
]) ])
}) })
.collect(); .collect();
let sub_title = if sub_scroll > 0 {
format!(" subconscious ↑{} ", sub_scroll)
} else {
" subconscious ".to_string()
};
let subconscious = Paragraph::new(log_view) let subconscious = Paragraph::new(log_view)
.wrap(Wrap { trim: false }) .wrap(Wrap { trim: false })
.block( .block(
@ -3086,37 +3178,24 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
.borders(Borders::ALL) .borders(Borders::ALL)
.border_type(BorderType::Rounded) .border_type(BorderType::Rounded)
.border_style(Style::default().fg(state.palette.surfacing)) .border_style(Style::default().fg(state.palette.surfacing))
.title(Span::styled(" subconscious ", Style::default().fg(state.palette.surfacing).add_modifier(Modifier::BOLD))), .title(Span::styled(sub_title, Style::default().fg(state.palette.surfacing).add_modifier(Modifier::BOLD))),
); );
f.render_widget(subconscious, panes[1]); f.render_widget(subconscious, panes[1]);
} }
fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) { fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
let pressure_pct = (state.pressure * 100.0) as u16; let pressure_pct = (state.pressure * 100.0) as u16;
let pressure_label = format!("ctx {}%", pressure_pct);
let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" }; let cockpit_hint = if state.cockpit { "Tab close cockpit" } else { "Tab cockpit" };
let tool_hint = if state.tool_cards_expanded { "^T collapse tools" } else { "^T expand tools" }; let tool_hint = if state.tool_cards_expanded { "CTRL+T collapse tools" } else { "CTRL+T expand tools" };
let esc_hint = "Esc menu";
let posture_label = match state.render_mode {
ChatMode::Conversation => "chat",
ChatMode::Code => "code",
};
let mut spans = vec![ let mut spans = vec![
Span::styled( Span::styled(
format!(" {esc_hint} · Enter send · S-Ret ↵ · ↑↓ scroll · {cockpit_hint} · {tool_hint} "), format!(" Esc menu · Enter send · {cockpit_hint} · {tool_hint} "),
Style::default().fg(state.palette.agent_dim), Style::default().fg(state.palette.agent_dim),
), ),
Span::raw(""), Span::raw(""),
Span::styled(
format!("posture {posture_label}"),
Style::default().fg(
if state.render_mode == ChatMode::Code { state.palette.tool_accent } else { state.palette.agent_dim },
),
),
Span::raw(""),
Span::styled(format!("conv {}", short(&state.conversation_id)), Style::default().fg(state.palette.agent_dim)), Span::styled(format!("conv {}", short(&state.conversation_id)), Style::default().fg(state.palette.agent_dim)),
Span::raw(""), Span::raw(""),
Span::styled(pressure_label, Style::default().fg(state.palette.agent_dim)), Span::styled(format!("ctx {}%", pressure_pct), Style::default().fg(state.palette.agent_dim)),
]; ];
if state.scroll > 0 { if state.scroll > 0 {
spans.push(Span::raw("")); spans.push(Span::raw(""));