feat(hh): scrollback for chat + sandbox terminal

- Chat history: PgUp/PgDn (page), arrows (line when no sandbox), Home=oldest,
  End=live. Viewport holds steady when new lines arrive while scrolled up;
  sending a message jumps back to live. Backlog capped at 4000 lines.
- Sandbox terminal: vt100 parser now keeps 2000 rows of scrollback; ↑/↓ scroll
  it when not driving (arrows still go to the shell while driving). Offset
  applied each frame; reset on dismiss / End.
- Title indicators: 'chat ↑N (End=live)' and 'sandbox · ↑N scrollback'.

Termux's extra-keys row has arrows + PgUp/PgDn/Home/End, so it's phone-usable.
9 tests pass; clean build.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
leetcrypt
2026-05-30 23:10:36 -07:00
parent d6595935d3
commit 51bc85e078
2 changed files with 78 additions and 10 deletions
+15 -5
View File
@@ -46,9 +46,11 @@ fn draw_sandbox(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &T
.map(|r| Line::from(Span::styled(r, Style::default().fg(theme.title))))
.collect();
let drive = if app.driving {
" · DRIVING — type here · Esc to release"
" · DRIVING — type here · Esc to release".to_string()
} else if app.sbx_scroll > 0 {
format!(" · ↑{} scrollback (↓/End=live)", app.sbx_scroll)
} else {
" · type /drive (or F2) to take the shell"
" · /drive (or F2) · ↑/↓ scroll".to_string()
};
let title = format!(" sandbox · {}{} ", sv.backend, drive);
let border = if app.driving { theme.accent } else { theme.border };
@@ -98,13 +100,21 @@ fn fmt_line<'a>(l: &'a ChatLine, app: &App, theme: &Theme) -> Line<'a> {
fn draw_chat(f: &mut Frame, area: ratatui::layout::Rect, app: &App, theme: &Theme) {
let visible = area.height.saturating_sub(2) as usize;
let start = app.lines.len().saturating_sub(visible);
let lines: Vec<Line> = app.lines[start..].iter().map(|l| fmt_line(l, app, theme)).collect();
let len = app.lines.len();
// Window ends `chat_scroll` lines above the live bottom.
let end = len.saturating_sub(app.chat_scroll);
let start = end.saturating_sub(visible);
let lines: Vec<Line> = app.lines[start..end].iter().map(|l| fmt_line(l, app, theme)).collect();
let title = if app.chat_scroll > 0 {
format!(" chat ↑{} (End=live) ", app.chat_scroll)
} else {
" chat ".to_string()
};
let chat = Paragraph::new(lines)
.block(
Block::bordered()
.border_style(Style::default().fg(theme.border))
.title(Span::styled(" chat ", Style::default().fg(theme.title))),
.title(Span::styled(title, Style::default().fg(theme.title))),
)
.wrap(Wrap { trim: false });
f.render_widget(chat, area);