Skip to content
Open
7 changes: 5 additions & 2 deletions src/commands/browse/help.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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;

Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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);
Expand Down
33 changes: 30 additions & 3 deletions src/commands/browse/nav.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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<usize> {
pub(super) fn row_list_index(
area: ratatui::layout::Rect,
row: u16,
len: usize,
offset: usize,
) -> Option<usize> {
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(
Expand All @@ -173,10 +179,11 @@ pub(super) fn source_list_index(
row: u16,
sources: &[WorkspaceSource],
vertical: bool,
offset: usize,
) -> Option<usize> {
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)
Expand Down Expand Up @@ -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);
}
}
6 changes: 4 additions & 2 deletions src/commands/browse/panes.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};
Expand Down Expand Up @@ -419,6 +419,7 @@ pub struct ContentCtx<'a> {
pub target: Option<WorkAt>,
pub area: ratatui::layout::Rect,
pub io_focus: ContentIoFocus,
pub expanded: &'a ExpandedBlocks,
}

/// Tracks layout line counts for Input / Output halves separately.
Expand All @@ -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;
Expand Down
104 changes: 95 additions & 9 deletions src/commands/browse/picker.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand All @@ -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::{
Expand Down Expand Up @@ -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();
Expand Down Expand Up @@ -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 {
Expand All @@ -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,
Expand Down Expand Up @@ -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,
},
)
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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(
Comment thread
Ariestar marked this conversation as resolved.
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,
Expand All @@ -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;
Comment thread
Ariestar marked this conversation as resolved.
}
}
continue;
}
} else if visual_select_mode.is_some() {
Expand Down Expand Up @@ -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(
Expand All @@ -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();
}
Expand Down
2 changes: 1 addition & 1 deletion src/commands/browse/visual.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
30 changes: 30 additions & 0 deletions src/tui/content/io.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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};

Expand Down Expand Up @@ -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<usize>,
pub(crate) output: HashSet<usize>,
}

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 {
Expand Down
2 changes: 1 addition & 1 deletion src/tui/content/markdown.rs
Original file line number Diff line number Diff line change
Expand Up @@ -814,7 +814,7 @@ fn agent_heading_style(text: &str) -> Option<Style> {
/// structural content renders in a subdued gray, distinct from the
/// default-foreground body.
fn structure_marker_style(text: &str) -> Option<Style> {
if !text.starts_with("<:") {
if !crate::tui::content::text::is_structure_marker(text) {
return None;
}
Some(structural_gray_style())
Expand Down
Loading