diff --git a/src/commands/browse/help.rs b/src/commands/browse/help.rs index d2d9a107..ebf5cbbe 100644 --- a/src/commands/browse/help.rs +++ b/src/commands/browse/help.rs @@ -9,8 +9,9 @@ use crate::tui::content::view::ContentViewMode; use crate::tui::terminal::suspend; use crate::tui::workspace::{ can_open_dialogue_vim, selected_index, workspace_content_io_texts, workspace_content_text, - workspace_layout, ContentIoFocus, ContentIoFrame, ContentScrolls, WorkspaceDialogue, - WorkspaceFocus, WorkspaceHelpAction, WorkspacePickedContent, WorkspaceSession, WorkspaceSource, + workspace_layout, ContentIoFocus, ContentIoFrame, ContentScrolls, ExpandedBlocks, + WorkspaceDialogue, WorkspaceFocus, WorkspaceHelpAction, WorkspacePickedContent, + WorkspaceSession, WorkspaceSource, }; use sivtr_core::record::WorkAt; @@ -51,6 +52,7 @@ pub(super) fn apply_workspace_help_action( content_scrolls: &mut ContentScrolls, content_io_focus: &mut ContentIoFocus, content_mode: &mut ContentViewMode, + expanded: &ExpandedBlocks, content_input_lines: usize, content_output_lines: usize, show_help: &mut bool, @@ -252,6 +254,7 @@ pub(super) fn apply_workspace_help_action( dialogue_idx, *content_mode, content_at, + expanded, ); let frame = ContentIoFrame::build(layout.content, io, *content_mode, *content_io_focus); let active = frame.active(*content_io_focus, content_scrolls); diff --git a/src/commands/browse/nav.rs b/src/commands/browse/nav.rs index 388de090..c11e60c9 100644 --- a/src/commands/browse/nav.rs +++ b/src/commands/browse/nav.rs @@ -162,9 +162,15 @@ pub(super) fn move_workspace_cursor_down( } } -pub(super) fn row_list_index(area: ratatui::layout::Rect, row: u16, len: usize) -> Option { +pub(super) fn row_list_index( + area: ratatui::layout::Rect, + row: u16, + len: usize, + offset: usize, +) -> Option { let row = row.checked_sub(area.y.saturating_add(1))? as usize; - (row < len).then_some(row) + let index = row.saturating_add(offset); + (index < len).then_some(index) } pub(super) fn source_list_index( @@ -173,10 +179,11 @@ pub(super) fn source_list_index( row: u16, sources: &[WorkspaceSource], vertical: bool, + offset: usize, ) -> Option { if vertical { // List panel: one source per row (same as sessions/dialogues). - return row_list_index(area, row, sources.len()); + return row_list_index(area, row, sources.len(), offset); } // Compact strip: single content row, labels laid out left→right. if row != area.y.saturating_add(1) @@ -210,3 +217,23 @@ pub(super) fn reset_workspace_dialogue_state( selected_dialogues.resize(dialogue_count, false); *range_anchor = None; } + +#[cfg(test)] +mod tests { + use super::row_list_index; + use ratatui::layout::Rect; + + #[test] + fn row_list_index_includes_scroll_offset() { + let area = Rect::new(0, 0, 30, 10); + // Row 1 is the first content row below the panel title. + assert_eq!(row_list_index(area, 1, 100, 0), Some(0)); + // A scrolled list maps the clicked row onto the offset index. + assert_eq!(row_list_index(area, 1, 100, 50), Some(50)); + assert_eq!(row_list_index(area, 4, 100, 50), Some(53)); + // Indices beyond the list end are rejected. + assert_eq!(row_list_index(area, 4, 100, 98), None); + // The title row is not a selectable row. + assert_eq!(row_list_index(area, 0, 100, 0), None); + } +} diff --git a/src/commands/browse/panes.rs b/src/commands/browse/panes.rs index 0c3dbf8f..7728ee54 100644 --- a/src/commands/browse/panes.rs +++ b/src/commands/browse/panes.rs @@ -10,8 +10,8 @@ use crate::pane::{Pane, PaneInput, SlidingPane, WindowRow}; use crate::tui::content::view::ContentViewMode; use crate::tui::workspace::{ - workspace_content_io_texts, ContentIoFocus, ContentIoFrame, ContentIoTexts, WorkspaceDialogue, - WorkspaceSession, WorkspaceSource, + workspace_content_io_texts, ContentIoFocus, ContentIoFrame, ContentIoTexts, ExpandedBlocks, + WorkspaceDialogue, WorkspaceSession, WorkspaceSource, }; use sivtr_core::ai::AgentSelection; use sivtr_core::record::{WorkAt, WorkRecord, WorkRef}; @@ -419,6 +419,7 @@ pub struct ContentCtx<'a> { pub target: Option, pub area: ratatui::layout::Rect, pub io_focus: ContentIoFocus, + pub expanded: &'a ExpandedBlocks, } /// Tracks layout line counts for Input / Output halves separately. @@ -444,6 +445,7 @@ impl ContentPane { ctx.highlighted_idx, ctx.mode, ctx.target, + ctx.expanded, ); let frame = ContentIoFrame::build(ctx.area, texts, ctx.mode, ctx.io_focus); self.input_lines = frame.input_lines; diff --git a/src/commands/browse/picker.rs b/src/commands/browse/picker.rs index 6d364f0f..43966150 100644 --- a/src/commands/browse/picker.rs +++ b/src/commands/browse/picker.rs @@ -6,7 +6,10 @@ use ratatui::widgets::ListState; use std::collections::HashSet; use std::path::PathBuf; -use crate::tui::content::view::{content_link_at, ContentViewMode}; +use crate::tui::content::view::{ + content_link_at, content_position_at, content_row_in_text, content_structure_block_at, + ContentViewMode, +}; use crate::tui::search::{ workspace_search_fingerprint, workspace_search_has_query, workspace_search_scope, WorkspaceSearchIndex, WorkspaceSearchOutput, @@ -15,8 +18,8 @@ use crate::tui::terminal::read_interaction; use crate::tui::workspace::{ help_action_for_key, panel_inner_rows, render_workspace, search_match_half, selected_index, workspace_help_entries, workspace_hit_test, workspace_layout, ContentIoFocus, ContentIoFrame, - ContentScrolls, WorkspaceDialogue, WorkspaceFocus, WorkspacePickedContent, WorkspaceSearchView, - WorkspaceSession, WorkspaceSource, WorkspaceView, + ContentScrolls, ExpandedBlocks, WorkspaceDialogue, WorkspaceFocus, WorkspacePickedContent, + WorkspaceSearchView, WorkspaceSession, WorkspaceSource, WorkspaceView, }; use super::content::{ @@ -80,6 +83,13 @@ pub(crate) fn run( let mut content_scrolls = ContentScrolls::default(); let mut content_io_focus = ContentIoFocus::Input; let mut content_mode = ContentViewMode::Reading; + let mut expanded_blocks = ExpandedBlocks::default(); + let mut expanded_key = None; + // Block under a pending click; toggled on mouse release unless a drag + // turned the gesture into a text selection. + let mut pending_block_toggle: Option<(ContentIoFocus, usize)> = None; + // Last clicked structure block, highlighted like a list row. + let mut active_block: Option<(ContentIoFocus, usize)> = None; let mut show_help = false; let mut show_search = false; let mut search_query = String::new(); @@ -324,6 +334,23 @@ pub(crate) fn run( if let Some((half, _)) = pending_half { content_io_focus = half; } + // Expansion indices are per-dialogue; reset when the shown + // dialogue's identity, target, or selection changes. + let dialogue_identity = dialogues + .get(dialogue_idx) + .map(|dialogue| (dialogue.source.clone(), dialogue.work_ref.clone())); + let expand_key = ( + dialogue_identity, + dialogue_idx, + active_content_at, + selected_dialogues.clone(), + ); + if expanded_key.as_ref() != Some(&expand_key) { + expanded_blocks.clear(); + pending_block_toggle = None; + active_block = None; + expanded_key = Some(expand_key); + } content_frame = ContentIoFrame::build( layout.content, content_pane.ensure(ContentCtx { @@ -334,6 +361,7 @@ pub(crate) fn run( target: active_content_at, area: layout.content, io_focus: content_io_focus, + expanded: &expanded_blocks, }), content_mode, content_io_focus, @@ -403,6 +431,7 @@ pub(crate) fn run( fullscreen, content_selection: visual_select_mode .map(|mode: VisualSelectMode| mode.selection), + content_active_block: active_block, content_frame: &content_frame, }, ) @@ -650,6 +679,7 @@ pub(crate) fn run( &mut content_scrolls, &mut content_io_focus, &mut content_mode, + &expanded_blocks, content_pane.line_count(ContentIoFocus::Input), content_pane.line_count(ContentIoFocus::Output), &mut show_help, @@ -763,6 +793,7 @@ pub(crate) fn run( &mut content_scrolls, &mut content_io_focus, &mut content_mode, + &expanded_blocks, content_pane.line_count(ContentIoFocus::Input), content_pane.line_count(ContentIoFocus::Output), &mut show_help, @@ -857,6 +888,45 @@ pub(crate) fn run( let _ = open_link_target(&target); continue; } + // Read mode: clicking a structure tag expands/collapses + // that block (raw mode always shows full blocks). + if content_mode == ContentViewMode::Reading { + if let Some(position) = content_position_at( + active.area, + active.text, + *active.scroll, + content_mode, + mouse.column, + mouse.row, + ) { + // Clicks below the last rendered line clamp + // to it in `content_position_at`; ignore + // them instead of toggling the last block. + if !content_row_in_text( + active.area, + active.text, + content_mode, + *active.scroll, + mouse.row, + ) { + continue; + } + // Record the block under the click and toggle + // it on release, so a drag still selects text + // instead of collapsing the block. + pending_block_toggle = content_structure_block_at( + active.area, + active.text, + content_mode, + position.line, + ) + .map(|block| (half, block)); + } + } + } + // A drag selects text and cancels the pending block toggle. + if matches!(mouse.kind, MouseEventKind::Drag(MouseButton::Left)) { + pending_block_toggle = None; } if handle_content_mouse_select( &mut visual_select_mode, @@ -875,6 +945,15 @@ pub(crate) fn run( if visual_select_mode.is_some() { set_focus(&mut focus, &mut fullscreen, WorkspaceFocus::Content); } + // Pure click (no drag): release toggles the block + // and marks it as the active highlight. + if matches!(mouse.kind, MouseEventKind::Up(MouseButton::Left)) { + if let Some((half, block)) = pending_block_toggle.take() { + active_block = Some((half, block)); + expanded_blocks.toggle(half, block); + redraw = true; + } + } continue; } } else if visual_select_mode.is_some() { @@ -935,14 +1014,18 @@ pub(crate) fn run( mouse.row, &sources, vertical, + source_state.offset(), ) { source_state.select(Some(idx)); } } WorkspaceFocus::Sessions => { - if let Some(idx) = - row_list_index(layout.sessions, mouse.row, sessions.len()) - { + if let Some(idx) = row_list_index( + layout.sessions, + mouse.row, + sessions.len(), + session_state.offset(), + ) { session_state.select(Some(idx)); if !has_selected_sessions(&selected_sessions) { reset_workspace_dialogue_state( @@ -956,9 +1039,12 @@ pub(crate) fn run( } } WorkspaceFocus::Dialogues => { - if let Some(idx) = - row_list_index(layout.dialogues, mouse.row, dialogue_count) - { + if let Some(idx) = row_list_index( + layout.dialogues, + mouse.row, + dialogue_count, + dialogue_state.offset(), + ) { dialogue_state.select(Some(idx)); content_scrolls.clear(); } diff --git a/src/commands/browse/visual.rs b/src/commands/browse/visual.rs index 0919ccfc..a5b04a6b 100644 --- a/src/commands/browse/visual.rs +++ b/src/commands/browse/visual.rs @@ -17,7 +17,7 @@ use crate::tui::workspace::{ use super::content::workspace_picked_content; use super::nav::{move_workspace_cursor_down, move_workspace_cursor_up}; -const MOUSE_SCROLL_LINES: usize = 3; +const MOUSE_SCROLL_LINES: usize = 1; #[derive(Clone, Copy)] pub(super) struct VisualSelectMode { pub(super) selection: ContentSelection, diff --git a/src/tui/content/io.rs b/src/tui/content/io.rs index 6f9f27bd..f7c1e49d 100644 --- a/src/tui/content/io.rs +++ b/src/tui/content/io.rs @@ -4,6 +4,7 @@ //! so picker / render / help don't re-copy the same match arms. use ratatui::layout::Rect; +use std::collections::HashSet; use crate::tui::content::view::{content_view_line_count, ContentViewMode}; @@ -117,6 +118,35 @@ impl ContentScrolls { } } +/// Which structure blocks the user expanded, per content half. Read mode +/// defaults every block to its `<:…:>` tag line; a block in this set shows +/// its full payload instead. Raw mode always shows full blocks and ignores +/// this state. A block index is the ordinal of the block's open marker line +/// within that half's text — stable because every block shows exactly one +/// open marker line whether collapsed or expanded. +#[derive(Clone, Debug, Default, PartialEq, Eq)] +pub(crate) struct ExpandedBlocks { + pub(crate) input: HashSet, + pub(crate) output: HashSet, +} + +impl ExpandedBlocks { + pub(crate) fn toggle(&mut self, focus: ContentIoFocus, block: usize) { + let set = match focus { + ContentIoFocus::Input => &mut self.input, + ContentIoFocus::Output => &mut self.output, + }; + if !set.insert(block) { + set.remove(&block); + } + } + + pub(crate) fn clear(&mut self) { + self.input.clear(); + self.output.clear(); + } +} + /// Geometry of the dual content panes. #[derive(Clone, Copy, Debug, Default)] pub(crate) struct ContentIoAreas { diff --git a/src/tui/content/markdown.rs b/src/tui/content/markdown.rs index 437f05ce..b271e039 100644 --- a/src/tui/content/markdown.rs +++ b/src/tui/content/markdown.rs @@ -814,7 +814,7 @@ fn agent_heading_style(text: &str) -> Option