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:
parent
5586a7937c
commit
ab1167e99d
2 changed files with 126 additions and 55 deletions
|
|
@ -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();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
src/ui/chat.rs
157
src/ui/chat.rs
|
|
@ -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(" │ "));
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue