Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
164 changes: 142 additions & 22 deletions crates/cli/src/app.rs
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,8 @@ use crate::keymap::{self, ChordState, KeyAction, Keymap, KeymapResult, Profile};
use crate::ui;
use agentd_client::Client;
use agentd_protocol::{
EventNotificationPayload, GroupSummary, HarnessInfo, Notification, Request, SessionEvent,
SessionSummary, StateNotificationPayload, TimestampedEvent,
EventNotificationPayload, GroupSummary, HarnessInfo, MessageRole, Notification, Request,
SessionEvent, SessionSummary, StateNotificationPayload, TimestampedEvent,
};
use anyhow::{Context, Result};
use crossterm::event::{
Expand Down Expand Up @@ -221,12 +221,90 @@ impl MainWindowTree {
/// What the right pane is currently showing for the selected session.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ViewMode {
/// Structured transcript renderer (default for headless / non-PTY sessions).
Transcript,
/// Structured-event chat renderer (default for headless / non-PTY sessions).
Chat,
/// Live PTY emulator (default for sessions whose adapter has supports_pty).
Terminal,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ChatScrollKind {
Hidden,
AssistantMessage,
Message,
Reasoning,
Tool,
Metadata,
}

fn chat_scroll_kind(ev: &SessionEvent) -> ChatScrollKind {
match ev {
SessionEvent::Pty { .. }
| SessionEvent::PtyResize { .. }
| SessionEvent::EditorState { .. }
| SessionEvent::AgentStatus(_) => ChatScrollKind::Hidden,
SessionEvent::Message { role, text }
if should_render_chat_message_for_scroll(*role, text) =>
{
if *role == MessageRole::Assistant {
ChatScrollKind::AssistantMessage
} else {
ChatScrollKind::Message
}
}
SessionEvent::Message { .. } => ChatScrollKind::Hidden,
SessionEvent::Reasoning { .. } => ChatScrollKind::Reasoning,
SessionEvent::ToolUse { .. }
| SessionEvent::ToolResult { .. }
| SessionEvent::ToolApprovalRequest { .. }
| SessionEvent::TaskStart { .. }
| SessionEvent::TaskBackgrounded { .. }
| SessionEvent::TaskEnd { .. } => ChatScrollKind::Tool,
SessionEvent::Status { .. }
| SessionEvent::AwaitingInput { .. }
| SessionEvent::Cost { .. }
| SessionEvent::Diff { .. }
| SessionEvent::Error { .. }
| SessionEvent::Reset
| SessionEvent::Done { .. }
| SessionEvent::UiPanel(_)
| SessionEvent::UiDelete { .. }
| SessionEvent::BrowserPreview(_)
| SessionEvent::ContextCompacted { .. } => ChatScrollKind::Metadata,
}
}

fn chat_scroll_needs_gap(previous: ChatScrollKind, current: ChatScrollKind) -> bool {
!matches!(
(previous, current),
(ChatScrollKind::Tool, ChatScrollKind::Tool)
| (ChatScrollKind::Metadata, ChatScrollKind::Metadata)
| (ChatScrollKind::Reasoning, ChatScrollKind::Reasoning)
| (
ChatScrollKind::AssistantMessage,
ChatScrollKind::AssistantMessage
)
)
}

fn should_render_chat_message_for_scroll(role: MessageRole, text: &str) -> bool {
let trimmed = text.trim_start();
if role == MessageRole::Assistant && trimmed.starts_with("<permissions instructions>") {
return false;
}
if role == MessageRole::User
&& trimmed.starts_with("# AGENTS.md instructions for ")
&& trimmed.contains("\n<INSTRUCTIONS>")
{
return false;
}
true
}

fn transcript_scroll_pos(value: usize) -> u16 {
value.min((u16::MAX - 1) as usize) as u16
}

/// Which pane (if any) currently takes the entire screen. Zoom mirrors
/// tmux's `prefix z`: a single key collapses the rest of the layout
/// onto a single pane and back.
Expand Down Expand Up @@ -1401,7 +1479,7 @@ pub async fn run_with_socket(socket: std::path::PathBuf) -> Result<()> {
should_quit: false,
connected: true,
remote_clients: 0,
view: ViewMode::Transcript,
view: ViewMode::Chat,
histories: HashMap::new(),
block_hits: HashMap::new(),
matrix_reveal_hits: Vec::new(),
Expand Down Expand Up @@ -2133,7 +2211,7 @@ impl App {
self.view = if self.selected_session().map(|s| s.has_pty).unwrap_or(false) {
ViewMode::Terminal
} else {
ViewMode::Transcript
ViewMode::Chat
};
}

Expand Down Expand Up @@ -2411,6 +2489,48 @@ impl App {
items.iter().position(|it| it.matches(&self.selection))
}

fn chat_scroll_line_count(&self) -> usize {
let mut count = 0usize;
let mut previous = ChatScrollKind::Hidden;
for ev in &self.transcript {
let kind = chat_scroll_kind(&ev.event);
if kind == ChatScrollKind::Hidden {
continue;
}
if (kind == ChatScrollKind::AssistantMessage
&& previous == ChatScrollKind::AssistantMessage)
|| (kind == ChatScrollKind::Reasoning && previous == ChatScrollKind::Reasoning)
{
// Streaming assistant/reasoning chunks render as one aggregated chat row.
continue;
}
if count > 0 && chat_scroll_needs_gap(previous, kind) {
count += 1;
}
count += 1;
previous = kind;
}
count.max(1)
}

fn chat_scroll_max(&self) -> u16 {
transcript_scroll_pos(self.chat_scroll_line_count().saturating_sub(1))
}

fn adjust_chat_scroll(&mut self, delta: i32) {
let max = self.chat_scroll_max();
let current = if self.transcript_scroll == u16::MAX {
max
} else {
self.transcript_scroll.min(max)
};
self.transcript_scroll = if delta > 0 {
current.saturating_sub(delta as u16)
} else {
current.saturating_add(delta.unsigned_abs() as u16).min(max)
};
}

async fn refresh_selected_transcript(&mut self) {
let Some(id) = self.selected_id() else {
self.transcript.clear();
Expand Down Expand Up @@ -2440,7 +2560,7 @@ impl App {
self.view = ViewMode::Terminal;
self.bootstrap_terminal(&id).await;
} else {
self.view = ViewMode::Transcript;
self.view = ViewMode::Chat;
}
if self.selection.session_id() == Some(id.as_str()) {
self.start_session_transition();
Expand Down Expand Up @@ -2771,7 +2891,7 @@ impl App {
self.view = if self.selected_session().map(|s| s.has_pty).unwrap_or(false) {
ViewMode::Terminal
} else {
ViewMode::Transcript
ViewMode::Chat
};
}
}
Expand Down Expand Up @@ -4796,6 +4916,8 @@ impl App {
let next = adjusted_scrollback(self.scrollback_for_window(scroll_window), delta);
self.set_scrollback_for_window(scroll_window, next);
self.show_terminal_scrollbar();
} else if self.view == ViewMode::Chat {
self.adjust_chat_scroll(delta);
}
}

Expand Down Expand Up @@ -5500,14 +5622,14 @@ impl App {
ToggleView => {
let has_pty = self.in_pty_session();
self.view = match (self.view, has_pty) {
(ViewMode::Transcript, true) => {
(ViewMode::Chat, true) => {
// First time switching → bootstrap from replay snapshot.
if let Some(id) = self.selected_id() {
self.bootstrap_terminal(&id).await;
}
ViewMode::Terminal
}
_ => ViewMode::Transcript,
_ => ViewMode::Chat,
};
}
MoveSelectedUp => self.move_selected(true).await,
Expand Down Expand Up @@ -5542,31 +5664,29 @@ impl App {
ScrollUp => {
if self.can_scroll_pty_history() {
self.adjust_scrollback(1);
} else if self.transcript_scroll != u16::MAX {
self.transcript_scroll = self.transcript_scroll.saturating_sub(1);
} else if self.view == ViewMode::Chat {
self.adjust_chat_scroll(1);
}
}
ScrollDown => {
if self.can_scroll_pty_history() {
self.adjust_scrollback(-1);
} else if self.transcript_scroll != u16::MAX {
self.transcript_scroll = self.transcript_scroll.saturating_add(1);
} else if self.view == ViewMode::Chat {
self.adjust_chat_scroll(-1);
}
}
ScrollPageUp => {
if self.can_scroll_pty_history() {
self.adjust_scrollback(10);
} else if self.transcript_scroll == u16::MAX {
self.transcript_scroll = 0;
} else {
self.transcript_scroll = self.transcript_scroll.saturating_sub(10);
} else if self.view == ViewMode::Chat {
self.adjust_chat_scroll(10);
}
}
ScrollPageDown => {
if self.can_scroll_pty_history() {
self.adjust_scrollback(-10);
} else if self.transcript_scroll != u16::MAX {
self.transcript_scroll = self.transcript_scroll.saturating_add(10);
} else if self.view == ViewMode::Chat {
self.adjust_chat_scroll(-10);
}
}
ScrollTop => {
Expand Down Expand Up @@ -8148,8 +8268,8 @@ mod tests {
"the re-hydration request must actually fetch the PTY snapshot"
);

// In Transcript view a missing history must NOT spin up fetches.
app.view = ViewMode::Transcript;
// In Chat view a missing history must NOT spin up fetches.
app.view = ViewMode::Chat;
assert!(
!app.selected_needs_hydration(),
"transcript view should not re-fetch PTY history"
Expand Down
Loading
Loading