mirror of
https://github.com/hanzoai/dev.git
synced 2026-10-17 15:34:54 +00:00
chore(tui): remove legacy overlay stack
This commit is contained in:
+8
-18
@@ -18,7 +18,7 @@ This document provides a comprehensive inventory of dead or unused code in the c
|
||||
- ✅ **6 orphaned modules** totaling ~72,700 bytes — **REMOVED** (see Section 2.1)
|
||||
- ✅ **Feature flag audit complete** — `code-fork` feature is properly used and enabled by default
|
||||
- ✅ **Core modules audit complete** — All modules (`codex/`, `unified_exec/`, `exec_command/`) are actively used
|
||||
- ⏳ **TUI modules audit** — Backtrack overlay stack still under evaluation (`backtrack_helpers.rs`, `pager_overlay.rs`, `resume_picker.rs` remain)
|
||||
- ⏳ **TUI modules audit** — Streaming controller/markdown modules still under review; overlay stack (`pager_overlay.rs`, `backtrack_helpers.rs`, `resume_picker.rs`) deleted
|
||||
- **9 modules** marked with `#![allow(dead_code)]` at file level — **ACCEPTABLE** (mostly helpers and utilities)
|
||||
- ✅ `vt100-tests` feature flag removed from `code-rs/tui/Cargo.toml` (2025-10-05)
|
||||
- **0 orphaned prompt files** (all are actively used, documented in `docs/maintenance/prompt-architecture.md`)
|
||||
@@ -35,7 +35,7 @@ This document provides a comprehensive inventory of dead or unused code in the c
|
||||
**Items Removed:**
|
||||
- `legacy_tests` feature flag from `code-rs/tui/Cargo.toml`
|
||||
- All 27 gated test modules (~11,232 lines)
|
||||
- Orphaned modules: `app_backtrack.rs`, `custom_terminal.rs`, `scroll_view.rs`, `text_block.rs`, `transcript_app.rs`
|
||||
- Orphaned modules: `app_backtrack.rs`, `custom_terminal.rs`, `scroll_view.rs`, `text_block.rs`, `transcript_app.rs`, `pager_overlay.rs`, `backtrack_helpers.rs`, `resume_picker.rs`
|
||||
- Legacy `exec_cell/render.rs`
|
||||
- Introduced new smoke scaffold at `code-rs/tui/tests/ui_smoke.rs`
|
||||
|
||||
@@ -216,26 +216,18 @@ cargo build -p code-tui
|
||||
|
||||
---
|
||||
|
||||
#### 2.1.4 resume_picker.rs ⏳ PENDING
|
||||
#### 2.1.4 resume_picker.rs ✅ REMOVED
|
||||
|
||||
**Location:** `code-rs/tui/src/resume_picker.rs`
|
||||
**Location:** `code-rs/tui/src/resume_picker.rs` (DELETED)
|
||||
**Size:** 37,218 bytes
|
||||
**Status:** Still present; depends on now-removed `custom_terminal.rs`
|
||||
**Status:** REMOVED - Alternate-screen picker superseded by inline resume prompt
|
||||
|
||||
**Note:** Needs replacement or deletion once resume UX plan is finalized.
|
||||
|
||||
**Deletion Plan:**
|
||||
```bash
|
||||
# Candidate removal once replacement flow lands
|
||||
# rm code-rs/tui/src/resume_picker.rs
|
||||
```
|
||||
|
||||
**Verification (current state):**
|
||||
**Verification:**
|
||||
```bash
|
||||
rg "resume_picker" code-rs/tui/
|
||||
```
|
||||
|
||||
**Risk:** MEDIUM - Module references legacy terminal helpers
|
||||
**Risk:** NONE - No code paths referenced the module
|
||||
|
||||
---
|
||||
|
||||
@@ -358,17 +350,15 @@ cargo test -p code-core --test suite
|
||||
|
||||
| File | Size | Status |
|
||||
|------|------|--------|
|
||||
| `src/pager_overlay.rs` | 25,058 B | `#![allow(dead_code, unused_imports, unused_variables)]` — PENDING REVIEW |
|
||||
| `src/streaming/controller.rs` | 18,450 B | `#![allow(dead_code)]` — PENDING REVIEW |
|
||||
| `src/streaming/mod.rs` | 3,952 B | `#![allow(dead_code)]` — PENDING REVIEW |
|
||||
| `src/markdown_stream.rs` | 41,063 B | `#![allow(dead_code)]` — PENDING REVIEW |
|
||||
| `src/markdown.rs` | 28,666 B | `#![allow(dead_code)]` — PENDING REVIEW |
|
||||
| ✅ ~~`src/transcript_app.rs`~~ | ~~9,904 B~~ | **REMOVED** |
|
||||
| `src/backtrack_helpers.rs` | 4,919 B | `#![allow(dead_code)]` — PENDING REVIEW |
|
||||
| `src/bottom_pane/list_selection_view.rs` | ? | `#![allow(dead_code)]` — PENDING REVIEW |
|
||||
| `src/bottom_pane/paste_burst.rs` | ? | `#![allow(dead_code, unused_imports, unused_variables)]` — PENDING REVIEW |
|
||||
|
||||
**Remaining:** ~102,000+ bytes requiring investigation
|
||||
**Remaining:** ~70,000+ bytes requiring investigation
|
||||
|
||||
### 3.2 Core Modules (code-rs/core)
|
||||
|
||||
|
||||
+2
-1
@@ -26,6 +26,7 @@ Supporting detail: `DEAD_CODE_INVENTORY.md`
|
||||
- Removed ~35,000 lines of outdated test code
|
||||
- See `docs/migration/legacy-tests-retirement.md` for porting plan
|
||||
- ✅ Removed legacy TUI test harness files (`chatwidget/tests.rs`, `chatwidget/tests_retry.rs`) and the orphaned `exec_cell/render.rs`
|
||||
- ✅ Removed defunct TUI overlay/backtrack modules (`pager_overlay.rs`, `backtrack_helpers.rs`, `resume_picker.rs`)
|
||||
- ✅ Archived `code-rs/tui/agent_tasks/` planning docs under `docs/archive/tui-migrations/`
|
||||
- ✅ Deleted `code-rs/tui/src/compat.rs` ratatui adapter layer; import ratatui types directly
|
||||
- ✅ Removed all `cfg(feature = "legacy_tests")` blocks
|
||||
@@ -33,7 +34,7 @@ Supporting detail: `DEAD_CODE_INVENTORY.md`
|
||||
- ✅ Created comprehensive migration documentation in `docs/migration/` and `docs/maintenance/`
|
||||
|
||||
### Needs Follow-Up
|
||||
- ⏳ TUI overlay/backtrack modules still present pending replacement (`code-rs/tui/src/backtrack_helpers.rs`, `code-rs/tui/src/pager_overlay.rs`, `code-rs/tui/src/resume_picker.rs`)
|
||||
- ⏳ Audit remaining TUI modules marked `#![allow(dead_code)]` (streaming controller, markdown stack)
|
||||
- ⏳ Reconcile vt100 rendering coverage — upstream snapshots live only in `codex-rs`
|
||||
|
||||
### Active Work
|
||||
|
||||
@@ -1,155 +0,0 @@
|
||||
#![allow(dead_code)]
|
||||
use ratatui::text::Line;
|
||||
|
||||
/// Convenience: compute the highlight range for the Nth last user message.
|
||||
pub(crate) fn highlight_range_for_nth_last_user(
|
||||
lines: &[Line<'_>],
|
||||
n: usize,
|
||||
) -> Option<(usize, usize)> {
|
||||
let header = find_nth_last_user_header_index(lines, n)?;
|
||||
Some(highlight_range_from_header(lines, header))
|
||||
}
|
||||
|
||||
/// Compute the wrapped display-line offset before `header_idx`, for a given width.
|
||||
pub(crate) fn wrapped_offset_before(lines: &[Line<'_>], header_idx: usize, width: u16) -> usize {
|
||||
let before = &lines[0..header_idx];
|
||||
crate::insert_history::word_wrap_lines(before, width).len()
|
||||
}
|
||||
|
||||
/// Find the header index for the Nth last user message in the transcript.
|
||||
/// Returns `None` if `n == 0` or there are fewer than `n` user messages.
|
||||
pub(crate) fn find_nth_last_user_header_index(lines: &[Line<'_>], n: usize) -> Option<usize> {
|
||||
if n == 0 {
|
||||
return None;
|
||||
}
|
||||
let mut found = 0usize;
|
||||
for (idx, line) in lines.iter().enumerate().rev() {
|
||||
let content: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
if content.trim() == "user" {
|
||||
found += 1;
|
||||
if found == n {
|
||||
return Some(idx);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Normalize a requested backtrack step `n` against the available user messages.
|
||||
/// - Returns `0` if there are no user messages.
|
||||
/// - Returns `n` if the Nth last user message exists.
|
||||
/// - Otherwise wraps to `1` (the most recent user message).
|
||||
pub(crate) fn normalize_backtrack_n(lines: &[Line<'_>], n: usize) -> usize {
|
||||
if n == 0 {
|
||||
return 0;
|
||||
}
|
||||
if find_nth_last_user_header_index(lines, n).is_some() {
|
||||
return n;
|
||||
}
|
||||
if find_nth_last_user_header_index(lines, 1).is_some() {
|
||||
1
|
||||
} else {
|
||||
0
|
||||
}
|
||||
}
|
||||
|
||||
/// Extract the text content of the Nth last user message.
|
||||
/// The message body is considered to be the lines following the "user" header
|
||||
/// until the first blank line.
|
||||
pub(crate) fn nth_last_user_text(lines: &[Line<'_>], n: usize) -> Option<String> {
|
||||
let header_idx = find_nth_last_user_header_index(lines, n)?;
|
||||
extract_message_text_after_header(lines, header_idx)
|
||||
}
|
||||
|
||||
/// Extract message text starting after `header_idx` until the first blank line.
|
||||
fn extract_message_text_after_header(lines: &[Line<'_>], header_idx: usize) -> Option<String> {
|
||||
let start = header_idx + 1;
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
for line in lines.iter().skip(start) {
|
||||
let is_blank = line
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.as_ref().trim().is_empty());
|
||||
if is_blank {
|
||||
break;
|
||||
}
|
||||
let text: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.as_ref())
|
||||
.collect::<Vec<_>>()
|
||||
.join("");
|
||||
out.push(text);
|
||||
}
|
||||
if out.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(out.join("\n"))
|
||||
}
|
||||
}
|
||||
|
||||
/// Given a header index, return the inclusive range for the message block
|
||||
/// [header_idx, end) where end is the first blank line after the header or the
|
||||
/// end of the transcript.
|
||||
fn highlight_range_from_header(lines: &[Line<'_>], header_idx: usize) -> (usize, usize) {
|
||||
let mut end = header_idx + 1;
|
||||
while end < lines.len() {
|
||||
let is_blank = lines[end]
|
||||
.spans
|
||||
.iter()
|
||||
.all(|s| s.content.as_ref().trim().is_empty());
|
||||
if is_blank {
|
||||
break;
|
||||
}
|
||||
end += 1;
|
||||
}
|
||||
(header_idx, end)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::text::Span;
|
||||
|
||||
fn line(s: &str) -> Line<'static> {
|
||||
Line::from(Span::raw(s.to_string()))
|
||||
}
|
||||
|
||||
fn transcript_with_users(count: usize) -> Vec<Line<'static>> {
|
||||
// Build a transcript with `count` user messages, each followed by one body line and a blank line.
|
||||
let mut v = Vec::new();
|
||||
for i in 0..count {
|
||||
v.push(line("user"));
|
||||
v.push(line(&format!("message {i}")));
|
||||
v.push(line(""));
|
||||
}
|
||||
v
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_wraps_to_one_when_past_oldest() {
|
||||
let lines = transcript_with_users(2);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 1), 1);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 2), 2);
|
||||
// Requesting 3rd when only 2 exist wraps to 1
|
||||
assert_eq!(normalize_backtrack_n(&lines, 3), 1);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_returns_zero_when_no_user_messages() {
|
||||
let lines = transcript_with_users(0);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 1), 0);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 5), 0);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn normalize_keeps_valid_n() {
|
||||
let lines = transcript_with_users(3);
|
||||
assert_eq!(normalize_backtrack_n(&lines, 2), 2);
|
||||
}
|
||||
}
|
||||
@@ -31,7 +31,6 @@ use uuid::Uuid;
|
||||
mod app;
|
||||
mod app_event;
|
||||
mod app_event_sender;
|
||||
mod backtrack_helpers;
|
||||
mod account_label;
|
||||
mod bottom_pane;
|
||||
mod chatwidget;
|
||||
@@ -57,7 +56,6 @@ mod markdown_renderer;
|
||||
mod markdown_stream;
|
||||
mod syntax_highlight;
|
||||
pub mod onboarding;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
mod render;
|
||||
// mod scroll_view; // Orphaned after trait-based HistoryCell migration
|
||||
|
||||
@@ -1,533 +0,0 @@
|
||||
#![allow(dead_code, unused_imports, unused_variables)]
|
||||
use std::io::Result;
|
||||
|
||||
use crate::insert_history;
|
||||
use crate::tui;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::buffer::Buffer;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Color;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::style::Styled;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use ratatui::text::Text;
|
||||
use ratatui::widgets::Clear;
|
||||
use ratatui::widgets::Paragraph;
|
||||
use ratatui::widgets::Widget;
|
||||
use ratatui::widgets::WidgetRef;
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
pub(crate) enum TuiEvent {
|
||||
Key(KeyEvent),
|
||||
Draw,
|
||||
}
|
||||
|
||||
pub(crate) enum Overlay {
|
||||
Transcript(TranscriptOverlay),
|
||||
Static(StaticOverlay),
|
||||
}
|
||||
|
||||
impl Overlay {
|
||||
pub(crate) fn new_transcript(lines: Vec<Line<'static>>, esc_hint: &'static str) -> Self {
|
||||
Self::Transcript(TranscriptOverlay::new(lines, esc_hint))
|
||||
}
|
||||
|
||||
pub(crate) fn new_static_with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
Self::Static(StaticOverlay::with_title(lines, title))
|
||||
}
|
||||
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match self {
|
||||
Overlay::Transcript(o) => o.handle_event(tui, event),
|
||||
Overlay::Static(o) => o.handle_event(tui, event),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
match self {
|
||||
Overlay::Transcript(o) => o.is_done(),
|
||||
Overlay::Static(o) => o.is_done(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Common pager navigation hints rendered on the first line
|
||||
const PAGER_KEY_HINTS: &[(&str, &str)] = &[
|
||||
("↑/↓", "scroll"),
|
||||
("PgUp/PgDn", "page"),
|
||||
("Home/End", "jump"),
|
||||
];
|
||||
|
||||
// Render a single line of key hints from (key, description) pairs.
|
||||
fn render_key_hints(area: Rect, buf: &mut Buffer, pairs: &[(&str, &str)]) {
|
||||
let key_hint_style = Style::default().fg(crate::colors::info());
|
||||
let mut spans: Vec<Span<'static>> = vec![" ".into()];
|
||||
let mut first = true;
|
||||
for (key, desc) in pairs {
|
||||
if !first {
|
||||
spans.push(" ".into());
|
||||
}
|
||||
spans.push(Span::from(key.to_string()).set_style(key_hint_style));
|
||||
spans.push(" ".into());
|
||||
spans.push(Span::from(desc.to_string()));
|
||||
first = false;
|
||||
}
|
||||
Paragraph::new(vec![Line::from(spans).dim()]).render_ref(area, buf);
|
||||
}
|
||||
|
||||
/// Generic widget for rendering a pager view.
|
||||
struct PagerView {
|
||||
lines: Vec<Line<'static>>,
|
||||
scroll_offset: usize,
|
||||
title: String,
|
||||
wrap_cache: Option<WrapCache>,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
fn new(lines: Vec<Line<'static>>, title: String, scroll_offset: usize) -> Self {
|
||||
Self {
|
||||
lines,
|
||||
scroll_offset,
|
||||
title,
|
||||
wrap_cache: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
Clear.render(area, buf);
|
||||
self.render_header(area, buf);
|
||||
let content_area = self.scroll_area(area);
|
||||
self.ensure_wrapped(content_area.width);
|
||||
// Compute page bounds without holding an immutable borrow on cache while mutating self
|
||||
let wrapped_len = self
|
||||
.wrap_cache
|
||||
.as_ref()
|
||||
.map(|c| c.wrapped.len())
|
||||
.unwrap_or(0);
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(wrapped_len.saturating_sub(content_area.height as usize));
|
||||
let start = self.scroll_offset;
|
||||
let end = (start + content_area.height as usize).min(wrapped_len);
|
||||
|
||||
let (wrapped, _src_idx) = self.cached();
|
||||
let page = &wrapped[start..end];
|
||||
self.render_content_page_prepared(content_area, buf, page);
|
||||
self.render_bottom_bar(area, content_area, buf, wrapped);
|
||||
}
|
||||
|
||||
fn render_with_highlight(
|
||||
&mut self,
|
||||
area: Rect,
|
||||
buf: &mut Buffer,
|
||||
highlight: Option<(usize, usize)>,
|
||||
) {
|
||||
self.render_header(area, buf);
|
||||
let content_area = self.scroll_area(area);
|
||||
self.ensure_wrapped(content_area.width);
|
||||
// Compute page bounds first to avoid borrow conflicts
|
||||
let wrapped_len = self
|
||||
.wrap_cache
|
||||
.as_ref()
|
||||
.map(|c| c.wrapped.len())
|
||||
.unwrap_or(0);
|
||||
self.scroll_offset = self
|
||||
.scroll_offset
|
||||
.min(wrapped_len.saturating_sub(content_area.height as usize));
|
||||
let start = self.scroll_offset;
|
||||
let end = (start + content_area.height as usize).min(wrapped_len);
|
||||
|
||||
let (wrapped, src_idx) = self.cached();
|
||||
let page = self.page_with_optional_highlight(wrapped, src_idx, start, end, highlight);
|
||||
self.render_content_page_prepared(content_area, buf, &page);
|
||||
self.render_bottom_bar(area, content_area, buf, wrapped);
|
||||
}
|
||||
|
||||
fn render_header(&self, area: Rect, buf: &mut Buffer) {
|
||||
Span::from("/ ".repeat(area.width as usize / 2))
|
||||
.dim()
|
||||
.render_ref(area, buf);
|
||||
let header = format!("/ {}", self.title);
|
||||
Span::from(header).dim().render_ref(area, buf);
|
||||
}
|
||||
|
||||
// Removed unused render_content_page (replaced by render_content_page_prepared)
|
||||
|
||||
fn render_content_page_prepared(&self, area: Rect, buf: &mut Buffer, page: &[Line<'static>]) {
|
||||
Clear.render(area, buf);
|
||||
Paragraph::new(page.to_vec()).render_ref(area, buf);
|
||||
|
||||
let visible = page.len();
|
||||
if visible < area.height as usize {
|
||||
for i in 0..(area.height as usize - visible) {
|
||||
let add = ((visible + i).min(u16::MAX as usize)) as u16;
|
||||
let y = area.y.saturating_add(add);
|
||||
Span::from("~")
|
||||
.dim()
|
||||
.render_ref(Rect::new(area.x, y, 1, 1), buf);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_bottom_bar(
|
||||
&self,
|
||||
full_area: Rect,
|
||||
content_area: Rect,
|
||||
buf: &mut Buffer,
|
||||
wrapped: &[Line<'static>],
|
||||
) {
|
||||
let area_bottom = full_area.y.saturating_add(full_area.height);
|
||||
if full_area.width == 0 || full_area.height == 0 || area_bottom <= full_area.y {
|
||||
return;
|
||||
}
|
||||
|
||||
let mut sep_y = content_area.bottom();
|
||||
let max_sep_y = area_bottom.saturating_sub(1);
|
||||
if sep_y > max_sep_y {
|
||||
sep_y = max_sep_y;
|
||||
}
|
||||
if sep_y < full_area.y {
|
||||
sep_y = full_area.y;
|
||||
}
|
||||
|
||||
let sep_height = area_bottom.saturating_sub(sep_y).min(1);
|
||||
if sep_height == 0 {
|
||||
return;
|
||||
}
|
||||
let sep_rect = Rect::new(full_area.x, sep_y, full_area.width, sep_height);
|
||||
if sep_rect.width == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
Span::from("─".repeat(sep_rect.width as usize))
|
||||
.dim()
|
||||
.render_ref(sep_rect, buf);
|
||||
|
||||
let percent = if wrapped.is_empty() {
|
||||
100
|
||||
} else {
|
||||
let max_scroll = wrapped.len().saturating_sub(content_area.height as usize);
|
||||
if max_scroll == 0 {
|
||||
100
|
||||
} else {
|
||||
(((self.scroll_offset.min(max_scroll)) as f32 / max_scroll as f32) * 100.0).round()
|
||||
as u8
|
||||
}
|
||||
};
|
||||
let pct_text = format!(" {percent}% ");
|
||||
let pct_w = pct_text.chars().count() as u16;
|
||||
if pct_w < sep_rect.width {
|
||||
let padding = sep_rect
|
||||
.width
|
||||
.saturating_sub(pct_w.saturating_add(1));
|
||||
let pct_x = sep_rect.x.saturating_add(padding);
|
||||
Span::from(pct_text)
|
||||
.dim()
|
||||
.render_ref(Rect::new(pct_x, sep_rect.y, pct_w, 1), buf);
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_key_event(&mut self, _tui: &mut tui::Tui, key_event: KeyEvent) -> Result<()> {
|
||||
match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Up,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(1);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Down,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(1);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::PageUp,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
let area = self.scroll_area(Rect::new(0, 0, w, h));
|
||||
self.scroll_offset = self.scroll_offset.saturating_sub(area.height as usize);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::PageDown | KeyCode::Char(' '),
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
let (w, h) = crossterm::terminal::size().unwrap_or((80, 24));
|
||||
let area = self.scroll_area(Rect::new(0, 0, w, h));
|
||||
self.scroll_offset = self.scroll_offset.saturating_add(area.height as usize);
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::Home,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.scroll_offset = 0;
|
||||
}
|
||||
KeyEvent {
|
||||
code: KeyCode::End,
|
||||
kind: KeyEventKind::Press | KeyEventKind::Repeat,
|
||||
..
|
||||
} => {
|
||||
self.scroll_offset = usize::MAX;
|
||||
}
|
||||
_ => {
|
||||
return Ok(());
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn scroll_area(&self, area: Rect) -> Rect {
|
||||
let mut area = area;
|
||||
area.y = area.y.saturating_add(1);
|
||||
area.height = area.height.saturating_sub(2);
|
||||
area
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
struct WrapCache {
|
||||
width: u16,
|
||||
wrapped: Vec<Line<'static>>,
|
||||
src_idx: Vec<usize>,
|
||||
base_len: usize,
|
||||
}
|
||||
|
||||
impl PagerView {
|
||||
fn ensure_wrapped(&mut self, width: u16) {
|
||||
let width = width.max(1);
|
||||
let needs = match self.wrap_cache {
|
||||
Some(ref c) => c.width != width || c.base_len != self.lines.len(),
|
||||
None => true,
|
||||
};
|
||||
if !needs {
|
||||
return;
|
||||
}
|
||||
let mut wrapped: Vec<Line<'static>> = Vec::new();
|
||||
let mut src_idx: Vec<usize> = Vec::new();
|
||||
for (i, line) in self.lines.iter().enumerate() {
|
||||
let ws = insert_history::word_wrap_lines(std::slice::from_ref(line), width);
|
||||
src_idx.extend(std::iter::repeat_n(i, ws.len()));
|
||||
wrapped.extend(ws);
|
||||
}
|
||||
self.wrap_cache = Some(WrapCache {
|
||||
width,
|
||||
wrapped,
|
||||
src_idx,
|
||||
base_len: self.lines.len(),
|
||||
});
|
||||
}
|
||||
|
||||
fn cached(&self) -> (&[Line<'static>], &[usize]) {
|
||||
if let Some(cache) = self.wrap_cache.as_ref() {
|
||||
(&cache.wrapped, &cache.src_idx)
|
||||
} else {
|
||||
(&[], &[])
|
||||
}
|
||||
}
|
||||
|
||||
fn page_with_optional_highlight<'a>(
|
||||
&self,
|
||||
wrapped: &'a [Line<'static>],
|
||||
src_idx: &[usize],
|
||||
start: usize,
|
||||
end: usize,
|
||||
highlight: Option<(usize, usize)>,
|
||||
) -> std::borrow::Cow<'a, [Line<'static>]> {
|
||||
use ratatui::style::Modifier;
|
||||
let (hi_start, hi_end) = match highlight {
|
||||
Some(r) => r,
|
||||
None => return std::borrow::Cow::Borrowed(&wrapped[start..end]),
|
||||
};
|
||||
let mut out: Vec<Line<'static>> = Vec::with_capacity(end - start);
|
||||
let mut bold_done = false;
|
||||
for (row, src_line) in wrapped.iter().enumerate().skip(start).take(end.saturating_sub(start)) {
|
||||
let mut line = src_line.clone();
|
||||
if let Some(src) = src_idx.get(row).copied() {
|
||||
if src >= hi_start && src < hi_end {
|
||||
for (i, s) in line.spans.iter_mut().enumerate() {
|
||||
s.style.add_modifier |= Modifier::REVERSED;
|
||||
if !bold_done && i == 0 {
|
||||
s.style.add_modifier |= Modifier::BOLD;
|
||||
bold_done = true;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out.push(line);
|
||||
}
|
||||
std::borrow::Cow::Owned(out)
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct TranscriptOverlay {
|
||||
view: PagerView,
|
||||
highlight_range: Option<(usize, usize)>,
|
||||
is_done: bool,
|
||||
esc_hint: &'static str,
|
||||
}
|
||||
|
||||
impl TranscriptOverlay {
|
||||
pub(crate) fn new(transcript_lines: Vec<Line<'static>>, esc_hint: &'static str) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(
|
||||
transcript_lines,
|
||||
"T R A N S C R I P T".to_string(),
|
||||
usize::MAX,
|
||||
),
|
||||
highlight_range: None,
|
||||
is_done: false,
|
||||
esc_hint,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn insert_lines(&mut self, lines: Vec<Line<'static>>) {
|
||||
self.view.lines.extend(lines);
|
||||
self.view.wrap_cache = None;
|
||||
}
|
||||
|
||||
pub(crate) fn set_highlight_range(&mut self, range: Option<(usize, usize)>) {
|
||||
self.highlight_range = range;
|
||||
}
|
||||
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line1 = Rect::new(area.x, area.y, area.width, 1);
|
||||
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
|
||||
render_key_hints(line1, buf, PAGER_KEY_HINTS);
|
||||
let mut pairs: Vec<(&str, &str)> = vec![("q", "quit"), ("Esc", self.esc_hint)];
|
||||
if let Some((start, end)) = self.highlight_range {
|
||||
if end > start {
|
||||
pairs.push(("⏎", "edit message"));
|
||||
}
|
||||
}
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let top_h = area.height.saturating_sub(3);
|
||||
let top = Rect::new(area.x, area.y, area.width, top_h);
|
||||
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
|
||||
self.view
|
||||
.render_with_highlight(top, buf, self.highlight_range);
|
||||
self.render_hints(bottom, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl TranscriptOverlay {
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('t'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.is_done = true;
|
||||
Ok(())
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(|frame| {
|
||||
let area = frame.area();
|
||||
let buf = frame.buffer_mut();
|
||||
self.render(area, buf);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
}
|
||||
pub(crate) fn set_scroll_offset(&mut self, offset: usize) {
|
||||
self.view.scroll_offset = offset;
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) struct StaticOverlay {
|
||||
view: PagerView,
|
||||
is_done: bool,
|
||||
}
|
||||
|
||||
impl StaticOverlay {
|
||||
pub(crate) fn with_title(lines: Vec<Line<'static>>, title: String) -> Self {
|
||||
Self {
|
||||
view: PagerView::new(lines, title, 0),
|
||||
is_done: false,
|
||||
}
|
||||
}
|
||||
|
||||
fn render_hints(&self, area: Rect, buf: &mut Buffer) {
|
||||
let line1 = Rect::new(area.x, area.y, area.width, 1);
|
||||
let line2 = Rect::new(area.x, area.y.saturating_add(1), area.width, 1);
|
||||
render_key_hints(line1, buf, PAGER_KEY_HINTS);
|
||||
let pairs = [("q", "quit")];
|
||||
render_key_hints(line2, buf, &pairs);
|
||||
}
|
||||
|
||||
pub(crate) fn render(&mut self, area: Rect, buf: &mut Buffer) {
|
||||
let top_h = area.height.saturating_sub(3);
|
||||
let top = Rect::new(area.x, area.y, area.width, top_h);
|
||||
let bottom = Rect::new(area.x, area.y + top_h, area.width, 3);
|
||||
self.view.render(top, buf);
|
||||
self.render_hints(bottom, buf);
|
||||
}
|
||||
}
|
||||
|
||||
impl StaticOverlay {
|
||||
pub(crate) fn handle_event(&mut self, tui: &mut tui::Tui, event: TuiEvent) -> Result<()> {
|
||||
match event {
|
||||
TuiEvent::Key(key_event) => match key_event {
|
||||
KeyEvent {
|
||||
code: KeyCode::Char('q'),
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
}
|
||||
| KeyEvent {
|
||||
code: KeyCode::Char('c'),
|
||||
modifiers: crossterm::event::KeyModifiers::CONTROL,
|
||||
kind: KeyEventKind::Press,
|
||||
..
|
||||
} => {
|
||||
self.is_done = true;
|
||||
Ok(())
|
||||
}
|
||||
other => self.view.handle_key_event(tui, other),
|
||||
},
|
||||
TuiEvent::Draw => {
|
||||
tui.draw(|frame| {
|
||||
let area = frame.area();
|
||||
let buf = frame.buffer_mut();
|
||||
self.render(area, buf);
|
||||
})?;
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
}
|
||||
pub(crate) fn is_done(&self) -> bool {
|
||||
self.is_done
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,785 +0,0 @@
|
||||
use std::collections::HashSet;
|
||||
use std::path::Path;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use chrono::DateTime;
|
||||
use chrono::Utc;
|
||||
use code_core::ConversationItem;
|
||||
use code_core::ConversationsPage;
|
||||
use code_core::Cursor;
|
||||
use code_core::RolloutRecorder;
|
||||
use code_core::INTERACTIVE_SESSION_SOURCES;
|
||||
use color_eyre::eyre::Result;
|
||||
use crossterm::event::KeyCode;
|
||||
use crossterm::event::KeyEvent;
|
||||
use crossterm::event::KeyEventKind;
|
||||
use ratatui::layout::Constraint;
|
||||
use ratatui::layout::Layout;
|
||||
use ratatui::layout::Rect;
|
||||
use ratatui::style::Stylize as _;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
use tokio::sync::mpsc;
|
||||
use tokio_stream::StreamExt;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
|
||||
use crate::text_formatting::truncate_text;
|
||||
use crate::tui::FrameRequester;
|
||||
use crate::tui::Tui;
|
||||
use crate::tui::TuiEvent;
|
||||
use code_protocol::models::ContentItem;
|
||||
use code_protocol::models::ResponseItem;
|
||||
use code_protocol::protocol::InputMessageKind;
|
||||
use code_protocol::protocol::USER_MESSAGE_BEGIN;
|
||||
|
||||
const PAGE_SIZE: usize = 25;
|
||||
const LOAD_NEAR_THRESHOLD: usize = 5;
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum ResumeSelection {
|
||||
StartFresh,
|
||||
Resume(PathBuf),
|
||||
Exit,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct PageLoadRequest {
|
||||
code_home: PathBuf,
|
||||
cursor: Option<Cursor>,
|
||||
request_token: usize,
|
||||
search_token: Option<usize>,
|
||||
}
|
||||
|
||||
type PageLoader = Arc<dyn Fn(PageLoadRequest) + Send + Sync>;
|
||||
|
||||
enum BackgroundEvent {
|
||||
PageLoaded {
|
||||
request_token: usize,
|
||||
search_token: Option<usize>,
|
||||
page: std::io::Result<ConversationsPage>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Interactive session picker that lists recorded rollout files with simple
|
||||
/// search and pagination. Shows the first user input as the preview, relative
|
||||
/// time (e.g., "5 seconds ago"), and the absolute path.
|
||||
pub async fn run_resume_picker(tui: &mut Tui, code_home: &Path) -> Result<ResumeSelection> {
|
||||
let alt = AltScreenGuard::enter(tui);
|
||||
let (bg_tx, bg_rx) = mpsc::unbounded_channel();
|
||||
|
||||
let loader_tx = bg_tx.clone();
|
||||
let page_loader: PageLoader = Arc::new(move |request: PageLoadRequest| {
|
||||
let tx = loader_tx.clone();
|
||||
tokio::spawn(async move {
|
||||
let page = RolloutRecorder::list_conversations(
|
||||
&request.code_home,
|
||||
PAGE_SIZE,
|
||||
request.cursor.as_ref(),
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
)
|
||||
.await;
|
||||
let _ = tx.send(BackgroundEvent::PageLoaded {
|
||||
request_token: request.request_token,
|
||||
search_token: request.search_token,
|
||||
page,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
let mut state = PickerState::new(
|
||||
code_home.to_path_buf(),
|
||||
alt.tui.frame_requester(),
|
||||
page_loader,
|
||||
);
|
||||
state.load_initial_page().await?;
|
||||
state.request_frame();
|
||||
|
||||
let mut tui_events = alt.tui.event_stream().fuse();
|
||||
let mut background_events = UnboundedReceiverStream::new(bg_rx).fuse();
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
Some(ev) = tui_events.next() => {
|
||||
match ev {
|
||||
TuiEvent::Key(key) => {
|
||||
if matches!(key.kind, KeyEventKind::Release) {
|
||||
continue;
|
||||
}
|
||||
if let Some(sel) = state.handle_key(key).await? {
|
||||
return Ok(sel);
|
||||
}
|
||||
}
|
||||
TuiEvent::Draw => {
|
||||
if let Ok(size) = alt.tui.terminal.size() {
|
||||
let list_height = size.height.saturating_sub(3) as usize;
|
||||
state.update_view_rows(list_height);
|
||||
state.ensure_minimum_rows_for_view(list_height);
|
||||
}
|
||||
draw_picker(alt.tui, &state)?;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Some(event) = background_events.next() => {
|
||||
state.handle_background_event(event)?;
|
||||
}
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback – treat as cancel/new
|
||||
Ok(ResumeSelection::StartFresh)
|
||||
}
|
||||
|
||||
/// RAII guard that ensures we leave the alt-screen on scope exit.
|
||||
struct AltScreenGuard<'a> {
|
||||
tui: &'a mut Tui,
|
||||
}
|
||||
|
||||
impl<'a> AltScreenGuard<'a> {
|
||||
fn enter(tui: &'a mut Tui) -> Self {
|
||||
let _ = tui.enter_alt_screen();
|
||||
Self { tui }
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for AltScreenGuard<'_> {
|
||||
fn drop(&mut self) {
|
||||
let _ = self.tui.leave_alt_screen();
|
||||
}
|
||||
}
|
||||
|
||||
struct PickerState {
|
||||
code_home: PathBuf,
|
||||
requester: FrameRequester,
|
||||
pagination: PaginationState,
|
||||
all_rows: Vec<Row>,
|
||||
filtered_rows: Vec<Row>,
|
||||
seen_paths: HashSet<PathBuf>,
|
||||
selected: usize,
|
||||
scroll_top: usize,
|
||||
query: String,
|
||||
search_state: SearchState,
|
||||
next_request_token: usize,
|
||||
next_search_token: usize,
|
||||
page_loader: PageLoader,
|
||||
view_rows: Option<usize>,
|
||||
}
|
||||
|
||||
struct PaginationState {
|
||||
next_cursor: Option<Cursor>,
|
||||
num_scanned_files: usize,
|
||||
reached_scan_cap: bool,
|
||||
loading: LoadingState,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum LoadingState {
|
||||
Idle,
|
||||
Pending(PendingLoad),
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
struct PendingLoad {
|
||||
request_token: usize,
|
||||
search_token: Option<usize>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug)]
|
||||
enum SearchState {
|
||||
Idle,
|
||||
Active { token: usize },
|
||||
}
|
||||
|
||||
enum LoadTrigger {
|
||||
Scroll,
|
||||
Search { token: usize },
|
||||
}
|
||||
|
||||
impl LoadingState {
|
||||
fn is_pending(&self) -> bool {
|
||||
matches!(self, LoadingState::Pending(_))
|
||||
}
|
||||
}
|
||||
|
||||
impl SearchState {
|
||||
fn active_token(&self) -> Option<usize> {
|
||||
match self {
|
||||
SearchState::Idle => None,
|
||||
SearchState::Active { token } => Some(*token),
|
||||
}
|
||||
}
|
||||
|
||||
fn is_active(&self) -> bool {
|
||||
self.active_token().is_some()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
struct Row {
|
||||
path: PathBuf,
|
||||
preview: String,
|
||||
ts: Option<DateTime<Utc>>,
|
||||
}
|
||||
|
||||
impl PickerState {
|
||||
fn new(code_home: PathBuf, requester: FrameRequester, page_loader: PageLoader) -> Self {
|
||||
Self {
|
||||
code_home,
|
||||
requester,
|
||||
pagination: PaginationState {
|
||||
next_cursor: None,
|
||||
num_scanned_files: 0,
|
||||
reached_scan_cap: false,
|
||||
loading: LoadingState::Idle,
|
||||
},
|
||||
all_rows: Vec::new(),
|
||||
filtered_rows: Vec::new(),
|
||||
seen_paths: HashSet::new(),
|
||||
selected: 0,
|
||||
scroll_top: 0,
|
||||
query: String::new(),
|
||||
search_state: SearchState::Idle,
|
||||
next_request_token: 0,
|
||||
next_search_token: 0,
|
||||
page_loader,
|
||||
view_rows: None,
|
||||
}
|
||||
}
|
||||
|
||||
fn request_frame(&self) {
|
||||
self.requester.schedule_frame();
|
||||
}
|
||||
|
||||
async fn handle_key(&mut self, key: KeyEvent) -> Result<Option<ResumeSelection>> {
|
||||
match key.code {
|
||||
KeyCode::Esc => return Ok(Some(ResumeSelection::StartFresh)),
|
||||
KeyCode::Char('c')
|
||||
if key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL) =>
|
||||
{
|
||||
return Ok(Some(ResumeSelection::Exit));
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
if let Some(row) = self.filtered_rows.get(self.selected) {
|
||||
return Ok(Some(ResumeSelection::Resume(row.path.clone())));
|
||||
}
|
||||
}
|
||||
KeyCode::Up => {
|
||||
if self.selected > 0 {
|
||||
self.selected -= 1;
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
if self.selected + 1 < self.filtered_rows.len() {
|
||||
self.selected += 1;
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
self.maybe_load_more_for_scroll();
|
||||
self.request_frame();
|
||||
}
|
||||
KeyCode::PageUp => {
|
||||
let step = self.view_rows.unwrap_or(10).max(1);
|
||||
if self.selected > 0 {
|
||||
self.selected = self.selected.saturating_sub(step);
|
||||
self.ensure_selected_visible();
|
||||
self.request_frame();
|
||||
}
|
||||
}
|
||||
KeyCode::PageDown => {
|
||||
if !self.filtered_rows.is_empty() {
|
||||
let step = self.view_rows.unwrap_or(10).max(1);
|
||||
let max_index = self.filtered_rows.len().saturating_sub(1);
|
||||
self.selected = (self.selected + step).min(max_index);
|
||||
self.ensure_selected_visible();
|
||||
self.maybe_load_more_for_scroll();
|
||||
self.request_frame();
|
||||
}
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
let mut new_query = self.query.clone();
|
||||
new_query.pop();
|
||||
self.set_query(new_query);
|
||||
}
|
||||
KeyCode::Char(c) => {
|
||||
// basic text input for search
|
||||
if !key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL)
|
||||
&& !key.modifiers.contains(crossterm::event::KeyModifiers::ALT)
|
||||
{
|
||||
let mut new_query = self.query.clone();
|
||||
new_query.push(c);
|
||||
self.set_query(new_query);
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
async fn load_initial_page(&mut self) -> Result<()> {
|
||||
let page = RolloutRecorder::list_conversations(
|
||||
&self.code_home,
|
||||
PAGE_SIZE,
|
||||
None,
|
||||
INTERACTIVE_SESSION_SOURCES,
|
||||
)
|
||||
.await?;
|
||||
self.reset_pagination();
|
||||
self.all_rows.clear();
|
||||
self.filtered_rows.clear();
|
||||
self.seen_paths.clear();
|
||||
self.search_state = SearchState::Idle;
|
||||
self.selected = 0;
|
||||
self.ingest_page(page);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn handle_background_event(&mut self, event: BackgroundEvent) -> Result<()> {
|
||||
match event {
|
||||
BackgroundEvent::PageLoaded {
|
||||
request_token,
|
||||
search_token,
|
||||
page,
|
||||
} => {
|
||||
let pending = match self.pagination.loading {
|
||||
LoadingState::Pending(pending) => pending,
|
||||
LoadingState::Idle => return Ok(()),
|
||||
};
|
||||
if pending.request_token != request_token {
|
||||
return Ok(());
|
||||
}
|
||||
self.pagination.loading = LoadingState::Idle;
|
||||
let page = page.map_err(color_eyre::Report::from)?;
|
||||
self.ingest_page(page);
|
||||
let completed_token = pending.search_token.or(search_token);
|
||||
self.continue_search_if_token_matches(completed_token);
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn reset_pagination(&mut self) {
|
||||
self.pagination.next_cursor = None;
|
||||
self.pagination.num_scanned_files = 0;
|
||||
self.pagination.reached_scan_cap = false;
|
||||
self.pagination.loading = LoadingState::Idle;
|
||||
}
|
||||
|
||||
fn ingest_page(&mut self, page: ConversationsPage) {
|
||||
if let Some(cursor) = page.next_cursor.clone() {
|
||||
self.pagination.next_cursor = Some(cursor);
|
||||
} else {
|
||||
self.pagination.next_cursor = None;
|
||||
}
|
||||
self.pagination.num_scanned_files = self
|
||||
.pagination
|
||||
.num_scanned_files
|
||||
.saturating_add(page.num_scanned_files);
|
||||
if page.reached_scan_cap {
|
||||
self.pagination.reached_scan_cap = true;
|
||||
}
|
||||
|
||||
let rows = rows_from_items(page.items);
|
||||
for row in rows {
|
||||
if self.seen_paths.insert(row.path.clone()) {
|
||||
self.all_rows.push(row);
|
||||
}
|
||||
}
|
||||
|
||||
self.apply_filter();
|
||||
}
|
||||
|
||||
fn apply_filter(&mut self) {
|
||||
if self.query.is_empty() {
|
||||
self.filtered_rows = self.all_rows.clone();
|
||||
} else {
|
||||
let q = self.query.to_lowercase();
|
||||
self.filtered_rows = self
|
||||
.all_rows
|
||||
.iter()
|
||||
.filter(|r| r.preview.to_lowercase().contains(&q))
|
||||
.cloned()
|
||||
.collect();
|
||||
}
|
||||
if self.selected >= self.filtered_rows.len() {
|
||||
self.selected = self.filtered_rows.len().saturating_sub(1);
|
||||
}
|
||||
if self.filtered_rows.is_empty() {
|
||||
self.scroll_top = 0;
|
||||
}
|
||||
self.ensure_selected_visible();
|
||||
self.request_frame();
|
||||
}
|
||||
|
||||
fn set_query(&mut self, new_query: String) {
|
||||
if self.query == new_query {
|
||||
return;
|
||||
}
|
||||
self.query = new_query;
|
||||
self.selected = 0;
|
||||
self.apply_filter();
|
||||
if self.query.is_empty() {
|
||||
self.search_state = SearchState::Idle;
|
||||
return;
|
||||
}
|
||||
if !self.filtered_rows.is_empty() {
|
||||
self.search_state = SearchState::Idle;
|
||||
return;
|
||||
}
|
||||
if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() {
|
||||
self.search_state = SearchState::Idle;
|
||||
return;
|
||||
}
|
||||
let token = self.allocate_search_token();
|
||||
self.search_state = SearchState::Active { token };
|
||||
self.load_more_if_needed(LoadTrigger::Search { token });
|
||||
}
|
||||
|
||||
fn continue_search_if_needed(&mut self) {
|
||||
let Some(token) = self.search_state.active_token() else {
|
||||
return;
|
||||
};
|
||||
if !self.filtered_rows.is_empty() {
|
||||
self.search_state = SearchState::Idle;
|
||||
return;
|
||||
}
|
||||
if self.pagination.reached_scan_cap || self.pagination.next_cursor.is_none() {
|
||||
self.search_state = SearchState::Idle;
|
||||
return;
|
||||
}
|
||||
self.load_more_if_needed(LoadTrigger::Search { token });
|
||||
}
|
||||
|
||||
fn continue_search_if_token_matches(&mut self, completed_token: Option<usize>) {
|
||||
let Some(active) = self.search_state.active_token() else {
|
||||
return;
|
||||
};
|
||||
if let Some(token) = completed_token
|
||||
&& token != active
|
||||
{
|
||||
return;
|
||||
}
|
||||
self.continue_search_if_needed();
|
||||
}
|
||||
|
||||
fn ensure_selected_visible(&mut self) {
|
||||
if self.filtered_rows.is_empty() {
|
||||
self.scroll_top = 0;
|
||||
return;
|
||||
}
|
||||
let capacity = self.view_rows.unwrap_or(self.filtered_rows.len()).max(1);
|
||||
|
||||
if self.selected < self.scroll_top {
|
||||
self.scroll_top = self.selected;
|
||||
} else {
|
||||
let last_visible = self.scroll_top.saturating_add(capacity - 1);
|
||||
if self.selected > last_visible {
|
||||
self.scroll_top = self.selected.saturating_sub(capacity - 1);
|
||||
}
|
||||
}
|
||||
|
||||
let max_start = self.filtered_rows.len().saturating_sub(capacity);
|
||||
if self.scroll_top > max_start {
|
||||
self.scroll_top = max_start;
|
||||
}
|
||||
}
|
||||
|
||||
fn ensure_minimum_rows_for_view(&mut self, minimum_rows: usize) {
|
||||
if minimum_rows == 0 {
|
||||
return;
|
||||
}
|
||||
if self.filtered_rows.len() >= minimum_rows {
|
||||
return;
|
||||
}
|
||||
if self.pagination.loading.is_pending() || self.pagination.next_cursor.is_none() {
|
||||
return;
|
||||
}
|
||||
if let Some(token) = self.search_state.active_token() {
|
||||
self.load_more_if_needed(LoadTrigger::Search { token });
|
||||
} else {
|
||||
self.load_more_if_needed(LoadTrigger::Scroll);
|
||||
}
|
||||
}
|
||||
|
||||
fn update_view_rows(&mut self, rows: usize) {
|
||||
self.view_rows = if rows == 0 { None } else { Some(rows) };
|
||||
self.ensure_selected_visible();
|
||||
}
|
||||
|
||||
fn maybe_load_more_for_scroll(&mut self) {
|
||||
if self.pagination.loading.is_pending() {
|
||||
return;
|
||||
}
|
||||
if self.pagination.next_cursor.is_none() {
|
||||
return;
|
||||
}
|
||||
if self.filtered_rows.is_empty() {
|
||||
return;
|
||||
}
|
||||
let remaining = self.filtered_rows.len().saturating_sub(self.selected + 1);
|
||||
if remaining <= LOAD_NEAR_THRESHOLD {
|
||||
self.load_more_if_needed(LoadTrigger::Scroll);
|
||||
}
|
||||
}
|
||||
|
||||
fn load_more_if_needed(&mut self, trigger: LoadTrigger) {
|
||||
if self.pagination.loading.is_pending() {
|
||||
return;
|
||||
}
|
||||
let Some(cursor) = self.pagination.next_cursor.clone() else {
|
||||
return;
|
||||
};
|
||||
let request_token = self.allocate_request_token();
|
||||
let search_token = match trigger {
|
||||
LoadTrigger::Scroll => None,
|
||||
LoadTrigger::Search { token } => Some(token),
|
||||
};
|
||||
self.pagination.loading = LoadingState::Pending(PendingLoad {
|
||||
request_token,
|
||||
search_token,
|
||||
});
|
||||
self.request_frame();
|
||||
|
||||
(self.page_loader)(PageLoadRequest {
|
||||
code_home: self.code_home.clone(),
|
||||
cursor: Some(cursor),
|
||||
request_token,
|
||||
search_token,
|
||||
});
|
||||
}
|
||||
|
||||
fn allocate_request_token(&mut self) -> usize {
|
||||
let token = self.next_request_token;
|
||||
self.next_request_token = self.next_request_token.wrapping_add(1);
|
||||
token
|
||||
}
|
||||
|
||||
fn allocate_search_token(&mut self) -> usize {
|
||||
let token = self.next_search_token;
|
||||
self.next_search_token = self.next_search_token.wrapping_add(1);
|
||||
token
|
||||
}
|
||||
}
|
||||
|
||||
fn rows_from_items(items: Vec<ConversationItem>) -> Vec<Row> {
|
||||
items.into_iter().map(|item| head_to_row(&item)).collect()
|
||||
}
|
||||
|
||||
fn head_to_row(item: &ConversationItem) -> Row {
|
||||
let mut ts: Option<DateTime<Utc>> = None;
|
||||
if let Some(first) = item.head.first()
|
||||
&& let Some(t) = first.get("timestamp").and_then(|v| v.as_str())
|
||||
&& let Ok(parsed) = chrono::DateTime::parse_from_rfc3339(t)
|
||||
{
|
||||
ts = Some(parsed.with_timezone(&Utc));
|
||||
}
|
||||
|
||||
let preview = preview_from_head(&item.head)
|
||||
.map(|s| s.trim().to_string())
|
||||
.filter(|s| !s.is_empty())
|
||||
.unwrap_or_else(|| String::from("(no message yet)"));
|
||||
|
||||
Row {
|
||||
path: item.path.clone(),
|
||||
preview,
|
||||
ts,
|
||||
}
|
||||
}
|
||||
|
||||
fn preview_from_head(head: &[serde_json::Value]) -> Option<String> {
|
||||
head.iter()
|
||||
.filter_map(|value| serde_json::from_value::<ResponseItem>(value.clone()).ok())
|
||||
.find_map(|item| match item {
|
||||
ResponseItem::Message { content, .. } => {
|
||||
// Find the actual user message (as opposed to user instructions or ide context)
|
||||
let preview = content
|
||||
.into_iter()
|
||||
.filter_map(|content| match content {
|
||||
ContentItem::InputText { text }
|
||||
if matches!(
|
||||
InputMessageKind::from(("user", text.as_str())),
|
||||
InputMessageKind::Plain
|
||||
) =>
|
||||
{
|
||||
// Strip ide context.
|
||||
let text = match text.find(USER_MESSAGE_BEGIN) {
|
||||
Some(idx) => {
|
||||
text[idx + USER_MESSAGE_BEGIN.len()..].trim().to_string()
|
||||
}
|
||||
None => text,
|
||||
};
|
||||
Some(text)
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<String>();
|
||||
|
||||
if preview.is_empty() {
|
||||
None
|
||||
} else {
|
||||
Some(preview)
|
||||
}
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
fn draw_picker(tui: &mut Tui, state: &PickerState) -> std::io::Result<()> {
|
||||
// Render full-screen overlay
|
||||
let height = tui.terminal.size()?.height;
|
||||
tui.draw(height, |frame| {
|
||||
let area = frame.area();
|
||||
let [header, search, list, hint] = Layout::vertical([
|
||||
Constraint::Length(1),
|
||||
Constraint::Length(1),
|
||||
Constraint::Min(area.height.saturating_sub(3)),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.areas(area);
|
||||
|
||||
// Header
|
||||
frame.render_widget_ref(
|
||||
Line::from(vec!["Resume a previous session".bold().cyan()]),
|
||||
header,
|
||||
);
|
||||
|
||||
// Search line
|
||||
let q = if state.query.is_empty() {
|
||||
"Type to search".dim().to_string()
|
||||
} else {
|
||||
format!("Search: {}", state.query)
|
||||
};
|
||||
frame.render_widget_ref(Line::from(q), search);
|
||||
|
||||
// List
|
||||
render_list(frame, list, state);
|
||||
|
||||
// Hint line
|
||||
let hint_line: Line = vec![
|
||||
"Enter".bold(),
|
||||
" to resume ".into(),
|
||||
"• ".dim(),
|
||||
"Esc".bold(),
|
||||
" to start new ".into(),
|
||||
"• ".dim(),
|
||||
"Ctrl+C".into(),
|
||||
" to quit ".into(),
|
||||
"• ".dim(),
|
||||
"↑/↓".into(),
|
||||
" to browse".dim(),
|
||||
]
|
||||
.into();
|
||||
frame.render_widget_ref(hint_line, hint);
|
||||
})
|
||||
}
|
||||
|
||||
fn render_list(frame: &mut crate::custom_terminal::Frame, area: Rect, state: &PickerState) {
|
||||
if area.height == 0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let rows = &state.filtered_rows;
|
||||
if rows.is_empty() {
|
||||
let message = render_empty_state_line(state);
|
||||
frame.render_widget_ref(message, area);
|
||||
return;
|
||||
}
|
||||
|
||||
let capacity = area.height as usize;
|
||||
let start = state.scroll_top.min(rows.len().saturating_sub(1));
|
||||
let end = rows.len().min(start + capacity);
|
||||
let mut y = area.y;
|
||||
|
||||
for (idx, row) in rows[start..end].iter().enumerate() {
|
||||
let is_sel = start + idx == state.selected;
|
||||
let marker = if is_sel { "> ".bold() } else { " ".into() };
|
||||
let ts = row
|
||||
.ts
|
||||
.map(human_time_ago)
|
||||
.unwrap_or_else(|| "".to_string())
|
||||
.dim();
|
||||
let max_cols = area.width.saturating_sub(6) as usize;
|
||||
let preview = truncate_text(&row.preview, max_cols);
|
||||
|
||||
let line: Line = vec![marker, ts, " ".into(), preview.into()].into();
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
frame.render_widget_ref(line, rect);
|
||||
y = y.saturating_add(1);
|
||||
}
|
||||
|
||||
if state.pagination.loading.is_pending() && y < area.y.saturating_add(area.height) {
|
||||
let loading_line: Line = vec![" ".into(), "Loading older sessions…".italic().dim()].into();
|
||||
let rect = Rect::new(area.x, y, area.width, 1);
|
||||
frame.render_widget_ref(loading_line, rect);
|
||||
}
|
||||
}
|
||||
|
||||
fn render_empty_state_line(state: &PickerState) -> Line<'static> {
|
||||
if !state.query.is_empty() {
|
||||
if state.search_state.is_active()
|
||||
|| (state.pagination.loading.is_pending() && state.pagination.next_cursor.is_some())
|
||||
{
|
||||
return vec!["Searching…".italic().dim()].into();
|
||||
}
|
||||
if state.pagination.reached_scan_cap {
|
||||
let msg = format!(
|
||||
"Search scanned first {} sessions; more may exist",
|
||||
state.pagination.num_scanned_files
|
||||
);
|
||||
return vec![Span::from(msg).italic().dim()].into();
|
||||
}
|
||||
return vec!["No results for your search".italic().dim()].into();
|
||||
}
|
||||
|
||||
if state.all_rows.is_empty() && state.pagination.num_scanned_files == 0 {
|
||||
return vec!["No sessions yet".italic().dim()].into();
|
||||
}
|
||||
|
||||
if state.pagination.loading.is_pending() {
|
||||
return vec!["Loading older sessions…".italic().dim()].into();
|
||||
}
|
||||
|
||||
vec!["No sessions yet".italic().dim()].into()
|
||||
}
|
||||
|
||||
fn human_time_ago(ts: DateTime<Utc>) -> String {
|
||||
let now = Utc::now();
|
||||
let delta = now - ts;
|
||||
let secs = delta.num_seconds();
|
||||
if secs < 60 {
|
||||
let n = secs.max(0);
|
||||
if n == 1 {
|
||||
format!("{n} second ago")
|
||||
} else {
|
||||
format!("{n} seconds ago")
|
||||
}
|
||||
} else if secs < 60 * 60 {
|
||||
let m = secs / 60;
|
||||
if m == 1 {
|
||||
format!("{m} minute ago")
|
||||
} else {
|
||||
format!("{m} minutes ago")
|
||||
}
|
||||
} else if secs < 60 * 60 * 24 {
|
||||
let h = secs / 3600;
|
||||
if h == 1 {
|
||||
format!("{h} hour ago")
|
||||
} else {
|
||||
format!("{h} hours ago")
|
||||
}
|
||||
} else {
|
||||
let d = secs / (60 * 60 * 24);
|
||||
if d == 1 {
|
||||
format!("{d} day ago")
|
||||
} else {
|
||||
format!("{d} days ago")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ S T A T I C / / / / / / / / / / / / / "
|
||||
"one "
|
||||
"two "
|
||||
"three "
|
||||
"~ "
|
||||
"~ "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ scroll PgUp/PgDn page Home/End "
|
||||
" q quit "
|
||||
" "
|
||||
-16
@@ -1,16 +0,0 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
assertion_line: 721
|
||||
expression: snapshot
|
||||
---
|
||||
/ T R A N S C R I P T / / / / / / / / / / / / / / / / / / / / / / / / / / / / /
|
||||
• Proposed Change foo.txt (+2 -0)
|
||||
1 +hello
|
||||
2 +world
|
||||
|
||||
• Change Approved foo.txt (+2 -0)
|
||||
|
||||
✓ Patch applied
|
||||
─────────────────────────────────────────────────────────────────────────── 0% ─
|
||||
↑/↓ scroll PgUp/PgDn page Home/End jump
|
||||
q quit Esc undo timeline
|
||||
-14
@@ -1,14 +0,0 @@
|
||||
---
|
||||
source: tui/src/pager_overlay.rs
|
||||
expression: term.backend()
|
||||
---
|
||||
"/ T R A N S C R I P T / / / / / / / / / "
|
||||
"alpha "
|
||||
" "
|
||||
"beta "
|
||||
" "
|
||||
"gamma "
|
||||
"───────────────────────────────── 100% ─"
|
||||
" ↑/↓ scroll PgUp/PgDn page Home/End "
|
||||
" q quit Esc undo timeline "
|
||||
" "
|
||||
Reference in New Issue
Block a user