From 84fedd8048e20c8d4fea163857c656a845b2911c Mon Sep 17 00:00:00 2001 From: Hare Date: Tue, 21 Apr 2026 23:12:35 +0900 Subject: [PATCH] =?UTF-8?q?TUI=E3=81=AE=E3=82=AA=E3=83=BC=E3=83=90?= =?UTF-8?q?=E3=83=BC=E3=83=9B=E3=83=BC=E3=83=AB=E5=AE=9F=E8=A3=85?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- Cargo.lock | 1 + crates/tui/Cargo.toml | 1 + crates/tui/src/app.rs | 536 ++++++++++++++++++++++-------------- crates/tui/src/block.rs | 66 +++++ crates/tui/src/cache.rs | 52 ++++ crates/tui/src/input.rs | 404 +++++++++++++++++++++++++++ crates/tui/src/main.rs | 207 +++++++++----- crates/tui/src/scroll.rs | 117 ++++++++ crates/tui/src/tool.rs | 538 ++++++++++++++++++++++++++++++++++++ crates/tui/src/ui.rs | 579 ++++++++++++++++++++++++++++++--------- docs/tui-keybindings.md | 64 ++++- 11 files changed, 2151 insertions(+), 414 deletions(-) create mode 100644 crates/tui/src/block.rs create mode 100644 crates/tui/src/cache.rs create mode 100644 crates/tui/src/input.rs create mode 100644 crates/tui/src/scroll.rs create mode 100644 crates/tui/src/tool.rs diff --git a/Cargo.lock b/Cargo.lock index eba17fae..b5ab9939 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -3543,6 +3543,7 @@ dependencies = [ "serde_json", "tokio", "unicode-width", + "uuid", ] [[package]] diff --git a/crates/tui/Cargo.toml b/crates/tui/Cargo.toml index d1f74a0f..285003c3 100644 --- a/crates/tui/Cargo.toml +++ b/crates/tui/Cargo.toml @@ -11,3 +11,4 @@ crossterm = "0.28" tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] } serde_json = "1.0" unicode-width = "0.2.2" +uuid = "1.23" diff --git a/crates/tui/src/app.rs b/crates/tui/src/app.rs index 76774452..85fea3c6 100644 --- a/crates/tui/src/app.rs +++ b/crates/tui/src/app.rs @@ -1,4 +1,10 @@ -use protocol::{Event, Greeting, Method, NotificationLevel, NotificationSource, RunResult}; +use protocol::{Event, Method, NotificationLevel, NotificationSource, RunResult}; + +use crate::block::{Block, CompactEvent, ToolCallBlock, ToolCallState}; +use crate::cache::FileCache; +use crate::input::InputBuffer; +use crate::scroll::Scroll; +use crate::ui::Mode; pub struct App { pub pod_name: String, @@ -13,41 +19,22 @@ pub struct App { pub run_output_tokens: u64, pub turn_index: usize, pub current_tool: Option, - pub input: String, - pub cursor: usize, + pub input: InputBuffer, pub quit: bool, pub shutdown_confirm: Option, /// 2-tap guard for `Ctrl-C` when the Pod is not running. First press /// records the instant; a second press within the timeout exits the /// TUI (the Pod itself stays alive). pub quit_confirm: Option, - /// Lines waiting to be flushed to terminal via insert_before. - pub output_queue: Vec, - /// Partial streaming text not yet terminated by newline. - pending_text: String, -} - -/// A unit of output to push above the inline viewport. -pub enum OutputItem { - TurnHeader(String), - Padded(MessageKind, String), - PaddedRight(MessageKind, String), - GreetingCard(Greeting), - Blank, -} - -#[derive(Clone, Copy)] -pub enum MessageKind { - TurnHeader, - User, - Assistant, - Tool, - Error, - TurnStats, - /// Pod → user notification, Warn level. - NoticeWarn, - /// Pod → user notification, Error level. - NoticeError, + /// Full display history in render order. + pub blocks: Vec, + pub scroll: Scroll, + pub mode: Mode, + pub cache: FileCache, + /// True when the latest AssistantText block is still being streamed + /// and future text deltas should append to it instead of starting a + /// fresh block. + assistant_streaming: bool, } impl App { @@ -62,38 +49,46 @@ impl App { run_output_tokens: 0, turn_index: 0, current_tool: None, - input: String::new(), - cursor: 0, + input: InputBuffer::new(), quit: false, shutdown_confirm: None, quit_confirm: None, - output_queue: Vec::new(), - pending_text: String::new(), + blocks: Vec::new(), + scroll: Scroll::default(), + mode: Mode::Normal, + cache: FileCache::new(), + assistant_streaming: false, } } pub fn submit_input(&mut self) -> Option { - let text = self.input.trim().to_owned(); + let text = self.input.submit_text().trim().to_owned(); if text.is_empty() { // Empty Enter only does something meaningful when the Pod // is paused: resume the interrupted turn. Otherwise no-op. if self.paused { + self.input.clear(); return Some(Method::Resume); } return None; } self.turn_index += 1; - self.output_queue.push(OutputItem::Blank); - self.output_queue - .push(OutputItem::TurnHeader(format!("#{}", self.turn_index))); - self.output_queue - .push(OutputItem::Padded(MessageKind::User, text.clone())); - self.output_queue.push(OutputItem::Blank); + self.blocks.push(Block::TurnHeader { + turn: self.turn_index, + }); + self.blocks.push(Block::UserMessage { text: text.clone() }); self.input.clear(); - self.cursor = 0; Some(Method::Run { input: text }) } + pub fn push_error(&mut self, message: impl Into) { + self.blocks.push(Block::Notification { + level: NotificationLevel::Error, + source: NotificationSource::Pod, + message: message.into(), + }); + } + pub fn handle_pod_event(&mut self, event: Event) { match event { Event::TurnStart { .. } => { @@ -101,56 +96,94 @@ impl App { self.paused = false; self.run_requests += 1; self.current_tool = None; + self.assistant_streaming = false; } Event::TextDelta { text } => { - self.pending_text.push_str(&text); - self.flush_pending_lines(); + self.append_assistant_text(&text); } Event::TextDone { .. } => { - // Flush any remaining partial line - if !self.pending_text.is_empty() { - let text = std::mem::take(&mut self.pending_text); - self.output_queue - .push(OutputItem::Padded(MessageKind::Assistant, text)); - } + self.assistant_streaming = false; } Event::TurnEnd { .. } => { - // Flush streaming text if TextDone wasn't received - if !self.pending_text.is_empty() { - let text = std::mem::take(&mut self.pending_text); - self.output_queue - .push(OutputItem::Padded(MessageKind::Assistant, text)); - } + self.assistant_streaming = false; + self.mark_orphan_tool_calls_incomplete(); self.current_tool = None; } - Event::ToolCallStart { name, .. } => { + Event::ToolCallStart { id, name } => { self.current_tool = Some(name.clone()); - self.output_queue.push(OutputItem::Padded( - MessageKind::Tool, - format!("[tool] {name}"), - )); + self.assistant_streaming = false; + self.blocks.push(Block::ToolCall(ToolCallBlock { + id, + name, + args_stream: String::new(), + arguments: None, + state: ToolCallState::Pending, + })); + } + Event::ToolCallArgsDelta { id, json } => { + if let Some(b) = self.find_tool_call_mut(&id) { + b.args_stream.push_str(&json); + if matches!(b.state, ToolCallState::Pending) { + b.state = ToolCallState::Streaming; + } + } } Event::ToolCallDone { - name, arguments, .. + id, arguments, .. } => { self.current_tool = None; - self.output_queue.push(OutputItem::Padded( - MessageKind::Tool, - format!("[tool] {name} done ({} bytes)", arguments.len()), - )); + if let Some(b) = self.find_tool_call_mut(&id) { + b.arguments = Some(arguments); + // Only advance the state when it's still in-flight. + // If a ToolResult arrived out of order and already + // transitioned us to Done/Error, keep that. + if matches!( + b.state, + ToolCallState::Pending | ToolCallState::Streaming + ) { + b.state = ToolCallState::Executing; + } + } } Event::ToolResult { - summary, is_error, .. + id, + summary, + output, + is_error, } => { - let prefix = if is_error { - "[tool error]" + if let Some(b) = self.find_tool_call_mut(&id) { + // Capture data we need for cache updates before we + // move `output` into the new state. + let name = b.name.clone(); + let args = b.arguments.clone(); + b.state = if is_error { + ToolCallState::Error { + summary, + output: output.clone(), + } + } else { + ToolCallState::Done { + summary, + output: output.clone(), + } + }; + if !is_error { + apply_cache_update(&mut self.cache, &name, args.as_deref(), output.as_deref()); + } } else { - "[tool result]" - }; - self.output_queue.push(OutputItem::Padded( - MessageKind::Tool, - format!("{prefix} {summary}"), - )); + // Result for an unknown tool call. Surface it as a + // notification so it isn't silently dropped. + let level = if is_error { + NotificationLevel::Error + } else { + NotificationLevel::Warn + }; + self.blocks.push(Block::Notification { + level, + source: NotificationSource::Pod, + message: format!("orphan tool result ({id}): {summary}"), + }); + } } Event::Usage { input_tokens, @@ -160,75 +193,42 @@ impl App { self.run_output_tokens += output_tokens.unwrap_or(0); } Event::Error { code, message } => { - self.output_queue.push(OutputItem::Padded( - MessageKind::Error, - format!("[{code:?}] {message}"), - )); + self.push_error(format!("[{code:?}] {message}")); } Event::RunEnd { result } => { - self.output_queue.push(OutputItem::PaddedRight( - MessageKind::TurnStats, - format!( - "{} reqs ↑{}/↓{}", - self.run_requests, - fmt_tokens(self.run_input_tokens), - fmt_tokens(self.run_output_tokens), - ), - )); - self.output_queue.push(OutputItem::Blank); + self.blocks.push(Block::TurnStats { + requests: self.run_requests, + input_tokens: self.run_input_tokens, + output_tokens: self.run_output_tokens, + }); self.running = false; self.paused = matches!(result, RunResult::Paused); self.run_requests = 0; self.run_input_tokens = 0; self.run_output_tokens = 0; self.current_tool = None; + self.assistant_streaming = false; } - Event::ToolCallArgsDelta { .. } => {} Event::CompactStart => { - self.output_queue.push(OutputItem::Padded( - MessageKind::NoticeWarn, - "[compact] starting".to_string(), - )); + self.blocks.push(Block::Compact(CompactEvent::Start)); } Event::CompactDone { new_session_id } => { - let short = new_session_id - .to_string() - .chars() - .take(8) - .collect::(); - self.output_queue.push(OutputItem::Padded( - MessageKind::NoticeWarn, - format!("[compact] done (new session {short})"), - )); + self.blocks + .push(Block::Compact(CompactEvent::Done { new_session_id })); } Event::CompactFailed { error } => { - self.output_queue.push(OutputItem::Padded( - MessageKind::NoticeError, - format!("[compact error] {error}"), - )); + self.blocks + .push(Block::Compact(CompactEvent::Failed { error })); } Event::Notification(notification) => { - let kind = match notification.level { - NotificationLevel::Warn => MessageKind::NoticeWarn, - NotificationLevel::Error => MessageKind::NoticeError, - }; - let prefix = match notification.level { - NotificationLevel::Warn => "[notice]", - NotificationLevel::Error => "[notice error]", - }; - let source = notification_source_label(notification.source); - self.output_queue.push(OutputItem::Padded( - kind, - format!("{prefix} {source}: {}", notification.message), - )); + self.blocks.push(Block::Notification { + level: notification.level, + source: notification.source, + message: notification.message, + }); } Event::History { items, greeting } => { - self.restore_history(&items); - if self.turn_index == 0 { - self.output_queue - .insert(0, OutputItem::GreetingCard(greeting)); - self.output_queue.insert(1, OutputItem::Blank); - } + self.restore_history(&items, greeting); } Event::Shutdown => { self.quit = true; @@ -236,128 +236,186 @@ impl App { } } - /// Extract complete lines (ending with \n) from pending_text and queue them. - fn flush_pending_lines(&mut self) { - while let Some(pos) = self.pending_text.find('\n') { - let line = self.pending_text[..pos].to_owned(); - self.pending_text = self.pending_text[pos + 1..].to_owned(); - self.output_queue - .push(OutputItem::Padded(MessageKind::Assistant, line)); + fn append_assistant_text(&mut self, text: &str) { + if self.assistant_streaming { + if let Some(Block::AssistantText { text: existing }) = self.blocks.last_mut() { + existing.push_str(text); + return; + } + } + self.blocks.push(Block::AssistantText { + text: text.to_owned(), + }); + self.assistant_streaming = true; + } + + fn find_tool_call_mut(&mut self, id: &str) -> Option<&mut ToolCallBlock> { + for b in self.blocks.iter_mut().rev() { + if let Block::ToolCall(tc) = b + && tc.id == id + { + return Some(tc); + } + } + None + } + + /// Called on `TurnEnd`: mark any tool call still in an in-progress + /// state as `Incomplete` so the user sees something was left hanging + /// instead of a silently-truncated block. + fn mark_orphan_tool_calls_incomplete(&mut self) { + for b in self.blocks.iter_mut().rev() { + if let Block::ToolCall(tc) = b { + if matches!( + tc.state, + ToolCallState::Pending + | ToolCallState::Streaming + | ToolCallState::Executing + ) { + tc.state = ToolCallState::Incomplete; + } else { + // Earlier tool calls in the same list are already + // finalized; stop walking. + break; + } + } else if matches!(b, Block::TurnHeader { .. }) { + break; + } } } + // Input manipulation — thin forwarders so call sites in main.rs + // stay readable. pub fn insert_char(&mut self, c: char) { - self.input.insert(self.cursor, c); - self.cursor += c.len_utf8(); + self.input.insert_char(c); + } + pub fn insert_newline(&mut self) { + self.input.insert_newline(); + } + pub fn insert_paste(&mut self, content: String) { + self.input.insert_paste(content); } - pub fn delete_char_before(&mut self) { - if self.cursor > 0 { - let prev = self.input[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - self.input.drain(prev..self.cursor); - self.cursor = prev; - } + self.input.delete_before(); } - pub fn delete_char_after(&mut self) { - if self.cursor < self.input.len() { - let next = self.input[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.input.len()); - self.input.drain(self.cursor..next); - } + self.input.delete_after(); } - pub fn move_cursor_left(&mut self) { - if self.cursor > 0 { - self.cursor = self.input[..self.cursor] - .char_indices() - .next_back() - .map(|(i, _)| i) - .unwrap_or(0); - } + self.input.move_left(); } - pub fn move_cursor_right(&mut self) { - if self.cursor < self.input.len() { - self.cursor = self.input[self.cursor..] - .char_indices() - .nth(1) - .map(|(i, _)| self.cursor + i) - .unwrap_or(self.input.len()); - } + self.input.move_right(); } - pub fn move_cursor_home(&mut self) { - self.cursor = 0; + self.input.move_home(); } - pub fn move_cursor_end(&mut self) { - self.cursor = self.input.len(); + self.input.move_end(); + } + pub fn move_cursor_up(&mut self) { + self.input.move_up(); + } + pub fn move_cursor_down(&mut self) { + self.input.move_down(); } - fn restore_history(&mut self, items: &[serde_json::Value]) { + fn restore_history(&mut self, items: &[serde_json::Value], greeting: protocol::Greeting) { + // Fresh session: greeting + any replayed items. Append-only — we + // don't try to merge with already-displayed live events because + // `History` only fires on an empty live state. self.turn_index = 0; + self.blocks.clear(); + self.cache = FileCache::new(); + self.blocks.push(Block::Greeting(greeting)); + self.assistant_streaming = false; + for item in items { let item_type = item["type"].as_str().unwrap_or(""); match item_type { "message" => { let role = item["role"].as_str().unwrap_or(""); - let kind = match role { - "user" => { - self.turn_index += 1; - self.output_queue.push(OutputItem::Blank); - self.output_queue - .push(OutputItem::TurnHeader(format!("#{}", self.turn_index))); - MessageKind::User - } - "assistant" => MessageKind::Assistant, - _ => continue, - }; let text = item["content"] .as_array() .and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next()) - .unwrap_or(""); - if !text.is_empty() { - self.output_queue - .push(OutputItem::Padded(kind, text.to_owned())); - if matches!(kind, MessageKind::User) { - self.output_queue.push(OutputItem::Blank); + .unwrap_or("") + .to_owned(); + match role { + "user" => { + self.turn_index += 1; + self.blocks.push(Block::TurnHeader { + turn: self.turn_index, + }); + if !text.is_empty() { + self.blocks.push(Block::UserMessage { text }); + } } + "assistant" if !text.is_empty() => { + self.blocks.push(Block::AssistantText { text }); + } + _ => {} } } "tool_call" => { - let name = item["name"].as_str().unwrap_or("?"); - self.output_queue.push(OutputItem::Padded( - MessageKind::Tool, - format!("[tool] {name}"), - )); + // `Item::ToolCall` serializes the linking key as + // `call_id`; `id` is a separate optional item-level + // identifier. Use `call_id` so this matches how + // Event::ToolCallStart populates the block. + let id = item["call_id"].as_str().unwrap_or("").to_owned(); + let name = item["name"].as_str().unwrap_or("?").to_owned(); + let arguments = item["arguments"].as_str().map(|s| s.to_owned()); + self.blocks.push(Block::ToolCall(ToolCallBlock { + id, + name, + args_stream: arguments.clone().unwrap_or_default(), + arguments, + state: ToolCallState::Executing, + })); } "tool_result" => { - let summary = item["summary"].as_str().unwrap_or(""); - self.output_queue.push(OutputItem::Padded( - MessageKind::Tool, - format!("[tool result] {summary}"), - )); + let id = item["call_id"].as_str().unwrap_or("").to_owned(); + let summary = item["summary"].as_str().unwrap_or("").to_owned(); + let output = item["content"].as_str().map(|s| s.to_owned()); + let is_error = item["is_error"].as_bool().unwrap_or(false); + if let Some(tc) = self.find_tool_call_mut(&id) { + let name = tc.name.clone(); + let args = tc.arguments.clone(); + tc.state = if is_error { + ToolCallState::Error { + summary, + output: output.clone(), + } + } else { + ToolCallState::Done { + summary, + output: output.clone(), + } + }; + if !is_error { + apply_cache_update( + &mut self.cache, + &name, + args.as_deref(), + output.as_deref(), + ); + } + } } _ => {} } } - } -} -fn notification_source_label(source: NotificationSource) -> &'static str { - match source { - NotificationSource::Pod => "pod", - NotificationSource::Worker => "worker", - NotificationSource::Compactor => "compactor", - NotificationSource::AgentsMd => "AGENTS.md", + // Any tool_call entries that never got paired with a + // tool_result (truncated or racing mid-turn on the server side) + // stay as Executing up to this point. Surface them as + // Incomplete so the replay matches live semantics. + for b in self.blocks.iter_mut() { + if let Block::ToolCall(tc) = b + && matches!(tc.state, ToolCallState::Executing | ToolCallState::Pending | ToolCallState::Streaming) + { + tc.state = ToolCallState::Incomplete; + } + } } } @@ -370,3 +428,61 @@ pub fn fmt_tokens(n: u64) -> String { n.to_string() } } + +pub fn notification_source_label(source: NotificationSource) -> &'static str { + match source { + NotificationSource::Pod => "pod", + NotificationSource::Worker => "worker", + NotificationSource::Compactor => "compactor", + NotificationSource::AgentsMd => "AGENTS.md", + } +} + +/// Seed / mutate the file-content cache based on a completed tool call. +/// +/// Each built-in file tool has its own rule: Read copies the result body +/// into the cache, Write replaces it with `args.content`, Edit applies +/// the `old_string → new_string` swap in-place. +fn apply_cache_update( + cache: &mut FileCache, + name: &str, + arguments: Option<&str>, + output: Option<&str>, +) { + let args = arguments.and_then(|s| serde_json::from_str::(s).ok()); + match name { + "Read" => { + let Some(args) = args.as_ref() else { return }; + let Some(path) = args["file_path"].as_str() else { + return; + }; + if let Some(content) = output { + cache.put(path, content.to_owned()); + } + } + "Write" => { + let Some(args) = args.as_ref() else { return }; + let Some(path) = args["file_path"].as_str() else { + return; + }; + let Some(content) = args["content"].as_str() else { + return; + }; + cache.put(path, content.to_owned()); + } + "Edit" => { + let Some(args) = args.as_ref() else { return }; + let Some(path) = args["file_path"].as_str() else { + return; + }; + let Some(old) = args["old_string"].as_str() else { + return; + }; + let Some(new) = args["new_string"].as_str() else { + return; + }; + cache.apply_edit(path, old, new); + } + _ => {} + } +} diff --git a/crates/tui/src/block.rs b/crates/tui/src/block.rs new file mode 100644 index 00000000..d4477aef --- /dev/null +++ b/crates/tui/src/block.rs @@ -0,0 +1,66 @@ +//! History blocks: the unit of the TUI's stored display model. +//! +//! The TUI holds a flat `Vec` and re-renders it every frame. +//! Streaming events mutate the most recent matching block instead of +//! queuing a new line, so one logical thing (a tool call, an assistant +//! reply) stays visually coherent as updates arrive. + +#![allow(dead_code)] // Phase 5 will consume `output` in detail mode. + +use protocol::{Greeting, NotificationLevel, NotificationSource}; + +pub enum Block { + Greeting(Greeting), + TurnHeader { + turn: usize, + }, + UserMessage { + text: String, + }, + AssistantText { + text: String, + }, + ToolCall(ToolCallBlock), + Notification { + level: NotificationLevel, + source: NotificationSource, + message: String, + }, + Compact(CompactEvent), + TurnStats { + requests: usize, + input_tokens: u64, + output_tokens: u64, + }, +} + +pub enum CompactEvent { + Start, + Done { new_session_id: uuid::Uuid }, + Failed { error: String }, +} + +pub struct ToolCallBlock { + pub id: String, + pub name: String, + /// Raw JSON fragments accumulated from `ToolCallArgsDelta`. + pub args_stream: String, + /// Final arguments text once `ToolCallDone` lands. + pub arguments: Option, + pub state: ToolCallState, +} + +pub enum ToolCallState { + /// `ToolCallStart` received, nothing else yet. + Pending, + /// At least one `ToolCallArgsDelta` has arrived. + Streaming, + /// `ToolCallDone` received, waiting on the tool result. + Executing, + /// `ToolResult { is_error: false, .. }` received. + Done { summary: String, output: Option }, + /// `ToolResult { is_error: true, .. }` received. + Error { summary: String, output: Option }, + /// Turn ended before a matching `ToolResult` arrived. + Incomplete, +} diff --git a/crates/tui/src/cache.rs b/crates/tui/src/cache.rs new file mode 100644 index 00000000..f0aaed47 --- /dev/null +++ b/crates/tui/src/cache.rs @@ -0,0 +1,52 @@ +//! File content cache for the Edit renderer. +//! +//! Holds `path → content` for every file the TUI has observed via a +//! successful Read (where `ToolResult.output` contains the file body), +//! Write (where `args.content` is the new body), or Edit (which mutates +//! the cached body in-place). The cache is purely a display-side view — +//! it has no opinion on what the filesystem actually contains. + +use std::collections::HashMap; + +#[derive(Default)] +pub struct FileCache { + contents: HashMap, +} + +impl FileCache { + pub fn new() -> Self { + Self::default() + } + + pub fn put(&mut self, path: impl Into, content: impl Into) { + self.contents.insert(path.into(), content.into()); + } + + pub fn get(&self, path: &str) -> Option<&str> { + self.contents.get(path).map(String::as_str) + } + + /// Apply an Edit-style substitution. When `old` is unique in the + /// cached content we swap it for `new`; otherwise we leave the + /// cache untouched (the TUI can't reliably reconstruct the new + /// state in that case). + pub fn apply_edit(&mut self, path: &str, old: &str, new: &str) { + let Some(current) = self.contents.get(path) else { + return; + }; + // Only swap when `old` appears exactly once — mirrors the + // tool's own precondition and keeps the cache from diverging + // when ambiguity would otherwise force a guess. + let mut occurrences = current.match_indices(old); + let first = occurrences.next(); + let second = occurrences.next(); + if let (Some((idx, matched)), None) = (first, second) { + let end = idx + matched.len(); + let mut buf = String::with_capacity(current.len() - old.len() + new.len()); + buf.push_str(¤t[..idx]); + buf.push_str(new); + buf.push_str(¤t[end..]); + self.contents.insert(path.to_owned(), buf); + } + } +} diff --git a/crates/tui/src/input.rs b/crates/tui/src/input.rs new file mode 100644 index 00000000..8dc5a7b2 --- /dev/null +++ b/crates/tui/src/input.rs @@ -0,0 +1,404 @@ +//! Multi-line input buffer with paste placeholders. +//! +//! The buffer stores a sequence of [`Atom`]s — each either a single +//! character (including `\n`) or an atomic paste reference. The cursor +//! is an index in `0..=atoms.len()` marking the insertion point between +//! atoms. Paste atoms are indivisible: Backspace deletes the whole +//! placeholder, the cursor can't land "inside" one. +//! +//! Display form: paste atoms render as +//! `[Clipboard #N | X chars, Y lines]`. Submit form: paste atoms expand +//! back to their original captured content so the Pod sees the full +//! pasted text (without the placeholder label). + +use ratatui::style::{Color, Style}; +use ratatui::text::{Line, Span}; +use unicode_width::UnicodeWidthChar; + +#[derive(Debug, Clone)] +pub struct PasteRef { + pub id: u32, + pub chars: usize, + pub lines: usize, + pub content: String, +} + +impl PasteRef { + pub fn label(&self) -> String { + format!( + "[Clipboard #{} | {} chars, {} lines]", + self.id, self.chars, self.lines + ) + } +} + +#[derive(Debug, Clone)] +pub enum Atom { + Char(char), + Paste(PasteRef), +} + +pub struct InputBuffer { + atoms: Vec, + /// Insertion point in `0..=atoms.len()`. + cursor: usize, + /// Monotonic counter reused across the TUI process lifetime. + next_paste_id: u32, +} + +impl Default for InputBuffer { + fn default() -> Self { + Self { + atoms: Vec::new(), + cursor: 0, + next_paste_id: 1, + } + } +} + +impl InputBuffer { + pub fn new() -> Self { + Self::default() + } + + pub fn clear(&mut self) { + self.atoms.clear(); + self.cursor = 0; + } + + pub fn insert_char(&mut self, c: char) { + self.atoms.insert(self.cursor, Atom::Char(c)); + self.cursor += 1; + } + + pub fn insert_newline(&mut self) { + self.insert_char('\n'); + } + + pub fn insert_paste(&mut self, content: String) { + let id = self.next_paste_id; + self.next_paste_id = self.next_paste_id.wrapping_add(1); + let chars = content.chars().count(); + let lines = content.lines().count().max(1); + self.atoms.insert( + self.cursor, + Atom::Paste(PasteRef { + id, + chars, + lines, + content, + }), + ); + self.cursor += 1; + } + + pub fn delete_before(&mut self) { + if self.cursor == 0 { + return; + } + self.cursor -= 1; + self.atoms.remove(self.cursor); + } + + pub fn delete_after(&mut self) { + if self.cursor < self.atoms.len() { + self.atoms.remove(self.cursor); + } + } + + pub fn move_left(&mut self) { + self.cursor = self.cursor.saturating_sub(1); + } + + pub fn move_right(&mut self) { + self.cursor = (self.cursor + 1).min(self.atoms.len()); + } + + pub fn move_home(&mut self) { + while self.cursor > 0 { + if matches!(self.atoms[self.cursor - 1], Atom::Char('\n')) { + break; + } + self.cursor -= 1; + } + } + + pub fn move_end(&mut self) { + while self.cursor < self.atoms.len() { + if matches!(self.atoms[self.cursor], Atom::Char('\n')) { + break; + } + self.cursor += 1; + } + } + + /// Move one logical line up, preserving column (atom count from + /// current line start). No-op if already on the first line. + pub fn move_up(&mut self) { + let (line_start, col) = self.line_start_and_col(); + if line_start == 0 { + return; + } + // `atoms[line_start - 1]` is the '\n' that opens the current + // line; find the previous line's start. + let prev_end = line_start - 1; + let mut prev_start = 0; + for i in (0..prev_end).rev() { + if matches!(self.atoms[i], Atom::Char('\n')) { + prev_start = i + 1; + break; + } + } + let prev_len = prev_end - prev_start; + self.cursor = prev_start + col.min(prev_len); + } + + /// Move one logical line down, preserving column. + pub fn move_down(&mut self) { + let (line_start, col) = self.line_start_and_col(); + // End of current line. + let mut cur_end = self.atoms.len(); + for i in line_start..self.atoms.len() { + if matches!(self.atoms[i], Atom::Char('\n')) { + cur_end = i; + break; + } + } + if cur_end == self.atoms.len() { + return; // no next line + } + let next_start = cur_end + 1; + let mut next_end = self.atoms.len(); + for i in next_start..self.atoms.len() { + if matches!(self.atoms[i], Atom::Char('\n')) { + next_end = i; + break; + } + } + let next_len = next_end - next_start; + self.cursor = next_start + col.min(next_len); + } + + fn line_start_and_col(&self) -> (usize, usize) { + let mut start = 0; + for i in (0..self.cursor).rev() { + if matches!(self.atoms[i], Atom::Char('\n')) { + start = i + 1; + break; + } + } + (start, self.cursor - start) + } + + /// Flatten atoms into the text sent to the Pod: paste atoms expand + /// to their original content; no `[Clipboard ...]` labels survive. + pub fn submit_text(&self) -> String { + let mut out = String::new(); + for a in &self.atoms { + match a { + Atom::Char(c) => out.push(*c), + Atom::Paste(p) => out.push_str(&p.content), + } + } + out + } + + /// Visible rendering wrapped to `content_width` display columns, plus + /// `(row, col)` of the cursor where `col` is a Unicode display column + /// within the wrapped layout. + pub fn render(&self, content_width: u16) -> InputRender { + let w = content_width.max(1) as usize; + let paste_style = Style::default().fg(Color::Magenta); + let text_style = Style::default(); + + // Row-builder state. `pending` + `pending_width` batch consecutive + // same-style chars into one Span per flush. + let mut rows: Vec>> = vec![Vec::new()]; + let mut row_width: usize = 0; + let mut pending = String::new(); + let mut pending_width: usize = 0; + let mut pending_style = text_style; + + let mut cursor_row: u16 = 0; + let mut cursor_col: u16 = 0; + let mut cursor_set = false; + + // Record cursor once, at the point right before `atom` would be + // placed — accounting for a wrap that the atom itself will cause. + fn cursor_before( + leading_width: usize, + row_width: usize, + pending_width: usize, + content_w: usize, + cur_rows: usize, + ) -> (u16, u16) { + let here = row_width + pending_width; + // If the atom's first-char width would overflow and the row + // isn't empty, the cursor sits at the start of the wrap row. + if leading_width > 0 && here + leading_width > content_w && here > 0 { + (cur_rows as u16, 0) + } else { + ((cur_rows - 1) as u16, here as u16) + } + } + + for (i, atom) in self.atoms.iter().enumerate() { + if !cursor_set && i == self.cursor { + let leading = match atom { + Atom::Char('\n') => 0, + Atom::Char(c) => UnicodeWidthChar::width(*c).unwrap_or(0), + Atom::Paste(p) => p + .label() + .chars() + .next() + .and_then(UnicodeWidthChar::width) + .unwrap_or(0), + }; + let (r, c) = cursor_before(leading, row_width, pending_width, w, rows.len()); + cursor_row = r; + cursor_col = c; + cursor_set = true; + } + + match atom { + Atom::Char('\n') => { + flush_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + ); + rows.push(Vec::new()); + row_width = 0; + } + Atom::Char(c) => { + let cw = UnicodeWidthChar::width(*c).unwrap_or(0); + if pending_style != text_style && !pending.is_empty() { + flush_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + ); + } + pending_style = text_style; + place_char( + *c, + cw, + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + w, + ); + } + Atom::Paste(p) => { + if pending_style != paste_style && !pending.is_empty() { + flush_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + ); + } + pending_style = paste_style; + for c in p.label().chars() { + let cw = UnicodeWidthChar::width(c).unwrap_or(0); + place_char( + c, + cw, + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + w, + ); + } + } + } + } + + // Flush trailing pending chars. + flush_pending( + &mut pending, + &mut pending_width, + pending_style, + &mut rows, + &mut row_width, + ); + + // Cursor at end-of-buffer. + if !cursor_set && self.cursor == self.atoms.len() { + if row_width >= w && w > 0 { + // Last row is full — land the cursor on a fresh line so + // it stays visible instead of hanging off the right edge. + rows.push(Vec::new()); + cursor_row = (rows.len() - 1) as u16; + cursor_col = 0; + } else { + cursor_row = (rows.len() - 1) as u16; + cursor_col = row_width as u16; + } + } + + let lines: Vec> = rows.into_iter().map(Line::from).collect(); + + InputRender { + lines, + cursor_row, + cursor_col, + } + } +} + +/// Append a single char, wrapping to a new row first when it would +/// overflow `content_w`. The row is allowed to hold a single oversized +/// char (e.g. a wide CJK glyph on a 1-column layout) so we never loop. +fn place_char( + c: char, + cw: usize, + pending: &mut String, + pending_width: &mut usize, + style: Style, + rows: &mut Vec>>, + row_width: &mut usize, + content_w: usize, +) { + let here = *row_width + *pending_width; + if here + cw > content_w && here > 0 { + flush_pending(pending, pending_width, style, rows, row_width); + rows.push(Vec::new()); + *row_width = 0; + } + pending.push(c); + *pending_width += cw; +} + +fn flush_pending( + pending: &mut String, + pending_width: &mut usize, + style: Style, + rows: &mut [Vec>], + row_width: &mut usize, +) { + if pending.is_empty() { + return; + } + let taken = std::mem::take(pending); + *row_width += *pending_width; + *pending_width = 0; + if let Some(last) = rows.last_mut() { + last.push(Span::styled(taken, style)); + } +} + +pub struct InputRender { + pub lines: Vec>, + pub cursor_row: u16, + pub cursor_col: u16, +} diff --git a/crates/tui/src/main.rs b/crates/tui/src/main.rs index d96a0f78..356a4a2e 100644 --- a/crates/tui/src/main.rs +++ b/crates/tui/src/main.rs @@ -1,15 +1,26 @@ mod app; +mod block; +mod cache; mod client; +mod input; +mod scroll; +mod tool; mod ui; use std::io; use std::path::PathBuf; -use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers}; -use crossterm::terminal; +use crossterm::event::{ + self, DisableBracketedPaste, EnableBracketedPaste, Event as TermEvent, KeyCode, KeyEvent, + KeyModifiers, +}; +use crossterm::execute; +use crossterm::terminal::{ + EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode, +}; use protocol::Method; +use ratatui::Terminal; use ratatui::backend::CrosstermBackend; -use ratatui::{Terminal, TerminalOptions, Viewport}; use crate::app::App; use crate::client::PodClient; @@ -56,37 +67,45 @@ async fn main() -> Result<(), Box> { let (pod_name, socket_override) = parse_args(); let socket_path = resolve_socket(&pod_name, socket_override); - terminal::enable_raw_mode()?; - let stdout = io::stdout(); + enable_raw_mode()?; + let mut stdout = io::stdout(); + execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?; let backend = CrosstermBackend::new(stdout); - let mut terminal = Terminal::with_options( - backend, - TerminalOptions { - viewport: Viewport::Inline(3), - }, - )?; + let mut terminal = Terminal::new(backend)?; + let result = run(&mut terminal, pod_name, &socket_path).await; + + // Always restore the terminal, even on error. + let _ = execute!( + terminal.backend_mut(), + DisableBracketedPaste, + LeaveAlternateScreen + ); + let _ = disable_raw_mode(); + terminal.show_cursor().ok(); + + result +} + +async fn run( + terminal: &mut Terminal>, + pod_name: String, + socket_path: &std::path::Path, +) -> Result<(), Box> { let mut app = App::new(pod_name); - match PodClient::connect(&socket_path).await { + match PodClient::connect(socket_path).await { Ok(mut client) => { app.connected = true; let _ = client.send(&Method::GetHistory).await; - run_loop(&mut terminal, &mut app, client).await?; + run_loop(terminal, &mut app, client).await?; } Err(e) => { - app.output_queue.push(app::OutputItem::Padded( - app::MessageKind::Error, - format!("Failed to connect to {}: {e}", socket_path.display()), - )); - ui::flush_output(&mut terminal, &mut app)?; - terminal.draw(|f| ui::draw(f, &app))?; + app.push_error(format!("Failed to connect to {}: {e}", socket_path.display())); + terminal.draw(|f| ui::draw(f, &mut app))?; run_disconnected(&mut app)?; } } - - terminal::disable_raw_mode()?; - Ok(()) } @@ -95,7 +114,6 @@ async fn run_loop( app: &mut App, mut client: PodClient, ) -> Result<(), Box> { - // Initial draw of the viewport terminal.draw(|f| ui::draw(f, app))?; loop { @@ -104,37 +122,38 @@ async fn run_loop( } tokio::select! { - // Terminal input _ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => { while event::poll(std::time::Duration::ZERO)? { - if let TermEvent::Key(key) = event::read()? { - if let Some(method) = handle_key(app, key) { - client.send(&method).await?; + match event::read()? { + TermEvent::Key(key) => { + if let Some(method) = handle_key(app, key) { + client.send(&method).await?; + } } - if app.quit { - break; + TermEvent::Paste(s) => { + app.insert_paste(s); } + TermEvent::Resize(_, _) => { + // No-op: next draw repaints in full. + } + _ => {} + } + if app.quit { + break; } } } - // Pod events (disabled after disconnect) event = client.next_event(), if app.connected => { match event { Some(ev) => app.handle_pod_event(ev), None => { app.connected = false; - app.output_queue.push(app::OutputItem::Padded( - app::MessageKind::Error, - "Connection lost".into(), - )); + app.push_error("Connection lost"); } } } } - // Flush any queued output above the viewport - ui::flush_output(terminal, app)?; - // Redraw the fixed viewport (status + input) terminal.draw(|f| ui::draw(f, app))?; } @@ -143,37 +162,77 @@ async fn run_loop( fn run_disconnected(_app: &mut App) -> Result<(), Box> { loop { - if event::poll(std::time::Duration::from_millis(100))? { - if let TermEvent::Key(key) = event::read()? { - if let KeyCode::Char('c') = key.code { - if key.modifiers.contains(KeyModifiers::CONTROL) { - break; - } - } - } + if event::poll(std::time::Duration::from_millis(100))? + && let TermEvent::Key(key) = event::read()? + && let KeyCode::Char('c') = key.code + && key.modifiers.contains(KeyModifiers::CONTROL) + { + break; } } Ok(()) } fn handle_key(app: &mut App, key: KeyEvent) -> Option { + let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); + let shift = key.modifiers.contains(KeyModifiers::SHIFT); + let alt = key.modifiers.contains(KeyModifiers::ALT); + + // Scroll / navigation (history view). match key.code { - KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => { - handle_pause_or_quit(app) + KeyCode::Up if shift => { + app.scroll.scroll_up(1); + return None; } - KeyCode::Char('x') if key.modifiers.contains(KeyModifiers::CONTROL) => { + KeyCode::Down if shift => { + app.scroll.scroll_down(1); + return None; + } + KeyCode::PageUp => { + app.scroll.page_up(); + return None; + } + KeyCode::PageDown => { + app.scroll.page_down(); + return None; + } + KeyCode::Home if ctrl => { + app.scroll.to_top(); + return None; + } + KeyCode::End if ctrl => { + app.scroll.to_bottom(); + return None; + } + KeyCode::Char('[') if ctrl => { + app.scroll.jump_prev_turn(); + return None; + } + KeyCode::Char(']') if ctrl => { + app.scroll.jump_next_turn(); + return None; + } + KeyCode::Char('o') if ctrl => { + app.mode = app.mode.cycle(); + return None; + } + _ => {} + } + + match key.code { + KeyCode::Char('c') if ctrl => handle_pause_or_quit(app), + KeyCode::Char('x') if ctrl => { if app.running { Some(Method::Cancel) } else { - app.output_queue.push(app::OutputItem::Padded( - app::MessageKind::Error, - "Nothing to cancel (Pod is not running).".into(), - )); + app.push_error("Nothing to cancel (Pod is not running)."); None } } - KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => { - return handle_shutdown(app); + KeyCode::Char('d') if ctrl => handle_shutdown(app), + KeyCode::Enter if alt => { + app.insert_newline(); + None } KeyCode::Enter => app.submit_input(), KeyCode::Backspace => { @@ -192,6 +251,14 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option { app.move_cursor_right(); None } + KeyCode::Up => { + app.move_cursor_up(); + None + } + KeyCode::Down => { + app.move_cursor_down(); + None + } KeyCode::Home => { app.move_cursor_home(); None @@ -214,17 +281,14 @@ fn handle_shutdown(app: &mut App) -> Option { if !app.running { return Some(Method::Shutdown); } - if let Some(t) = app.shutdown_confirm { - if t.elapsed() < CONFIRM_TIMEOUT { - app.shutdown_confirm = None; - return Some(Method::Shutdown); - } + if let Some(t) = app.shutdown_confirm + && t.elapsed() < CONFIRM_TIMEOUT + { + app.shutdown_confirm = None; + return Some(Method::Shutdown); } app.shutdown_confirm = Some(std::time::Instant::now()); - app.output_queue.push(app::OutputItem::Padded( - app::MessageKind::Error, - "Turn is running. Press Ctrl-D again to cancel and shut down.".into(), - )); + app.push_error("Turn is running. Press Ctrl-D again to cancel and shut down."); None } @@ -234,17 +298,14 @@ fn handle_pause_or_quit(app: &mut App) -> Option { if app.running { return Some(Method::Pause); } - if let Some(t) = app.quit_confirm { - if t.elapsed() < CONFIRM_TIMEOUT { - app.quit_confirm = None; - app.quit = true; - return None; - } + if let Some(t) = app.quit_confirm + && t.elapsed() < CONFIRM_TIMEOUT + { + app.quit_confirm = None; + app.quit = true; + return None; } app.quit_confirm = Some(std::time::Instant::now()); - app.output_queue.push(app::OutputItem::Padded( - app::MessageKind::Error, - "Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).".into(), - )); + app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running)."); None } diff --git a/crates/tui/src/scroll.rs b/crates/tui/src/scroll.rs new file mode 100644 index 00000000..447d6890 --- /dev/null +++ b/crates/tui/src/scroll.rs @@ -0,0 +1,117 @@ +//! History-view scroll state. +//! +//! Anchored at the top: `top_offset` counts logical lines (pre-wrap) from +//! the top of the history buffer. `follow_tail` is sticky — when set, the +//! draw step recomputes `top_offset` every frame so the latest line is +//! pinned at the bottom of the viewport. New content appended at the tail +//! only shifts the visible range when `follow_tail` is true; when the +//! user has scrolled up manually, their view stays put. + +pub struct Scroll { + pub follow_tail: bool, + pub top_offset: usize, + /// Cached by the renderer so key handlers can page or jump without + /// recomputing layout. + pub area_height: u16, + pub total_lines: usize, + /// Wrap-aware: the smallest `top_offset` that still leaves the last + /// logical line pinned to the bottom row. Scrolling past this would + /// just show empty space, so it doubles as the clamp for manual + /// scroll actions. Updated every frame by the renderer. + pub tail_top_offset: usize, + /// Line indices where each turn starts. Used for Ctrl-[/] navigation. + pub turn_starts: Vec, +} + +impl Default for Scroll { + fn default() -> Self { + Self { + follow_tail: true, + top_offset: 0, + area_height: 0, + total_lines: 0, + tail_top_offset: 0, + turn_starts: Vec::new(), + } + } +} + +impl Scroll { + /// Maximum valid `top_offset` given the cached totals. Beyond this + /// the viewport would show only blank space past the tail. Uses the + /// wrap-aware offset so long CJK / wrapped lines stay visible. + pub fn max_top_offset(&self) -> usize { + self.tail_top_offset + } + + pub fn scroll_up(&mut self, n: usize) { + self.follow_tail = false; + self.top_offset = self.top_offset.saturating_sub(n); + } + + pub fn scroll_down(&mut self, n: usize) { + let max = self.max_top_offset(); + let next = self.top_offset.saturating_add(n).min(max); + self.top_offset = next; + if next >= max { + self.follow_tail = true; + } + } + + pub fn page_up(&mut self) { + let step = self.area_height.saturating_sub(1).max(1) as usize; + self.scroll_up(step); + } + + pub fn page_down(&mut self) { + let step = self.area_height.saturating_sub(1).max(1) as usize; + self.scroll_down(step); + } + + pub fn to_top(&mut self) { + self.follow_tail = false; + self.top_offset = 0; + } + + pub fn to_bottom(&mut self) { + self.follow_tail = true; + } + + pub fn jump_prev_turn(&mut self) { + // Find the last turn start strictly above the current top. + let current = self.top_offset; + let target = self + .turn_starts + .iter() + .rev() + .copied() + .find(|&start| start < current); + if let Some(t) = target { + self.follow_tail = false; + self.top_offset = t; + } else if !self.turn_starts.is_empty() { + // Already at or above the first turn; pin to top. + self.follow_tail = false; + self.top_offset = 0; + } + } + + pub fn jump_next_turn(&mut self) { + let current = self.top_offset; + let target = self + .turn_starts + .iter() + .copied() + .find(|&start| start > current); + if let Some(t) = target { + self.follow_tail = false; + let max = self.max_top_offset(); + self.top_offset = t.min(max); + if self.top_offset >= max { + self.follow_tail = true; + } + } else { + self.to_bottom(); + } + } +} diff --git a/crates/tui/src/tool.rs b/crates/tui/src/tool.rs new file mode 100644 index 00000000..216577df --- /dev/null +++ b/crates/tui/src/tool.rs @@ -0,0 +1,538 @@ +//! Per-tool renderers. +//! +//! Each tool name has a custom renderer that converts a +//! [`ToolCallBlock`] into styled lines. Dispatch is by name; unknown +//! tools fall back to [`render_default`]. Some renderers (notably +//! `Read`) consume multiple consecutive blocks to produce a single +//! aggregate display. + +use ratatui::style::{Color, Modifier, Style}; +use ratatui::text::{Line, Span}; + +use crate::block::{Block, ToolCallBlock, ToolCallState}; +use crate::cache::FileCache; +use crate::ui::Mode; + +/// Maximum body lines in normal mode for tool output previews. +const NORMAL_MAX_BODY: usize = 5; +/// Width of the context window used by the Edit diff renderer. +const EDIT_DIFF_CONTEXT: usize = 3; + +pub struct ToolRenderOutput { + pub lines: Vec>, + /// How many blocks were consumed from `blocks[start..]`. Always >= 1. + pub consumed: usize, +} + +pub fn render_tool( + cache: &FileCache, + blocks: &[Block], + start: usize, + mode: Mode, +) -> ToolRenderOutput { + let Some(Block::ToolCall(tc)) = blocks.get(start) else { + return ToolRenderOutput { + lines: Vec::new(), + consumed: 1, + }; + }; + + match tc.name.as_str() { + "Read" => render_read_aggregate(blocks, start, mode), + "Write" => single(render_write(cache, tc, mode)), + "Edit" => single(render_edit(cache, tc, mode)), + "Glob" => single(render_search(tc, mode, "Glob")), + "Grep" => single(render_search(tc, mode, "Grep")), + _ => single(render_default(tc, mode)), + } +} + +fn single(lines: Vec>) -> ToolRenderOutput { + ToolRenderOutput { lines, consumed: 1 } +} + +// --------------------------------------------------------------------- +// Read (aggregating) +// --------------------------------------------------------------------- + +fn render_read_aggregate(blocks: &[Block], start: usize, mode: Mode) -> ToolRenderOutput { + let mut end = start + 1; + while end < blocks.len() { + match &blocks[end] { + Block::ToolCall(t) if t.name == "Read" => end += 1, + _ => break, + } + } + + let group: Vec<&ToolCallBlock> = blocks[start..end] + .iter() + .filter_map(|b| match b { + Block::ToolCall(tc) => Some(tc), + _ => None, + }) + .collect(); + + let in_progress = group + .iter() + .any(|tc| !matches!(tc.state, ToolCallState::Done { .. } | ToolCallState::Error { .. } | ToolCallState::Incomplete)); + + let paths: Vec = group.iter().map(|tc| read_path(tc)).collect(); + let count = paths.len(); + + let tool_style = Style::default().fg(Color::Cyan); + let mut lines: Vec> = Vec::new(); + + let header = if in_progress { + format!("[tool] Read — reading ({count} file{}…)", plural(count)) + } else { + format!("[tool] Read — {count} file{} read", plural(count)) + }; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(header, tool_style), + ])); + + if matches!(mode, Mode::Overview) { + return ToolRenderOutput { + lines, + consumed: end - start, + }; + } + + // Sliding window of 3 most-recent files while in progress; + // full list when finished. + let path_style = Style::default().fg(Color::White); + let limit = if in_progress { 3 } else { paths.len() }; + let start_idx = paths.len().saturating_sub(limit); + for p in &paths[start_idx..] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(p.clone(), path_style), + ])); + } + if in_progress && paths.len() > limit { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("… ({} earlier)", paths.len() - limit), + Style::default().fg(Color::DarkGray), + ), + ])); + } + + ToolRenderOutput { + lines, + consumed: end - start, + } +} + +fn read_path(tc: &ToolCallBlock) -> String { + parsed_args(tc) + .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) + .unwrap_or_else(|| "?".to_owned()) +} + +// --------------------------------------------------------------------- +// Write +// --------------------------------------------------------------------- + +fn render_write(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec> { + let args = parsed_args(tc); + let path = args + .as_ref() + .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) + .unwrap_or_else(|| "?".to_owned()); + let content_preview = args + .as_ref() + .and_then(|v| v["content"].as_str().map(|s| s.to_owned())) + .unwrap_or_default(); + + let action_is_overwrite = cache.get(&path).is_some(); + let label = if action_is_overwrite { + "Overwrote" + } else { + "Created" + }; + let label_style = if action_is_overwrite { + Style::default().fg(Color::Yellow) + } else { + Style::default().fg(Color::Green) + }; + let tool_style = Style::default().fg(Color::Cyan); + + if matches!(mode, Mode::Overview) { + return vec![Line::from(vec![ + Span::raw(" "), + Span::styled("[tool] Write — ".to_owned(), tool_style), + Span::styled(format!("{label} "), label_style), + Span::styled(path, Style::default().fg(Color::White)), + ])]; + } + + let mut lines = vec![ + Line::from(vec![ + Span::raw(" "), + Span::styled("[tool] Write — ".to_owned(), tool_style), + Span::styled(format!("{label} "), label_style), + Span::styled(path.clone(), Style::default().fg(Color::White)), + Span::styled( + format!(" ({})", state_suffix(&tc.state)), + Style::default().fg(Color::DarkGray), + ), + ]), + ]; + + // Body preview. + let cap = match mode { + Mode::Normal => NORMAL_MAX_BODY, + Mode::Detail => usize::MAX, + Mode::Overview => unreachable!(), + }; + let body_lines: Vec<&str> = content_preview.lines().collect(); + let shown = body_lines.len().min(cap); + let body_style = Style::default().fg(Color::Gray); + for l in &body_lines[..shown] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled((*l).to_owned(), body_style), + ])); + } + if body_lines.len() > shown { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("… +{} more lines", body_lines.len() - shown), + Style::default().fg(Color::DarkGray), + ), + ])); + } + + maybe_error_line(&mut lines, &tc.state); + lines +} + +// --------------------------------------------------------------------- +// Edit +// --------------------------------------------------------------------- + +fn render_edit(cache: &FileCache, tc: &ToolCallBlock, mode: Mode) -> Vec> { + let args = parsed_args(tc); + let path = args + .as_ref() + .and_then(|v| v["file_path"].as_str().map(|s| s.to_owned())) + .unwrap_or_else(|| "?".to_owned()); + let old = args + .as_ref() + .and_then(|v| v["old_string"].as_str().map(|s| s.to_owned())) + .unwrap_or_default(); + let new = args + .as_ref() + .and_then(|v| v["new_string"].as_str().map(|s| s.to_owned())) + .unwrap_or_default(); + + let tool_style = Style::default().fg(Color::Cyan); + let header = Line::from(vec![ + Span::raw(" "), + Span::styled(format!("[tool] Edit — {}", path), tool_style), + Span::styled( + format!(" ({})", state_suffix(&tc.state)), + Style::default().fg(Color::DarkGray), + ), + ]); + + if matches!(mode, Mode::Overview) { + return vec![header]; + } + + let mut lines = vec![header]; + + // Best-effort diff. Uses the cached content as the "before" snapshot + // so what we show is consistent with the TUI's own state even if + // the on-disk file has since diverged. + let diff_lines = cache + .get(&path) + .map(|content| build_edit_diff(content, &old, &new)); + if let Some(diff) = diff_lines { + for l in diff { + lines.push(l); + } + } else { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "(no cached content — run Read first for a diff view)".to_owned(), + Style::default().fg(Color::DarkGray), + ), + ])); + } + + maybe_error_line(&mut lines, &tc.state); + lines +} + +fn build_edit_diff(content: &str, old: &str, new: &str) -> Vec> { + // Locate the first (and typically only) match. + let Some(idx) = content.find(old) else { + return vec![Line::from(vec![ + Span::raw(" "), + Span::styled( + "(old_string not found in cached content)".to_owned(), + Style::default().fg(Color::DarkGray), + ), + ])]; + }; + let end = idx + old.len(); + + // Convert byte ranges to line ranges. + let before = &content[..idx]; + let line_of_idx = before.lines().count(); + // `lines()` omits a trailing empty line when the text ends in \n — + // fine for our purposes (we only need approximate line ranges). + let replaced_line_count = content[idx..end].lines().count().max(1); + + let all_lines: Vec<&str> = content.lines().collect(); + let ctx_start = line_of_idx.saturating_sub(EDIT_DIFF_CONTEXT); + let ctx_end = (line_of_idx + replaced_line_count + EDIT_DIFF_CONTEXT).min(all_lines.len()); + + let ctx_style = Style::default().fg(Color::Gray); + let minus_style = Style::default().fg(Color::Red); + let plus_style = Style::default().fg(Color::Green); + let mut lines: Vec> = Vec::new(); + + for l in &all_lines[ctx_start..line_of_idx] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!(" {l}"), ctx_style), + ])); + } + for l in old.lines() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("-{l}"), minus_style), + ])); + } + for l in new.lines() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!("+{l}"), plus_style), + ])); + } + for l in &all_lines[line_of_idx + replaced_line_count..ctx_end] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(format!(" {l}"), ctx_style), + ])); + } + + lines +} + +// --------------------------------------------------------------------- +// Glob / Grep +// --------------------------------------------------------------------- + +fn render_search(tc: &ToolCallBlock, mode: Mode, label: &str) -> Vec> { + let tool_style = Style::default().fg(Color::Cyan); + let summary_source: String = match &tc.state { + ToolCallState::Done { summary, .. } | ToolCallState::Error { summary, .. } => { + summary.clone() + } + _ => String::new(), + }; + + if matches!(mode, Mode::Overview) { + let first = summary_source + .lines() + .next() + .unwrap_or(state_suffix(&tc.state)) + .to_owned(); + return vec![Line::from(vec![ + Span::raw(" "), + Span::styled(format!("[tool] {label} — "), tool_style), + Span::styled(first, Style::default().fg(Color::White)), + ])]; + } + + let mut lines = vec![Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("[tool] {label} — {}", state_suffix(&tc.state)), + tool_style, + ), + ])]; + + let cap = match mode { + Mode::Normal => NORMAL_MAX_BODY, + Mode::Detail => usize::MAX, + Mode::Overview => unreachable!(), + }; + let body_lines: Vec<&str> = summary_source.lines().collect(); + let shown = body_lines.len().min(cap); + let body_style = Style::default().fg(Color::Gray); + for l in &body_lines[..shown] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled((*l).to_owned(), body_style), + ])); + } + if body_lines.len() > shown { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("… +{} more lines", body_lines.len() - shown), + Style::default().fg(Color::DarkGray), + ), + ])); + } + + lines +} + +// --------------------------------------------------------------------- +// Default (unknown tool) +// --------------------------------------------------------------------- + +fn render_default(tc: &ToolCallBlock, mode: Mode) -> Vec> { + let tool_style = Style::default().fg(Color::Cyan); + + if matches!(mode, Mode::Overview) { + let suffix = match &tc.state { + ToolCallState::Done { summary, .. } | ToolCallState::Error { summary, .. } => { + summary.lines().next().unwrap_or("").to_owned() + } + _ => state_suffix(&tc.state).to_owned(), + }; + let label = if suffix.is_empty() { + format!("[tool] {} — {}", tc.name, state_suffix(&tc.state)) + } else { + format!("[tool] {} — {suffix}", tc.name) + }; + return vec![Line::from(vec![ + Span::raw(" "), + Span::styled(label, tool_style), + ])]; + } + + let mut lines = vec![Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("[tool] {} — {}", tc.name, state_suffix(&tc.state)), + tool_style, + ), + ])]; + + let args_pretty = parsed_args(tc) + .and_then(|v| serde_json::to_string_pretty(&v).ok()) + .unwrap_or_else(|| tc.args_stream.clone()); + let arg_cap = match mode { + Mode::Normal => 3, + Mode::Detail => usize::MAX, + Mode::Overview => unreachable!(), + }; + emit_capped_lines( + &mut lines, + &args_pretty, + arg_cap, + Style::default().fg(Color::DarkGray).add_modifier(Modifier::ITALIC), + ); + + let summary_source: String = match &tc.state { + ToolCallState::Done { summary, .. } | ToolCallState::Error { summary, .. } => { + summary.clone() + } + _ => String::new(), + }; + let summary_cap = match mode { + Mode::Normal => 3, + Mode::Detail => usize::MAX, + Mode::Overview => unreachable!(), + }; + if !summary_source.is_empty() { + emit_capped_lines( + &mut lines, + &summary_source, + summary_cap, + Style::default().fg(Color::Gray), + ); + } + + lines +} + +// --------------------------------------------------------------------- +// Helpers +// --------------------------------------------------------------------- + +fn parsed_args(tc: &ToolCallBlock) -> Option { + tc.arguments + .as_ref() + .and_then(|s| serde_json::from_str::(s).ok()) +} + +fn state_suffix(state: &ToolCallState) -> &'static str { + match state { + ToolCallState::Pending => "pending", + ToolCallState::Streaming => "streaming args", + ToolCallState::Executing => "running", + ToolCallState::Done { .. } => "done", + ToolCallState::Error { .. } => "error", + ToolCallState::Incomplete => "incomplete", + } +} + +fn maybe_error_line(lines: &mut Vec>, state: &ToolCallState) { + match state { + ToolCallState::Error { summary, .. } => { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("error: {}", first_line(summary)), + Style::default().fg(Color::Red), + ), + ])); + } + ToolCallState::Incomplete => { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + "(no result before turn ended)".to_owned(), + Style::default().fg(Color::Red), + ), + ])); + } + _ => {} + } +} + +fn emit_capped_lines( + out: &mut Vec>, + text: &str, + cap: usize, + style: Style, +) { + let all: Vec<&str> = text.lines().collect(); + let shown = all.len().min(cap); + for l in &all[..shown] { + out.push(Line::from(vec![ + Span::raw(" "), + Span::styled((*l).to_owned(), style), + ])); + } + if all.len() > shown { + out.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("… +{} more lines", all.len() - shown), + Style::default().fg(Color::DarkGray), + ), + ])); + } +} + +fn first_line(s: &str) -> &str { + s.lines().next().unwrap_or("") +} + +fn plural(n: usize) -> &'static str { + if n == 1 { "" } else { "s" } +} diff --git a/crates/tui/src/ui.rs b/crates/tui/src/ui.rs index 75585e29..ec9fd220 100644 --- a/crates/tui/src/ui.rs +++ b/crates/tui/src/ui.rs @@ -1,131 +1,380 @@ +//! Full-screen rendering for the TUI. +//! +//! The layout is stacked top-to-bottom: +//! +//! ```text +//! history view (fills remaining space) +//! ──────────── separator ────────── +//! status line (1 row) +//! > input area (1 row in Phase 1) +//! ``` +//! +//! Every frame we walk the entire `App::blocks` vector, produce styled +//! lines, and render the tail that fits the history area. No +//! `insert_before` use — the terminal scrollback stays untouched. + use ratatui::Frame; -use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect}; +use ratatui::layout::{Constraint, Layout, Position, Rect}; use ratatui::style::{Color, Modifier, Style}; use ratatui::text::{Line, Span}; -use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap}; -use unicode_width::UnicodeWidthStr; +use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap}; -use protocol::Greeting; +use protocol::{Greeting, NotificationLevel}; -use crate::app::{App, MessageKind, OutputItem, fmt_tokens}; +use crate::app::{App, fmt_tokens, notification_source_label}; +use crate::block::{Block, CompactEvent}; -/// Draw the fixed viewport (3 lines: separator, status, input). -pub fn draw(frame: &mut Frame, app: &App) { - let area = frame.area(); - let chunks = Layout::vertical([ - Constraint::Length(1), // separator - Constraint::Length(1), // status - Constraint::Length(1), // input - ]) - .split(area); - - draw_separator(frame, chunks[0]); - draw_status(frame, app, chunks[1]); - draw_input(frame, app, chunks[2]); +/// Display density for the history view. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mode { + /// Every block fully expanded. + Detail, + /// Completed blocks compressed to roughly 5–6 lines; in-progress + /// tool blocks stay in detail. + Normal, + /// Each block rendered as a single line. + Overview, } -/// Flush queued output items above the inline viewport via insert_before. -pub fn flush_output( - terminal: &mut ratatui::Terminal>, - app: &mut App, -) -> std::io::Result<()> { - let items: Vec = app.output_queue.drain(..).collect(); - if items.is_empty() { - return Ok(()); - } - - let width = terminal.size()?.width; - - for item in items { - match item { - OutputItem::Blank => { - terminal.insert_before(1, |buf| { - // empty line - let _ = buf; - })?; - } - OutputItem::TurnHeader(text) => { - terminal.insert_before(1, |buf| { - let style = kind_style(&MessageKind::TurnHeader); - Paragraph::new(Line::from(Span::styled(text, style))).render(buf.area, buf); - })?; - } - OutputItem::Padded(kind, text) => { - let style = kind_style(&kind); - let lines: Vec = text - .lines() - .map(|l| Line::from(Span::styled(l.to_owned(), style))) - .collect(); - let height = wrapped_height(&lines, width.saturating_sub(1)); - terminal.insert_before(height, |buf| { - Paragraph::new(lines) - .block(Block::default().padding(Padding::left(1))) - .wrap(Wrap { trim: false }) - .render(buf.area, buf); - })?; - } - OutputItem::GreetingCard(g) => { - let lines = greeting_lines(&g); - let inner_width = width.saturating_sub(4); - let body_height: u16 = lines - .iter() - .map(|l| { - let w = l.width() as u16; - if inner_width == 0 || w == 0 { - 1 - } else { - w.div_ceil(inner_width) - } - }) - .sum(); - let height = body_height + 2; // top + bottom border - terminal.insert_before(height, |buf| { - Paragraph::new(lines) - .block( - Block::default() - .borders(Borders::ALL) - .border_type(BorderType::Rounded) - .border_style(Style::default().fg(Color::DarkGray)) - .padding(Padding::horizontal(1)), - ) - .wrap(Wrap { trim: false }) - .render(buf.area, buf); - })?; - } - OutputItem::PaddedRight(kind, text) => { - let style = kind_style(&kind); - let lines: Vec = text - .lines() - .map(|l| { - Line::from(Span::styled(l.to_owned(), style)).alignment(Alignment::Right) - }) - .collect(); - let height = wrapped_height(&lines, width.saturating_sub(1)); - terminal.insert_before(height, |buf| { - Paragraph::new(lines) - .block(Block::default().padding(Padding::left(1))) - .wrap(Wrap { trim: false }) - .render(buf.area, buf); - })?; - } +impl Mode { + pub fn cycle(self) -> Self { + match self { + Mode::Detail => Mode::Normal, + Mode::Normal => Mode::Overview, + Mode::Overview => Mode::Detail, } } - Ok(()) + pub fn label(self) -> &'static str { + match self { + Mode::Detail => "detail", + Mode::Normal => "normal", + Mode::Overview => "overview", + } + } } -fn wrapped_height(lines: &[Line], avail_width: u16) -> u16 { - if avail_width == 0 { - return lines.len().max(1) as u16; +pub fn draw(frame: &mut Frame, app: &mut App) { + let area = frame.area(); + // Input content starts after the "> " / " " prompt, so the width + // available for wrapping is two columns narrower than the frame. + let input_content_width = area.width.saturating_sub(2).max(1); + let input_render = app.input.render(input_content_width); + let input_height = input_area_height(&input_render, area.height); + + let chunks = Layout::vertical([ + Constraint::Min(0), // history view + Constraint::Length(1), // separator + Constraint::Length(1), // status + Constraint::Length(input_height), // input area + ]) + .split(area); + + draw_history(frame, app, chunks[0]); + draw_separator(frame, chunks[1]); + draw_status(frame, app, chunks[2]); + draw_input(frame, &input_render, chunks[3]); +} + +/// Cap the input area so it doesn't eat the history view: grows with the +/// buffer but never past `min(10, terminal_height / 3)`. +fn input_area_height(render: &crate::input::InputRender, terminal_height: u16) -> u16 { + let needed = render.lines.len().max(1) as u16; + let cap = (terminal_height / 3).max(1).min(10); + needed.clamp(1, cap) +} + +/// Pre-rendered history lines plus the line indices at which each turn +/// begins (used for Ctrl-[/] jumps). +pub struct HistoryLayout { + pub lines: Vec>, + pub turn_starts: Vec, +} + +pub fn compute_history(app: &App, width: u16) -> HistoryLayout { + let mut lines: Vec> = Vec::new(); + let mut turn_starts: Vec = Vec::new(); + let mut first = true; + let mut i = 0; + while i < app.blocks.len() { + if !first { + lines.push(Line::from("")); + } + first = false; + let block = &app.blocks[i]; + if matches!(block, Block::TurnHeader { .. }) { + turn_starts.push(lines.len()); + } + // Tool calls route through the per-tool renderer, which may + // consume multiple adjacent blocks (Read aggregation). + if matches!(block, Block::ToolCall(_)) { + let out = crate::tool::render_tool(&app.cache, &app.blocks, i, app.mode); + lines.extend(out.lines); + i += out.consumed.max(1); + continue; + } + render_block_into(&mut lines, block, width, app.mode); + i += 1; + } + HistoryLayout { lines, turn_starts } +} + +/// Maximum body lines a normal-mode block may emit before truncation. +const NORMAL_MAX_LINES: usize = 6; + +fn draw_history(frame: &mut Frame, app: &mut App, area: Rect) { + if area.height == 0 || area.width == 0 { + app.scroll.area_height = area.height; + app.scroll.total_lines = 0; + app.scroll.tail_top_offset = 0; + app.scroll.turn_starts.clear(); + return; + } + let width = area.width; + let HistoryLayout { lines, turn_starts } = compute_history(app, width); + + // Cache for key handlers. Computing `tail_top_offset` wrap-aware + // — i.e. in post-wrap terminal rows — is what keeps long CJK + // responses visible at the tail; otherwise the naive + // `total_lines - area_height` formula under-counts rows and the + // viewport anchors too far up. + let tail_top = compute_tail_top_offset(&lines, area.height, width); + app.scroll.area_height = area.height; + app.scroll.total_lines = lines.len(); + app.scroll.tail_top_offset = tail_top; + app.scroll.turn_starts = turn_starts; + + if app.scroll.follow_tail { + app.scroll.top_offset = tail_top; + } else { + app.scroll.top_offset = app.scroll.top_offset.min(tail_top); + } + + let visible = visible_slice(&lines, app.scroll.top_offset, area.height, width); + Paragraph::new(visible) + .wrap(Wrap { trim: false }) + .render(area, frame.buffer_mut()); +} + +/// Smallest top offset that still keeps the last logical line on screen +/// once wrapping is applied. Walks the lines from the tail and counts +/// wrapped rows; returns the first line index that no longer fits. +fn compute_tail_top_offset(lines: &[Line<'_>], area_height: u16, width: u16) -> usize { + if lines.is_empty() || area_height == 0 { + return 0; + } + let mut used: u32 = 0; + let cap = area_height as u32; + for (i, line) in lines.iter().enumerate().rev() { + let h = wrapped_line_height(line, width) as u32; + if used + h > cap { + return i + 1; + } + used += h; + } + 0 +} + +fn visible_slice( + lines: &[Line<'static>], + top_offset: usize, + area_height: u16, + width: u16, +) -> Vec> { + if lines.is_empty() || area_height == 0 { + return Vec::new(); + } + let mut out: Vec> = Vec::new(); + let mut used: u32 = 0; + for line in lines.iter().skip(top_offset) { + let h = wrapped_line_height(line, width) as u32; + if used + h > area_height as u32 { + break; + } + out.push(line.clone()); + used += h; + if used >= area_height as u32 { + break; + } + } + out +} + +fn wrapped_line_height(line: &Line, width: u16) -> u16 { + if width == 0 { + return 1; + } + let w = line.width() as u16; + if w == 0 { 1 } else { w.div_ceil(width) } +} + +fn render_block_into( + lines: &mut Vec>, + block: &Block, + width: u16, + mode: Mode, +) { + match block { + Block::Greeting(g) => match mode { + Mode::Overview => { + let text = format!("{} {} ({})", g.pod_name, g.model, g.provider); + lines.push(Line::from(Span::styled( + text, + Style::default().fg(Color::Cyan), + ))); + } + _ => render_greeting(lines, g, width), + }, + Block::TurnHeader { turn } => { + lines.push(Line::from(Span::styled( + format!("#{turn}"), + kind_style(MessageKind::TurnHeader), + ))); + } + Block::UserMessage { text } => match mode { + Mode::Overview => push_overview_line(lines, text, MessageKind::User, "> "), + _ => push_padded_truncated(lines, text, MessageKind::User, mode), + }, + Block::AssistantText { text } => match mode { + Mode::Overview => push_overview_line(lines, text, MessageKind::Assistant, ""), + _ => push_padded_truncated(lines, text, MessageKind::Assistant, mode), + }, + // ToolCall is dispatched in `compute_history` via `tool::render_tool` + // so it can consume multiple adjacent blocks (Read aggregation). + Block::ToolCall(_) => unreachable!("ToolCall handled by compute_history"), + Block::Notification { + level, + source, + message, + } => { + let kind = match level { + NotificationLevel::Warn => MessageKind::NoticeWarn, + NotificationLevel::Error => MessageKind::NoticeError, + }; + let prefix = match level { + NotificationLevel::Warn => "[notice]", + NotificationLevel::Error => "[notice error]", + }; + let label = notification_source_label(*source); + let text = format!("{prefix} {label}: {message}"); + match mode { + Mode::Overview => push_overview_line(lines, &text, kind, ""), + _ => push_padded_truncated(lines, &text, kind, mode), + } + } + Block::Compact(evt) => render_compact(lines, evt, mode), + Block::TurnStats { + requests, + input_tokens, + output_tokens, + } => { + let text = format!( + "{} reqs ↑{}/↓{}", + requests, + fmt_tokens(*input_tokens), + fmt_tokens(*output_tokens), + ); + lines.push( + Line::from(Span::styled(text, kind_style(MessageKind::TurnStats))) + .alignment(ratatui::layout::Alignment::Right), + ); + } + } +} + +fn push_padded_lines(lines: &mut Vec>, text: &str, kind: MessageKind) { + let style = kind_style(kind); + for raw in text.lines() { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled(raw.to_owned(), style), + ])); + } + if text.is_empty() { + lines.push(Line::from("")); + } +} + +/// Normal / detail padded text: detail prints every line; normal caps at +/// `NORMAL_MAX_LINES` and appends a "+N more" footer. +fn push_padded_truncated( + lines: &mut Vec>, + text: &str, + kind: MessageKind, + mode: Mode, +) { + if matches!(mode, Mode::Detail) { + push_padded_lines(lines, text, kind); + return; + } + let style = kind_style(kind); + let all: Vec<&str> = text.lines().collect(); + let shown = all.len().min(NORMAL_MAX_LINES); + for raw in &all[..shown] { + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled((*raw).to_owned(), style), + ])); + } + if all.len() > shown { + let hidden = all.len() - shown; + lines.push(Line::from(vec![ + Span::raw(" "), + Span::styled( + format!("… +{hidden} more lines"), + Style::default().fg(Color::DarkGray), + ), + ])); + } + if text.is_empty() { + lines.push(Line::from("")); + } +} + +/// Single-line summary for overview mode. First non-empty line of the +/// source text, with an optional prefix (e.g. "> " for user messages). +fn push_overview_line( + lines: &mut Vec>, + text: &str, + kind: MessageKind, + prefix: &str, +) { + let first = text.lines().find(|l| !l.trim().is_empty()).unwrap_or(""); + let more = text.lines().count().saturating_sub(1); + let style = kind_style(kind); + let mut spans = vec![Span::raw(" ")]; + if !prefix.is_empty() { + spans.push(Span::styled(prefix.to_owned(), style)); + } + spans.push(Span::styled(first.to_owned(), style)); + if more > 0 { + spans.push(Span::styled( + format!(" (+{more} lines)"), + Style::default().fg(Color::DarkGray), + )); + } + lines.push(Line::from(spans)); +} + +fn render_compact(lines: &mut Vec>, evt: &CompactEvent, mode: Mode) { + let (text, kind) = match evt { + CompactEvent::Start => ("[compact] starting".to_owned(), MessageKind::NoticeWarn), + CompactEvent::Done { new_session_id } => { + let short = new_session_id.to_string().chars().take(8).collect::(); + ( + format!("[compact] done (new session {short})"), + MessageKind::NoticeWarn, + ) + } + CompactEvent::Failed { error } => ( + format!("[compact error] {error}"), + MessageKind::NoticeError, + ), + }; + match mode { + Mode::Overview => push_overview_line(lines, &text, kind, ""), + _ => push_padded_lines(lines, &text, kind), } - lines - .iter() - .map(|line| { - let w = line.width() as u16; - if w == 0 { 1 } else { w.div_ceil(avail_width) } - }) - .sum::() - .max(1) } fn draw_separator(frame: &mut Frame, area: Rect) { @@ -149,7 +398,10 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { let mut spans = vec![ conn, Span::raw(" "), - Span::styled(&app.pod_name, Style::default().add_modifier(Modifier::BOLD)), + Span::styled( + app.pod_name.clone(), + Style::default().add_modifier(Modifier::BOLD), + ), ]; if app.running { @@ -186,19 +438,83 @@ fn draw_status(frame: &mut Frame, app: &App, area: Rect) { spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray))); } + // Right-aligned mode / scroll indicator. + let mut right: Vec> = Vec::new(); + if !app.scroll.follow_tail { + right.push(Span::styled( + "↑ scrolled ", + Style::default().fg(Color::Yellow), + )); + } + right.push(Span::styled( + format!("[{}]", app.mode.label()), + Style::default().fg(Color::DarkGray), + )); + let right_line = Line::from(right).alignment(ratatui::layout::Alignment::Right); + frame.render_widget(Paragraph::new(Line::from(spans)), area); + frame.render_widget(Paragraph::new(right_line), area); } -fn draw_input(frame: &mut Frame, app: &App, area: Rect) { - let line = Line::from(vec![ - Span::styled("> ", Style::default().fg(Color::DarkGray)), - Span::raw(&app.input), - ]); - frame.render_widget(Paragraph::new(line), area); +fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) { + // Prefix "> " on the first row, two-space gutter for continuation + // rows so multi-line input aligns visually. + let prompt_style = Style::default().fg(Color::DarkGray); + let mut lines: Vec> = Vec::with_capacity(render.lines.len()); + for (i, src) in render.lines.iter().enumerate() { + let prefix = if i == 0 { "> " } else { " " }; + let mut spans = vec![Span::styled(prefix.to_owned(), prompt_style)]; + spans.extend(src.spans.iter().cloned()); + lines.push(Line::from(spans)); + } + frame.render_widget(Paragraph::new(lines), area); - let cursor_x = area.x + 2 + UnicodeWidthStr::width(&app.input[..app.cursor]) as u16; - let cursor_y = area.y; - frame.set_cursor_position(Position::new(cursor_x, cursor_y)); + let cursor_x = area.x + 2 + render.cursor_col; + let cursor_y = area.y + render.cursor_row; + if cursor_y < area.y + area.height { + frame.set_cursor_position(Position::new(cursor_x, cursor_y)); + } +} + +fn render_greeting(lines: &mut Vec>, g: &Greeting, width: u16) { + let inner = greeting_lines(g); + let border_style = Style::default().fg(Color::DarkGray); + + // Render greeting into its own buffer so we can turn it into lines + // for the outer history stream. Use a fixed width = area width. + let box_width = width.min(80); + let mut body_height: u16 = 0; + let inner_width = box_width.saturating_sub(4); + for l in &inner { + let w = l.width() as u16; + body_height += if inner_width == 0 || w == 0 { + 1 + } else { + w.div_ceil(inner_width) + }; + } + let total_height = body_height + 2; + let area = Rect::new(0, 0, box_width, total_height); + let mut buf = ratatui::buffer::Buffer::empty(area); + Paragraph::new(inner) + .block( + UiBlock::default() + .borders(Borders::ALL) + .border_type(BorderType::Rounded) + .border_style(border_style) + .padding(Padding::horizontal(1)), + ) + .wrap(Wrap { trim: false }) + .render(area, &mut buf); + + for y in 0..total_height { + let mut spans: Vec> = Vec::new(); + for x in 0..box_width { + let cell = &buf[(x, y)]; + spans.push(Span::styled(cell.symbol().to_string(), cell.style())); + } + lines.push(Line::from(spans)); + } } fn greeting_lines(g: &Greeting) -> Vec> { @@ -236,13 +552,21 @@ fn greeting_lines(g: &Greeting) -> Vec> { lines } -pub fn kind_style(kind: &MessageKind) -> Style { +#[derive(Clone, Copy)] +pub enum MessageKind { + TurnHeader, + User, + Assistant, + TurnStats, + NoticeWarn, + NoticeError, +} + +pub fn kind_style(kind: MessageKind) -> Style { match kind { MessageKind::TurnHeader => Style::default().fg(Color::DarkGray), MessageKind::User => Style::default().fg(Color::Green), MessageKind::Assistant => Style::default().fg(Color::White), - MessageKind::Tool => Style::default().fg(Color::Cyan), - MessageKind::Error => Style::default().fg(Color::Red), MessageKind::TurnStats => Style::default().fg(Color::DarkGray), MessageKind::NoticeWarn => Style::default() .fg(Color::Black) @@ -255,4 +579,3 @@ pub fn kind_style(kind: &MessageKind) -> Style { } } -use ratatui::widgets::Widget; diff --git a/docs/tui-keybindings.md b/docs/tui-keybindings.md index 1a72cd56..9b7133ad 100644 --- a/docs/tui-keybindings.md +++ b/docs/tui-keybindings.md @@ -7,10 +7,20 @@ | キー | 動作 | |---|---| | 文字キー | カーソル位置に挿入 | -| `Backspace` | カーソル直前を削除 | -| `Delete` | カーソル直後を削除 | +| `Backspace` | カーソル直前を削除(ペーストプレースホルダは 1 回で全削除) | +| `Delete` | カーソル直後を削除(同上) | | `Left` / `Right` | カーソル移動 | -| `Home` / `End` | 行頭 / 行末へ | +| `Up` / `Down` | 論理行単位の上下移動(桁位置を保持) | +| `Home` / `End` | 現在行の行頭 / 行末へ | +| `Alt-Enter` | 改行を挿入(Input を複数行化) | + +### ペーストプレースホルダ + +ブラケットペーストで入力されたテキストは入力バッファ上では不可分な +プレースホルダ `[Clipboard #N | X chars, Y lines]` として表示される。 +実テキストは裏で保持され、送信時に `#N` ラベル無しで展開されて Pod に渡る。 +カーソルはプレースホルダ内部には入れず、`Backspace` / `Delete` 1 回で +プレースホルダ全体が消える。`#N` の通し番号は TUI プロセス起動中で連番。 ## 送信(Enter) @@ -31,6 +41,50 @@ Paused 中に Enter すると、入力の有無で 2 通り: 3. 入力を新しい user メッセージとして append 4. ターン開始 +## 履歴ビューのナビゲーション + +履歴ビューは全画面 TUI の上段にあり、TUI 内部で全ブロックを state として +保持してスクロール可能。スクロールバック(端末側の履歴)ではなく TUI の +自前バッファを動かす点に注意。 + +| キー | 動作 | +|---|---| +| `Shift-Up` / `Shift-Down` | 1 論理行スクロール | +| `PageUp` / `PageDown` | 1 ページスクロール(`area_height - 1` 行) | +| `Ctrl-[` / `Ctrl-]` | 前 / 次のターン先頭へジャンプ | +| `Ctrl-Home` | 履歴の先頭へ | +| `Ctrl-End` | 履歴の末尾へ(末尾追従モードを再開) | + +### 末尾追従 + +デフォルトは「末尾追従」モード:新しいイベント到着で自動的に最新行が +画面下に固定される。ユーザーが上方向にスクロールした瞬間に追従は解除され、 +その位置で固定される(新着イベントが来ても画面は動かない)。再び追従に +戻すには `Ctrl-End`。追従解除中はステータスバー右に `↑ scrolled` が点く。 + +### ターン単位ジャンプ + +`Ctrl-[` / `Ctrl-]` は TurnHeader ブロックの位置にスクロールオフセットを +合わせる。多数のツール呼び出しが挟まった長いターンでも前後ターンの先頭に +1 発で戻れる。末尾ターンから `Ctrl-]` を押すと末尾追従に復帰する。 + +## 表示モード + +履歴各ブロックの密度を 3 段階で切り替える。現在のモードはステータスバー +右端に `[mode]` として表示される。 + +| キー | 動作 | +|---|---| +| `Ctrl-O` | `detail` → `normal` → `overview` → `detail` の順に循環 | + +- **detail**: 全ブロック完全表示。ツールブロックは結果本体も全量 +- **normal**: 完了ブロックは概ね 5–6 行に圧縮、実行中のツールブロックは + detail と同じ扱い +- **overview**: 各ブロック 1 行に畳む(ツールブロックは + `ToolResult.summary` 1 行、長文 AssistantText は先頭 1 行 + 省略記) + +モード切替は全体に一括適用。個別ブロックの開閉は持たない。 + ## Pod 制御 | キー | Running 中 | Idle / Paused | @@ -61,3 +115,7 @@ Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`( - かつて存在した `Ctrl-R`(Resume 専用)は、空 Enter での Resume に統合されたため廃止 - かつて存在した `Esc`(TUI 終了)は、`Ctrl-C` の 2 連打 UX に統合されたため廃止 +- 旧 inline viewport 時代は履歴スクロールを端末側スクロールバックに + 任せていたため TUI 内のスクロールキーは存在しなかった。全画面 alt screen + への移行(`tickets/tui-fullscreen-overhaul.md`)で `Shift-Up/Down` ほか + のナビゲーションキーが追加された