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) => {
chat.copy_message_at(m.column, m.row);
}
// Wheel rides the same scroll field the
// arrow keys move — a notch is three lines.
// Wheel scrolls whichever pane the cursor
// is over — thinking, subconscious, or the
// message history. A notch is three lines.
MouseEventKind::ScrollUp => {
chat.scroll = chat.scroll.saturating_add(3);
chat.wheel_scroll(m.column, m.row, true);
}
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.update_completion();
}
KeyCode::Up => {
chat.scroll = chat.scroll.saturating_add(1);
}
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);
}
// Arrow / page keys no longer scroll the message history — that
// is mouse-wheel only now. The keys fall through as no-ops so
// they're free for input-cursor movement later.
KeyCode::Tab => {
chat.toggle_cockpit();
}

View file

@ -13,7 +13,7 @@
//! - Surfacing items: centered yellow bubble with `[surfacing]` header
//! (Constitution Article II.2).
use std::cell::RefCell;
use std::cell::{Cell, RefCell};
use std::sync::Arc;
use std::time::{Duration, Instant};
@ -342,6 +342,15 @@ pub struct MsgLayout {
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 backend: Arc<dyn Backend>,
pub mode: String,
@ -386,6 +395,14 @@ pub struct ChatState {
pub thinking: Vec<String>,
/// Recent subconscious surfacings + reflections for the cockpit pane.
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.
pub tick: u64,
/// When the current turn started (for spinner animation).
@ -548,6 +565,9 @@ impl ChatState {
cockpit: false,
thinking: Vec::new(),
cockpit_log,
thinking_scroll: Cell::new(0),
subconscious_scroll: Cell::new(0),
cockpit_layout: RefCell::new(CockpitLayout::default()),
tick: 0,
turn_started: None,
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;
}
/// 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.
/// Call after each input mutation. Stays live during `busy` so the
/// user can autocomplete `/btw` mid-turn.
@ -2721,19 +2762,44 @@ fn wrap_words(text: &str, width: usize) -> Vec<String> {
out
}
fn count_visual_lines(text: &str, wrap_width: usize) -> usize {
if text.is_empty() {
return 1;
/// Word-aware wrap for the input box. Given one logical line's characters,
/// returns the `(start, end)` char-index chunks that tile it left to right.
/// 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 mut count = 0;
for line in text.split('\n') {
let chars = line.chars().count();
if chars == 0 {
count += 1;
} else {
count += (chars + w - 1) / w;
}
let chars: Vec<char> = line.chars().collect();
count += wrap_input_line(&chars, w).len();
}
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();
for (li, logical_line) in logical.iter().enumerate() {
// Space-preserving wrap: split at inner_width regardless of word
// boundaries. This keeps trailing spaces visible so the cursor
// position matches what the user typed.
let mut pos = 0;
// Word-aware wrap: break at the last space before the edge so a word
// still being typed slides whole onto the next line. Every character
// is preserved exactly, so the cursor stays where the user typed.
let chars: Vec<char> = logical_line.chars().collect();
let mut chunk_start = 0;
while chunk_start < chars.len() {
let chunk_end = (chunk_start + inner_width).min(chars.len());
let chunks = wrap_input_line(&chars, inner_width);
for (pos, &(chunk_start, chunk_end)) in chunks.iter().enumerate() {
let chunk: String = chars[chunk_start..chunk_end].iter().collect();
let is_first = li == 0 && pos == 0;
let prefix: Span<'static> = if is_first {
@ -2782,14 +2846,12 @@ fn draw_input(f: &mut Frame, state: &ChatState, area: Rect) {
} else {
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))];
if is_last {
spans.push(Span::styled(cursor_ch.to_string(), Style::default().fg(prefix_color)));
}
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)])
.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
// consecutive reasoning blocks don't run into each other visually.
// Round-separator sentinel lines (pushed by ToolCall handler) render
// 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
.thinking
.iter()
.rev()
.take(panes[0].height as usize)
.skip(thinking_scroll)
.take(thinking_window)
.collect::<Vec<_>>()
.into_iter()
.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)
.wrap(Wrap { trim: false })
.block(
@ -3037,16 +3120,20 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.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]);
// Subconscious pane (surfacings, reflections, archivist)
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
.cockpit_log
.iter()
.rev()
.skip(sub_scroll)
.take(visible_height)
.collect::<Vec<_>>()
.into_iter()
@ -3079,6 +3166,11 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
])
})
.collect();
let sub_title = if sub_scroll > 0 {
format!(" subconscious ↑{} ", sub_scroll)
} else {
" subconscious ".to_string()
};
let subconscious = Paragraph::new(log_view)
.wrap(Wrap { trim: false })
.block(
@ -3086,37 +3178,24 @@ fn draw_cockpit(f: &mut Frame, state: &ChatState, area: Rect) {
.borders(Borders::ALL)
.border_type(BorderType::Rounded)
.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]);
}
fn draw_footer(f: &mut Frame, state: &ChatState, area: Rect) {
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 tool_hint = if state.tool_cards_expanded { "^T collapse tools" } else { "^T expand tools" };
let esc_hint = "Esc menu";
let posture_label = match state.render_mode {
ChatMode::Conversation => "chat",
ChatMode::Code => "code",
};
let tool_hint = if state.tool_cards_expanded { "CTRL+T collapse tools" } else { "CTRL+T expand tools" };
let mut spans = vec![
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),
),
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::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 {
spans.push(Span::raw(""));