WARNING: THIS SITE IS A MIRROR OF GITHUB.COM / IT CANNOT LOGIN OR REGISTER ACCOUNTS / THE CONTENTS ARE PROVIDED AS-IS / THIS SITE ASSUMES NO RESPONSIBILITY FOR ANY DISPLAYED CONTENT OR LINKS / IF YOU FOUND SOMETHING MAY NOT GOOD FOR EVERYONE, CONTACT ADMIN AT ilovescratch@foxmail.com
Skip to content

Commit 700a059

Browse files
committed
feat(tui2): add inline transcript find
Add an in-viewport find experience for the TUI2 transcript (the scrollable region above the composer), without introducing a persistent search bar or scroll indicator. UX: - Ctrl-F opens a 1-row "/ " prompt above the composer and updates highlights live as you type - Ctrl-G jumps to the next match without closing the prompt, and keeps working after the prompt closes while the query is still active - Esc closes the prompt but keeps the active query/highlights; Esc again clears the search - Enter closes the prompt and jumps to the selected match Implementation: - Add a dedicated transcript_find module to own query/edit state, smart-case matching over flattened transcript lines, stable jump anchoring (cell + line-in-cell), and per-line highlight rendering - Keep app.rs integration additive via small delegation calls from the key handler and render loop - Plumb find visibility to the footer so shortcuts show Ctrl-G next match only while the find prompt is visible Docs/tests: - Add tui2/docs/transcript_find.md documenting current behavior vs the ideal/perfect end state and explicitly calling out deferred work - Stabilize VT100-based rendering tests by forcing color output and emitting crossterm fg/bg colors directly in insert-history output
1 parent 2828549 commit 700a059

File tree

10 files changed

+883
-47
lines changed

10 files changed

+883
-47
lines changed
Lines changed: 246 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,246 @@
1+
# Transcript Find (Inline Viewport)
2+
3+
This document describes the design for “find in transcript” in the **TUI2 inline viewport** (the
4+
main transcript region above the composer), not the full-screen transcript overlay.
5+
6+
The goal is to provide fast, low-friction navigation through the in-memory transcript while keeping
7+
the UI predictable and the implementation easy to review/maintain.
8+
9+
---
10+
11+
## Goals
12+
13+
- **Search the inline viewport content**, derived from the same flattened transcript lines used for
14+
scrolling/selection, so search results track what the user sees.
15+
- **Ephemeral UI**: no always-on search bar and no scroll bar in this iteration.
16+
- **Fast navigation**:
17+
- highlight all matches
18+
- jump to the next match repeatedly without reopening the prompt
19+
- **Stable anchoring**: jumping should land on stable content anchors (cell + line), not raw screen
20+
rows.
21+
- **Reviewable architecture**: keep `app.rs` changes small by placing feature logic in a dedicated
22+
module and calling it from the render loop and key handler.
23+
24+
---
25+
26+
## Current Implementation (What We Have Today)
27+
28+
This section documents the current state so it’s easy to compare against the “ideal/perfect” end
29+
state discussed in review.
30+
31+
### UI
32+
33+
- When active, a single prompt row is rendered **above the composer**:
34+
- `"/ query current/total"`
35+
- Matches are highlighted in the transcript:
36+
- all matches: underlined
37+
- current match: reversed + bold + underlined
38+
- The prompt is **not persistent**: it only appears while editing.
39+
40+
### Keys
41+
42+
- `Ctrl-F`: open the find prompt and start editing the query.
43+
- While editing:
44+
- type to edit the query (highlights update as you type)
45+
- `Backspace`: delete one character
46+
- `Ctrl-U`: clear the query
47+
- `Enter`: close the prompt and jump to a match (if any)
48+
- `Esc`: close the prompt without clearing the query (highlights remain)
49+
- `Ctrl-G`: jump to next match.
50+
- Works while editing (prompt stays open).
51+
- Works even after the prompt is closed, as long as the query is still active.
52+
- `Esc` (when not editing and a query is active): clears the search/highlights.
53+
54+
### Footer hints
55+
56+
- When the find prompt is visible, the footer shows `Ctrl-G next match`:
57+
- in the shortcut summary line
58+
- and in the `?` shortcut overlay
59+
60+
### Implementation layout
61+
62+
- Core logic lives in `tui2/src/transcript_find.rs`:
63+
- key handling
64+
- match computation/caching
65+
- jump selection
66+
- per-line rendering helper (`render_line`) and prompt rendering helper (`render_prompt_line`)
67+
- `tui2/src/app.rs` is kept mostly additive by delegating:
68+
- early key handling delegation in `App::handle_key_event`
69+
- per-frame recompute/jump hook after transcript flattening
70+
- per-row render hook for match highlighting
71+
- prompt + cursor positioning while editing
72+
- Footer hint integration is wired via `set_transcript_ui_state(..., find_visible)` through:
73+
- `tui2/src/chatwidget.rs`
74+
- `tui2/src/bottom_pane/mod.rs`
75+
- `tui2/src/bottom_pane/chat_composer.rs`
76+
- `tui2/src/bottom_pane/footer.rs`
77+
78+
---
79+
80+
## UX and Keybindings
81+
82+
### Entering search
83+
84+
- `Ctrl-F` opens the find prompt on the line immediately above the composer.
85+
- While the prompt is open, typed characters update the query and immediately update highlights.
86+
87+
### Navigating results
88+
89+
- `Ctrl-G` jumps to the next match.
90+
- Works while the prompt is open.
91+
- Also works after the prompt is closed as long as a non-empty query is still active (so users can
92+
“keep stepping” through matches).
93+
94+
### Exiting / clearing
95+
96+
- `Esc` closes the prompt without clearing the active query (and therefore keeps highlights).
97+
- `Esc` again (when not editing and a query is active) clears the search/highlights.
98+
99+
### Footer hints
100+
101+
When the find prompt is visible, we surface the relevant navigation key (`Ctrl-G`) in:
102+
103+
- the shortcut summary line (the default footer mode)
104+
- the “?” shortcut overlay
105+
106+
This keeps the prompt itself visually minimal.
107+
108+
---
109+
110+
## Data Model: Search Over Flattened Lines
111+
112+
Search operates over the same representation as scrolling and selection:
113+
114+
1. Cells are flattened into a list of `Line<'static>` plus parallel `TranscriptLineMeta` entries
115+
(see `tui2/src/tui/scrolling.rs` and `tui2/docs/tui_viewport_and_history.md`).
116+
2. The find module searches **plain text** extracted from each flattened line (by concatenating its
117+
spans’ contents).
118+
3. Each match stores:
119+
- `line_index` (index into flattened lines)
120+
- `range` (byte range within the flattened line’s plain text)
121+
- `anchor` derived from `TranscriptLineMeta::CellLine { cell_index, line_in_cell }`
122+
123+
The anchor is used to update `TranscriptScroll` when jumping so the viewport lands on stable content
124+
even if the transcript grows.
125+
126+
---
127+
128+
## Matching Semantics
129+
130+
### Smart-case
131+
132+
The search is “smart-case”:
133+
134+
- If the query contains any ASCII uppercase, the match is case-sensitive.
135+
- Otherwise, both haystack and needle are matched in ASCII-lowercased form.
136+
137+
This avoids expensive Unicode case folding and keeps behavior predictable in terminals.
138+
139+
---
140+
141+
## Rendering
142+
143+
### Highlights
144+
145+
- All matches are highlighted (currently: underlined).
146+
- The “current match” is emphasized more strongly (currently: reversed + bold + underlined).
147+
148+
Highlighting is applied at render time for each visible line by splitting spans into segments and
149+
patching styles for the match ranges.
150+
151+
### Prompt line
152+
153+
While editing, the line directly above the composer shows:
154+
155+
`/ query current/total`
156+
157+
It is rendered inside the transcript viewport area (not as a persistent UI element), and the cursor
158+
is moved into this line while editing.
159+
160+
---
161+
162+
## Performance / Caching
163+
164+
Recomputing matches happens only when needed. The search module caches based on:
165+
166+
- transcript width (wrapping changes can change the flattened line list)
167+
- number of flattened lines (transcript growth)
168+
169+
This keeps the work proportional to actual content changes rather than every frame.
170+
171+
---
172+
173+
## Code Layout (Additive, Review-Friendly)
174+
175+
The implementation is structured so `app.rs` only delegates:
176+
177+
- `tui2/src/transcript_find.rs` owns:
178+
- query/edit state
179+
- match computation and caching
180+
- key handling for find-related shortcuts
181+
- rendering helpers for highlighted lines and the prompt line
182+
- producing a scroll anchor when a jump is requested
183+
184+
`app.rs` integration points are intentionally small:
185+
186+
- **Key handling**: early delegation to `TranscriptFind::handle_key_event`.
187+
- **Render**:
188+
- call `TranscriptFind::on_render` after building flattened lines to apply pending jumps
189+
- call `TranscriptFind::render_line` per visible row
190+
- render `render_prompt_line` when active and set cursor with `cursor_position`
191+
- **Footer**:
192+
- `set_transcript_ui_state(..., find_visible)` so the footer can show find-related hints only when
193+
the prompt is visible.
194+
195+
---
196+
197+
## Comparison to the “Ideal” End State
198+
199+
### Ideal UX (what “perfect” looks like)
200+
201+
- **Ephemeral, minimal UI**: no always-on search bar, and no scroll bar for this feature.
202+
- **Fast entry**: `Ctrl-F` opens a single prompt row above the composer.
203+
- **Live feedback**: highlights update as you type, and the prompt shows `current/total`.
204+
- **Repeat navigation without closing**: `Ctrl-G` jumps to the next match while the prompt stays
205+
open, and continues to work after the prompt closes as long as the query is active.
206+
- **Predictable exit semantics**:
207+
- `Enter`: accept query, close prompt, and jump (if any matches)
208+
- `Esc`: close the prompt but keep the query/highlights
209+
- `Esc` again (with an active query): clear the query/highlights
210+
- **Stable jumping**: navigation targets stable transcript anchors (cell + line-in-cell), so jumping
211+
behaves well as the transcript grows.
212+
- **Discoverability without clutter**: when the prompt is visible, the footer/shortcuts surface the
213+
navigation key (`Ctrl-G`) so the prompt itself stays tight.
214+
- **Future marker integration**: if/when a scroll indicator is introduced, match markers integrate
215+
with it (faint ticks for match lines, stronger marker for the current match).
216+
217+
### Already aligned with the ideal
218+
219+
- Ephemeral prompt (no always-on bar).
220+
- Live highlighting while typing.
221+
- `Ctrl-G` repeat navigation without reopening the prompt (including while editing).
222+
- Stable jump anchoring via `(cell_index, line_in_cell)` metadata.
223+
- Footer hints (`Ctrl-G next match`) shown only while the prompt is visible.
224+
- Minimal, review-friendly integration points in `app.rs` via `tui2/src/transcript_find.rs`.
225+
226+
### Not implemented yet (intentional deferrals)
227+
228+
- Prev match (e.g. `Ctrl-Shift-G`).
229+
- “Contextual landing” when jumping (e.g. padding/centering so the match isn’t pinned to the top).
230+
- Match markers integrated with a future scroll indicator.
231+
232+
### Known limitations / trade-offs in the current version
233+
234+
- Matching is ASCII smart-case (no full Unicode case folding).
235+
- Match ranges are byte ranges in the flattened plain text. This is fine for styling spans by byte
236+
slicing, but any future “column-precise” behaviors should be careful with multi-byte characters.
237+
238+
---
239+
240+
## Future Work (Not Implemented Here)
241+
242+
- **Prev match**: add `Ctrl-Shift-G` for previous match if desired.
243+
- **Marker integration**: if/when a scroll indicator is added, include match markers derived from
244+
match line indices (faint ticks) and a stronger marker for the current match.
245+
- **Contextual jump placement**: center the current match (or provide padding above) rather than
246+
placing it at the exact top row when jumping.

codex-rs/tui2/src/app.rs

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ use crate::render::highlight::highlight_bash_to_lines;
1818
use crate::render::renderable::Renderable;
1919
use crate::resume_picker::ResumeSelection;
2020
use crate::transcript_copy::TranscriptCopyUi;
21+
use crate::transcript_find::TranscriptFind;
2122
use crate::transcript_selection::TRANSCRIPT_GUTTER_COLS;
2223
use crate::transcript_selection::TranscriptSelection;
2324
use crate::transcript_selection::TranscriptSelectionPoint;
@@ -336,6 +337,7 @@ pub(crate) struct App {
336337
transcript_view_top: usize,
337338
transcript_total_lines: usize,
338339
transcript_copy_ui: TranscriptCopyUi,
340+
transcript_find: TranscriptFind,
339341

340342
// Pager overlay state (Transcript or Static like Diff)
341343
pub(crate) overlay: Option<Overlay>,
@@ -504,6 +506,7 @@ impl App {
504506
transcript_view_top: 0,
505507
transcript_total_lines: 0,
506508
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(copy_selection_shortcut),
509+
transcript_find: TranscriptFind::default(),
507510
overlay: None,
508511
deferred_history_lines: Vec::new(),
509512
has_emitted_history_lines: false,
@@ -651,7 +654,11 @@ impl App {
651654
frame.buffer,
652655
);
653656
}
654-
if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
657+
if let Some((x, y)) =
658+
self.transcript_find.cursor_position(frame.area(), chat_top)
659+
{
660+
frame.set_cursor_position((x, y));
661+
} else if let Some((x, y)) = self.chat_widget.cursor_pos(chat_area) {
655662
frame.set_cursor_position((x, y));
656663
}
657664
})?;
@@ -674,6 +681,7 @@ impl App {
674681
selection_active,
675682
scroll_position,
676683
self.copy_selection_key(),
684+
self.transcript_find.is_visible(),
677685
);
678686
}
679687
}
@@ -728,6 +736,18 @@ impl App {
728736
return area.y;
729737
}
730738

739+
if let Some((cell_index, line_in_cell)) = self.transcript_find.on_render(
740+
&lines,
741+
&line_meta,
742+
transcript_area.width,
743+
self.transcript_view_top,
744+
) {
745+
self.transcript_scroll = TranscriptScroll::Scrolled {
746+
cell_index,
747+
line_in_cell,
748+
};
749+
}
750+
731751
let is_user_cell: Vec<bool> = cells
732752
.iter()
733753
.map(|c| c.as_any().is::<UserHistoryCell>())
@@ -818,7 +838,10 @@ impl App {
818838
}
819839
}
820840

821-
wrapped[line_index].render_ref(row_area, frame.buffer);
841+
let line = self
842+
.transcript_find
843+
.render_line(line_index, &wrapped[line_index]);
844+
line.render_ref(row_area, frame.buffer);
822845
}
823846

824847
self.apply_transcript_selection(transcript_area, frame.buffer);
@@ -838,6 +861,19 @@ impl App {
838861
} else {
839862
self.transcript_copy_ui.clear_affordance();
840863
}
864+
865+
if let Some(prompt_line) = self.transcript_find.render_prompt_line()
866+
&& chat_top > area.y
867+
{
868+
let prompt_area = Rect {
869+
x: area.x,
870+
y: chat_top.saturating_sub(1),
871+
width: area.width,
872+
height: 1,
873+
};
874+
Clear.render_ref(prompt_area, frame.buffer);
875+
Paragraph::new(prompt_line).render_ref(prompt_area, frame.buffer);
876+
}
841877
chat_top
842878
}
843879

@@ -1476,6 +1512,7 @@ impl App {
14761512
};
14771513
self.chat_widget = ChatWidget::new(init, self.server.clone());
14781514
self.current_model = model_family.get_model_slug().to_string();
1515+
self.transcript_find.clear();
14791516
if let Some(summary) = summary {
14801517
let mut lines: Vec<Line<'static>> = vec![summary.usage_line.clone().into()];
14811518
if let Some(command) = summary.resume_command {
@@ -1564,6 +1601,7 @@ impl App {
15641601
tui.frame_requester().schedule_frame();
15651602
}
15661603
self.transcript_cells.push(cell.clone());
1604+
self.transcript_find.note_lines_changed();
15671605
let mut display = cell.display_lines(tui.terminal.last_known_screen_size.width);
15681606
if !display.is_empty() {
15691607
// Only insert a separating blank line for new cells that are not
@@ -2011,6 +2049,11 @@ impl App {
20112049
}
20122050

20132051
async fn handle_key_event(&mut self, tui: &mut tui::Tui, key_event: KeyEvent) {
2052+
if self.transcript_find.handle_key_event(&key_event) {
2053+
tui.frame_requester().schedule_frame();
2054+
return;
2055+
}
2056+
20142057
match key_event {
20152058
KeyEvent {
20162059
code: KeyCode::Char('t'),
@@ -2234,6 +2277,7 @@ mod tests {
22342277
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
22352278
CopySelectionShortcut::CtrlShiftC,
22362279
),
2280+
transcript_find: TranscriptFind::default(),
22372281
overlay: None,
22382282
deferred_history_lines: Vec::new(),
22392283
has_emitted_history_lines: false,
@@ -2283,6 +2327,7 @@ mod tests {
22832327
transcript_copy_ui: TranscriptCopyUi::new_with_shortcut(
22842328
CopySelectionShortcut::CtrlShiftC,
22852329
),
2330+
transcript_find: TranscriptFind::default(),
22862331
overlay: None,
22872332
deferred_history_lines: Vec::new(),
22882333
has_emitted_history_lines: false,

0 commit comments

Comments
 (0)