TUIのオーバーホール実装
This commit is contained in:
parent
9bf6378041
commit
84fedd8048
1
Cargo.lock
generated
1
Cargo.lock
generated
|
|
@ -3543,6 +3543,7 @@ dependencies = [
|
||||||
"serde_json",
|
"serde_json",
|
||||||
"tokio",
|
"tokio",
|
||||||
"unicode-width",
|
"unicode-width",
|
||||||
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
|
||||||
|
|
@ -11,3 +11,4 @@ crossterm = "0.28"
|
||||||
tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
tokio = { version = "1.49", features = ["rt-multi-thread", "macros", "net", "io-util", "sync", "time"] }
|
||||||
serde_json = "1.0"
|
serde_json = "1.0"
|
||||||
unicode-width = "0.2.2"
|
unicode-width = "0.2.2"
|
||||||
|
uuid = "1.23"
|
||||||
|
|
|
||||||
|
|
@ -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 struct App {
|
||||||
pub pod_name: String,
|
pub pod_name: String,
|
||||||
|
|
@ -13,41 +19,22 @@ pub struct App {
|
||||||
pub run_output_tokens: u64,
|
pub run_output_tokens: u64,
|
||||||
pub turn_index: usize,
|
pub turn_index: usize,
|
||||||
pub current_tool: Option<String>,
|
pub current_tool: Option<String>,
|
||||||
pub input: String,
|
pub input: InputBuffer,
|
||||||
pub cursor: usize,
|
|
||||||
pub quit: bool,
|
pub quit: bool,
|
||||||
pub shutdown_confirm: Option<std::time::Instant>,
|
pub shutdown_confirm: Option<std::time::Instant>,
|
||||||
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
/// 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
|
/// records the instant; a second press within the timeout exits the
|
||||||
/// TUI (the Pod itself stays alive).
|
/// TUI (the Pod itself stays alive).
|
||||||
pub quit_confirm: Option<std::time::Instant>,
|
pub quit_confirm: Option<std::time::Instant>,
|
||||||
/// Lines waiting to be flushed to terminal via insert_before.
|
/// Full display history in render order.
|
||||||
pub output_queue: Vec<OutputItem>,
|
pub blocks: Vec<Block>,
|
||||||
/// Partial streaming text not yet terminated by newline.
|
pub scroll: Scroll,
|
||||||
pending_text: String,
|
pub mode: Mode,
|
||||||
}
|
pub cache: FileCache,
|
||||||
|
/// True when the latest AssistantText block is still being streamed
|
||||||
/// A unit of output to push above the inline viewport.
|
/// and future text deltas should append to it instead of starting a
|
||||||
pub enum OutputItem {
|
/// fresh block.
|
||||||
TurnHeader(String),
|
assistant_streaming: bool,
|
||||||
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,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
|
|
@ -62,38 +49,46 @@ impl App {
|
||||||
run_output_tokens: 0,
|
run_output_tokens: 0,
|
||||||
turn_index: 0,
|
turn_index: 0,
|
||||||
current_tool: None,
|
current_tool: None,
|
||||||
input: String::new(),
|
input: InputBuffer::new(),
|
||||||
cursor: 0,
|
|
||||||
quit: false,
|
quit: false,
|
||||||
shutdown_confirm: None,
|
shutdown_confirm: None,
|
||||||
quit_confirm: None,
|
quit_confirm: None,
|
||||||
output_queue: Vec::new(),
|
blocks: Vec::new(),
|
||||||
pending_text: String::new(),
|
scroll: Scroll::default(),
|
||||||
|
mode: Mode::Normal,
|
||||||
|
cache: FileCache::new(),
|
||||||
|
assistant_streaming: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn submit_input(&mut self) -> Option<Method> {
|
pub fn submit_input(&mut self) -> Option<Method> {
|
||||||
let text = self.input.trim().to_owned();
|
let text = self.input.submit_text().trim().to_owned();
|
||||||
if text.is_empty() {
|
if text.is_empty() {
|
||||||
// Empty Enter only does something meaningful when the Pod
|
// Empty Enter only does something meaningful when the Pod
|
||||||
// is paused: resume the interrupted turn. Otherwise no-op.
|
// is paused: resume the interrupted turn. Otherwise no-op.
|
||||||
if self.paused {
|
if self.paused {
|
||||||
|
self.input.clear();
|
||||||
return Some(Method::Resume);
|
return Some(Method::Resume);
|
||||||
}
|
}
|
||||||
return None;
|
return None;
|
||||||
}
|
}
|
||||||
self.turn_index += 1;
|
self.turn_index += 1;
|
||||||
self.output_queue.push(OutputItem::Blank);
|
self.blocks.push(Block::TurnHeader {
|
||||||
self.output_queue
|
turn: self.turn_index,
|
||||||
.push(OutputItem::TurnHeader(format!("#{}", self.turn_index)));
|
});
|
||||||
self.output_queue
|
self.blocks.push(Block::UserMessage { text: text.clone() });
|
||||||
.push(OutputItem::Padded(MessageKind::User, text.clone()));
|
|
||||||
self.output_queue.push(OutputItem::Blank);
|
|
||||||
self.input.clear();
|
self.input.clear();
|
||||||
self.cursor = 0;
|
|
||||||
Some(Method::Run { input: text })
|
Some(Method::Run { input: text })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn push_error(&mut self, message: impl Into<String>) {
|
||||||
|
self.blocks.push(Block::Notification {
|
||||||
|
level: NotificationLevel::Error,
|
||||||
|
source: NotificationSource::Pod,
|
||||||
|
message: message.into(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
pub fn handle_pod_event(&mut self, event: Event) {
|
pub fn handle_pod_event(&mut self, event: Event) {
|
||||||
match event {
|
match event {
|
||||||
Event::TurnStart { .. } => {
|
Event::TurnStart { .. } => {
|
||||||
|
|
@ -101,56 +96,94 @@ impl App {
|
||||||
self.paused = false;
|
self.paused = false;
|
||||||
self.run_requests += 1;
|
self.run_requests += 1;
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::TextDelta { text } => {
|
Event::TextDelta { text } => {
|
||||||
self.pending_text.push_str(&text);
|
self.append_assistant_text(&text);
|
||||||
self.flush_pending_lines();
|
|
||||||
}
|
}
|
||||||
Event::TextDone { .. } => {
|
Event::TextDone { .. } => {
|
||||||
// Flush any remaining partial line
|
self.assistant_streaming = false;
|
||||||
if !self.pending_text.is_empty() {
|
|
||||||
let text = std::mem::take(&mut self.pending_text);
|
|
||||||
self.output_queue
|
|
||||||
.push(OutputItem::Padded(MessageKind::Assistant, text));
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Event::TurnEnd { .. } => {
|
Event::TurnEnd { .. } => {
|
||||||
// Flush streaming text if TextDone wasn't received
|
self.assistant_streaming = false;
|
||||||
if !self.pending_text.is_empty() {
|
self.mark_orphan_tool_calls_incomplete();
|
||||||
let text = std::mem::take(&mut self.pending_text);
|
|
||||||
self.output_queue
|
|
||||||
.push(OutputItem::Padded(MessageKind::Assistant, text));
|
|
||||||
}
|
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
}
|
}
|
||||||
Event::ToolCallStart { name, .. } => {
|
Event::ToolCallStart { id, name } => {
|
||||||
self.current_tool = Some(name.clone());
|
self.current_tool = Some(name.clone());
|
||||||
self.output_queue.push(OutputItem::Padded(
|
self.assistant_streaming = false;
|
||||||
MessageKind::Tool,
|
self.blocks.push(Block::ToolCall(ToolCallBlock {
|
||||||
format!("[tool] {name}"),
|
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 {
|
Event::ToolCallDone {
|
||||||
name, arguments, ..
|
id, arguments, ..
|
||||||
} => {
|
} => {
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
self.output_queue.push(OutputItem::Padded(
|
if let Some(b) = self.find_tool_call_mut(&id) {
|
||||||
MessageKind::Tool,
|
b.arguments = Some(arguments);
|
||||||
format!("[tool] {name} done ({} bytes)", arguments.len()),
|
// 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 {
|
Event::ToolResult {
|
||||||
summary, is_error, ..
|
id,
|
||||||
|
summary,
|
||||||
|
output,
|
||||||
|
is_error,
|
||||||
} => {
|
} => {
|
||||||
let prefix = if is_error {
|
if let Some(b) = self.find_tool_call_mut(&id) {
|
||||||
"[tool error]"
|
// 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 {
|
} else {
|
||||||
"[tool result]"
|
// Result for an unknown tool call. Surface it as a
|
||||||
};
|
// notification so it isn't silently dropped.
|
||||||
self.output_queue.push(OutputItem::Padded(
|
let level = if is_error {
|
||||||
MessageKind::Tool,
|
NotificationLevel::Error
|
||||||
format!("{prefix} {summary}"),
|
} else {
|
||||||
));
|
NotificationLevel::Warn
|
||||||
|
};
|
||||||
|
self.blocks.push(Block::Notification {
|
||||||
|
level,
|
||||||
|
source: NotificationSource::Pod,
|
||||||
|
message: format!("orphan tool result ({id}): {summary}"),
|
||||||
|
});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
Event::Usage {
|
Event::Usage {
|
||||||
input_tokens,
|
input_tokens,
|
||||||
|
|
@ -160,75 +193,42 @@ impl App {
|
||||||
self.run_output_tokens += output_tokens.unwrap_or(0);
|
self.run_output_tokens += output_tokens.unwrap_or(0);
|
||||||
}
|
}
|
||||||
Event::Error { code, message } => {
|
Event::Error { code, message } => {
|
||||||
self.output_queue.push(OutputItem::Padded(
|
self.push_error(format!("[{code:?}] {message}"));
|
||||||
MessageKind::Error,
|
|
||||||
format!("[{code:?}] {message}"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Event::RunEnd { result } => {
|
Event::RunEnd { result } => {
|
||||||
self.output_queue.push(OutputItem::PaddedRight(
|
self.blocks.push(Block::TurnStats {
|
||||||
MessageKind::TurnStats,
|
requests: self.run_requests,
|
||||||
format!(
|
input_tokens: self.run_input_tokens,
|
||||||
"{} reqs ↑{}/↓{}",
|
output_tokens: self.run_output_tokens,
|
||||||
self.run_requests,
|
});
|
||||||
fmt_tokens(self.run_input_tokens),
|
|
||||||
fmt_tokens(self.run_output_tokens),
|
|
||||||
),
|
|
||||||
));
|
|
||||||
self.output_queue.push(OutputItem::Blank);
|
|
||||||
self.running = false;
|
self.running = false;
|
||||||
self.paused = matches!(result, RunResult::Paused);
|
self.paused = matches!(result, RunResult::Paused);
|
||||||
self.run_requests = 0;
|
self.run_requests = 0;
|
||||||
self.run_input_tokens = 0;
|
self.run_input_tokens = 0;
|
||||||
self.run_output_tokens = 0;
|
self.run_output_tokens = 0;
|
||||||
self.current_tool = None;
|
self.current_tool = None;
|
||||||
|
self.assistant_streaming = false;
|
||||||
}
|
}
|
||||||
Event::ToolCallArgsDelta { .. } => {}
|
|
||||||
Event::CompactStart => {
|
Event::CompactStart => {
|
||||||
self.output_queue.push(OutputItem::Padded(
|
self.blocks.push(Block::Compact(CompactEvent::Start));
|
||||||
MessageKind::NoticeWarn,
|
|
||||||
"[compact] starting".to_string(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Event::CompactDone { new_session_id } => {
|
Event::CompactDone { new_session_id } => {
|
||||||
let short = new_session_id
|
self.blocks
|
||||||
.to_string()
|
.push(Block::Compact(CompactEvent::Done { new_session_id }));
|
||||||
.chars()
|
|
||||||
.take(8)
|
|
||||||
.collect::<String>();
|
|
||||||
self.output_queue.push(OutputItem::Padded(
|
|
||||||
MessageKind::NoticeWarn,
|
|
||||||
format!("[compact] done (new session {short})"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Event::CompactFailed { error } => {
|
Event::CompactFailed { error } => {
|
||||||
self.output_queue.push(OutputItem::Padded(
|
self.blocks
|
||||||
MessageKind::NoticeError,
|
.push(Block::Compact(CompactEvent::Failed { error }));
|
||||||
format!("[compact error] {error}"),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Event::Notification(notification) => {
|
Event::Notification(notification) => {
|
||||||
let kind = match notification.level {
|
self.blocks.push(Block::Notification {
|
||||||
NotificationLevel::Warn => MessageKind::NoticeWarn,
|
level: notification.level,
|
||||||
NotificationLevel::Error => MessageKind::NoticeError,
|
source: notification.source,
|
||||||
};
|
message: notification.message,
|
||||||
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),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
Event::History { items, greeting } => {
|
Event::History { items, greeting } => {
|
||||||
self.restore_history(&items);
|
self.restore_history(&items, greeting);
|
||||||
if self.turn_index == 0 {
|
|
||||||
self.output_queue
|
|
||||||
.insert(0, OutputItem::GreetingCard(greeting));
|
|
||||||
self.output_queue.insert(1, OutputItem::Blank);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
Event::Shutdown => {
|
Event::Shutdown => {
|
||||||
self.quit = true;
|
self.quit = true;
|
||||||
|
|
@ -236,128 +236,186 @@ impl App {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Extract complete lines (ending with \n) from pending_text and queue them.
|
fn append_assistant_text(&mut self, text: &str) {
|
||||||
fn flush_pending_lines(&mut self) {
|
if self.assistant_streaming {
|
||||||
while let Some(pos) = self.pending_text.find('\n') {
|
if let Some(Block::AssistantText { text: existing }) = self.blocks.last_mut() {
|
||||||
let line = self.pending_text[..pos].to_owned();
|
existing.push_str(text);
|
||||||
self.pending_text = self.pending_text[pos + 1..].to_owned();
|
return;
|
||||||
self.output_queue
|
}
|
||||||
.push(OutputItem::Padded(MessageKind::Assistant, line));
|
}
|
||||||
|
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) {
|
pub fn insert_char(&mut self, c: char) {
|
||||||
self.input.insert(self.cursor, c);
|
self.input.insert_char(c);
|
||||||
self.cursor += c.len_utf8();
|
}
|
||||||
|
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) {
|
pub fn delete_char_before(&mut self) {
|
||||||
if self.cursor > 0 {
|
self.input.delete_before();
|
||||||
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn delete_char_after(&mut self) {
|
pub fn delete_char_after(&mut self) {
|
||||||
if self.cursor < self.input.len() {
|
self.input.delete_after();
|
||||||
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);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_cursor_left(&mut self) {
|
pub fn move_cursor_left(&mut self) {
|
||||||
if self.cursor > 0 {
|
self.input.move_left();
|
||||||
self.cursor = self.input[..self.cursor]
|
|
||||||
.char_indices()
|
|
||||||
.next_back()
|
|
||||||
.map(|(i, _)| i)
|
|
||||||
.unwrap_or(0);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_cursor_right(&mut self) {
|
pub fn move_cursor_right(&mut self) {
|
||||||
if self.cursor < self.input.len() {
|
self.input.move_right();
|
||||||
self.cursor = self.input[self.cursor..]
|
|
||||||
.char_indices()
|
|
||||||
.nth(1)
|
|
||||||
.map(|(i, _)| self.cursor + i)
|
|
||||||
.unwrap_or(self.input.len());
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_cursor_home(&mut self) {
|
pub fn move_cursor_home(&mut self) {
|
||||||
self.cursor = 0;
|
self.input.move_home();
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn move_cursor_end(&mut self) {
|
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.turn_index = 0;
|
||||||
|
self.blocks.clear();
|
||||||
|
self.cache = FileCache::new();
|
||||||
|
self.blocks.push(Block::Greeting(greeting));
|
||||||
|
self.assistant_streaming = false;
|
||||||
|
|
||||||
for item in items {
|
for item in items {
|
||||||
let item_type = item["type"].as_str().unwrap_or("");
|
let item_type = item["type"].as_str().unwrap_or("");
|
||||||
match item_type {
|
match item_type {
|
||||||
"message" => {
|
"message" => {
|
||||||
let role = item["role"].as_str().unwrap_or("");
|
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"]
|
let text = item["content"]
|
||||||
.as_array()
|
.as_array()
|
||||||
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next())
|
.and_then(|parts| parts.iter().filter_map(|p| p["text"].as_str()).next())
|
||||||
.unwrap_or("");
|
.unwrap_or("")
|
||||||
if !text.is_empty() {
|
.to_owned();
|
||||||
self.output_queue
|
match role {
|
||||||
.push(OutputItem::Padded(kind, text.to_owned()));
|
"user" => {
|
||||||
if matches!(kind, MessageKind::User) {
|
self.turn_index += 1;
|
||||||
self.output_queue.push(OutputItem::Blank);
|
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" => {
|
"tool_call" => {
|
||||||
let name = item["name"].as_str().unwrap_or("?");
|
// `Item::ToolCall` serializes the linking key as
|
||||||
self.output_queue.push(OutputItem::Padded(
|
// `call_id`; `id` is a separate optional item-level
|
||||||
MessageKind::Tool,
|
// identifier. Use `call_id` so this matches how
|
||||||
format!("[tool] {name}"),
|
// 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" => {
|
"tool_result" => {
|
||||||
let summary = item["summary"].as_str().unwrap_or("");
|
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
||||||
self.output_queue.push(OutputItem::Padded(
|
let summary = item["summary"].as_str().unwrap_or("").to_owned();
|
||||||
MessageKind::Tool,
|
let output = item["content"].as_str().map(|s| s.to_owned());
|
||||||
format!("[tool result] {summary}"),
|
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 {
|
// Any tool_call entries that never got paired with a
|
||||||
match source {
|
// tool_result (truncated or racing mid-turn on the server side)
|
||||||
NotificationSource::Pod => "pod",
|
// stay as Executing up to this point. Surface them as
|
||||||
NotificationSource::Worker => "worker",
|
// Incomplete so the replay matches live semantics.
|
||||||
NotificationSource::Compactor => "compactor",
|
for b in self.blocks.iter_mut() {
|
||||||
NotificationSource::AgentsMd => "AGENTS.md",
|
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()
|
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::<serde_json::Value>(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);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
66
crates/tui/src/block.rs
Normal file
66
crates/tui/src/block.rs
Normal file
|
|
@ -0,0 +1,66 @@
|
||||||
|
//! History blocks: the unit of the TUI's stored display model.
|
||||||
|
//!
|
||||||
|
//! The TUI holds a flat `Vec<Block>` 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<String>,
|
||||||
|
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<String> },
|
||||||
|
/// `ToolResult { is_error: true, .. }` received.
|
||||||
|
Error { summary: String, output: Option<String> },
|
||||||
|
/// Turn ended before a matching `ToolResult` arrived.
|
||||||
|
Incomplete,
|
||||||
|
}
|
||||||
52
crates/tui/src/cache.rs
Normal file
52
crates/tui/src/cache.rs
Normal file
|
|
@ -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<String, String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl FileCache {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn put(&mut self, path: impl Into<String>, content: impl Into<String>) {
|
||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
404
crates/tui/src/input.rs
Normal file
404
crates/tui/src/input.rs
Normal file
|
|
@ -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<Atom>,
|
||||||
|
/// 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<Span<'static>>> = 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<Line<'static>> = 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<Vec<Span<'static>>>,
|
||||||
|
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<Span<'static>>],
|
||||||
|
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<Line<'static>>,
|
||||||
|
pub cursor_row: u16,
|
||||||
|
pub cursor_col: u16,
|
||||||
|
}
|
||||||
|
|
@ -1,15 +1,26 @@
|
||||||
mod app;
|
mod app;
|
||||||
|
mod block;
|
||||||
|
mod cache;
|
||||||
mod client;
|
mod client;
|
||||||
|
mod input;
|
||||||
|
mod scroll;
|
||||||
|
mod tool;
|
||||||
mod ui;
|
mod ui;
|
||||||
|
|
||||||
use std::io;
|
use std::io;
|
||||||
use std::path::PathBuf;
|
use std::path::PathBuf;
|
||||||
|
|
||||||
use crossterm::event::{self, Event as TermEvent, KeyCode, KeyEvent, KeyModifiers};
|
use crossterm::event::{
|
||||||
use crossterm::terminal;
|
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 protocol::Method;
|
||||||
|
use ratatui::Terminal;
|
||||||
use ratatui::backend::CrosstermBackend;
|
use ratatui::backend::CrosstermBackend;
|
||||||
use ratatui::{Terminal, TerminalOptions, Viewport};
|
|
||||||
|
|
||||||
use crate::app::App;
|
use crate::app::App;
|
||||||
use crate::client::PodClient;
|
use crate::client::PodClient;
|
||||||
|
|
@ -56,37 +67,45 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let (pod_name, socket_override) = parse_args();
|
let (pod_name, socket_override) = parse_args();
|
||||||
let socket_path = resolve_socket(&pod_name, socket_override);
|
let socket_path = resolve_socket(&pod_name, socket_override);
|
||||||
|
|
||||||
terminal::enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
|
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
|
||||||
let backend = CrosstermBackend::new(stdout);
|
let backend = CrosstermBackend::new(stdout);
|
||||||
let mut terminal = Terminal::with_options(
|
let mut terminal = Terminal::new(backend)?;
|
||||||
backend,
|
|
||||||
TerminalOptions {
|
|
||||||
viewport: Viewport::Inline(3),
|
|
||||||
},
|
|
||||||
)?;
|
|
||||||
|
|
||||||
|
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<CrosstermBackend<io::Stdout>>,
|
||||||
|
pod_name: String,
|
||||||
|
socket_path: &std::path::Path,
|
||||||
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
let mut app = App::new(pod_name);
|
let mut app = App::new(pod_name);
|
||||||
|
|
||||||
match PodClient::connect(&socket_path).await {
|
match PodClient::connect(socket_path).await {
|
||||||
Ok(mut client) => {
|
Ok(mut client) => {
|
||||||
app.connected = true;
|
app.connected = true;
|
||||||
let _ = client.send(&Method::GetHistory).await;
|
let _ = client.send(&Method::GetHistory).await;
|
||||||
run_loop(&mut terminal, &mut app, client).await?;
|
run_loop(terminal, &mut app, client).await?;
|
||||||
}
|
}
|
||||||
Err(e) => {
|
Err(e) => {
|
||||||
app.output_queue.push(app::OutputItem::Padded(
|
app.push_error(format!("Failed to connect to {}: {e}", socket_path.display()));
|
||||||
app::MessageKind::Error,
|
terminal.draw(|f| ui::draw(f, &mut app))?;
|
||||||
format!("Failed to connect to {}: {e}", socket_path.display()),
|
|
||||||
));
|
|
||||||
ui::flush_output(&mut terminal, &mut app)?;
|
|
||||||
terminal.draw(|f| ui::draw(f, &app))?;
|
|
||||||
run_disconnected(&mut app)?;
|
run_disconnected(&mut app)?;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
terminal::disable_raw_mode()?;
|
|
||||||
|
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -95,7 +114,6 @@ async fn run_loop(
|
||||||
app: &mut App,
|
app: &mut App,
|
||||||
mut client: PodClient,
|
mut client: PodClient,
|
||||||
) -> Result<(), Box<dyn std::error::Error>> {
|
) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
// Initial draw of the viewport
|
|
||||||
terminal.draw(|f| ui::draw(f, app))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
|
|
@ -104,37 +122,38 @@ async fn run_loop(
|
||||||
}
|
}
|
||||||
|
|
||||||
tokio::select! {
|
tokio::select! {
|
||||||
// Terminal input
|
|
||||||
_ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => {
|
_ = tokio::task::spawn_blocking(|| event::poll(std::time::Duration::from_millis(50))) => {
|
||||||
while event::poll(std::time::Duration::ZERO)? {
|
while event::poll(std::time::Duration::ZERO)? {
|
||||||
if let TermEvent::Key(key) = event::read()? {
|
match event::read()? {
|
||||||
if let Some(method) = handle_key(app, key) {
|
TermEvent::Key(key) => {
|
||||||
client.send(&method).await?;
|
if let Some(method) = handle_key(app, key) {
|
||||||
|
client.send(&method).await?;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
if app.quit {
|
TermEvent::Paste(s) => {
|
||||||
break;
|
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 => {
|
event = client.next_event(), if app.connected => {
|
||||||
match event {
|
match event {
|
||||||
Some(ev) => app.handle_pod_event(ev),
|
Some(ev) => app.handle_pod_event(ev),
|
||||||
None => {
|
None => {
|
||||||
app.connected = false;
|
app.connected = false;
|
||||||
app.output_queue.push(app::OutputItem::Padded(
|
app.push_error("Connection lost");
|
||||||
app::MessageKind::Error,
|
|
||||||
"Connection lost".into(),
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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))?;
|
terminal.draw(|f| ui::draw(f, app))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -143,37 +162,77 @@ async fn run_loop(
|
||||||
|
|
||||||
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
fn run_disconnected(_app: &mut App) -> Result<(), Box<dyn std::error::Error>> {
|
||||||
loop {
|
loop {
|
||||||
if event::poll(std::time::Duration::from_millis(100))? {
|
if event::poll(std::time::Duration::from_millis(100))?
|
||||||
if let TermEvent::Key(key) = event::read()? {
|
&& let TermEvent::Key(key) = event::read()?
|
||||||
if let KeyCode::Char('c') = key.code {
|
&& let KeyCode::Char('c') = key.code
|
||||||
if key.modifiers.contains(KeyModifiers::CONTROL) {
|
&& key.modifiers.contains(KeyModifiers::CONTROL)
|
||||||
break;
|
{
|
||||||
}
|
break;
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
|
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 {
|
match key.code {
|
||||||
KeyCode::Char('c') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Up if shift => {
|
||||||
handle_pause_or_quit(app)
|
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 {
|
if app.running {
|
||||||
Some(Method::Cancel)
|
Some(Method::Cancel)
|
||||||
} else {
|
} else {
|
||||||
app.output_queue.push(app::OutputItem::Padded(
|
app.push_error("Nothing to cancel (Pod is not running).");
|
||||||
app::MessageKind::Error,
|
|
||||||
"Nothing to cancel (Pod is not running).".into(),
|
|
||||||
));
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('d') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('d') if ctrl => handle_shutdown(app),
|
||||||
return handle_shutdown(app);
|
KeyCode::Enter if alt => {
|
||||||
|
app.insert_newline();
|
||||||
|
None
|
||||||
}
|
}
|
||||||
KeyCode::Enter => app.submit_input(),
|
KeyCode::Enter => app.submit_input(),
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
|
|
@ -192,6 +251,14 @@ fn handle_key(app: &mut App, key: KeyEvent) -> Option<Method> {
|
||||||
app.move_cursor_right();
|
app.move_cursor_right();
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
KeyCode::Up => {
|
||||||
|
app.move_cursor_up();
|
||||||
|
None
|
||||||
|
}
|
||||||
|
KeyCode::Down => {
|
||||||
|
app.move_cursor_down();
|
||||||
|
None
|
||||||
|
}
|
||||||
KeyCode::Home => {
|
KeyCode::Home => {
|
||||||
app.move_cursor_home();
|
app.move_cursor_home();
|
||||||
None
|
None
|
||||||
|
|
@ -214,17 +281,14 @@ fn handle_shutdown(app: &mut App) -> Option<Method> {
|
||||||
if !app.running {
|
if !app.running {
|
||||||
return Some(Method::Shutdown);
|
return Some(Method::Shutdown);
|
||||||
}
|
}
|
||||||
if let Some(t) = app.shutdown_confirm {
|
if let Some(t) = app.shutdown_confirm
|
||||||
if t.elapsed() < CONFIRM_TIMEOUT {
|
&& t.elapsed() < CONFIRM_TIMEOUT
|
||||||
app.shutdown_confirm = None;
|
{
|
||||||
return Some(Method::Shutdown);
|
app.shutdown_confirm = None;
|
||||||
}
|
return Some(Method::Shutdown);
|
||||||
}
|
}
|
||||||
app.shutdown_confirm = Some(std::time::Instant::now());
|
app.shutdown_confirm = Some(std::time::Instant::now());
|
||||||
app.output_queue.push(app::OutputItem::Padded(
|
app.push_error("Turn is running. Press Ctrl-D again to cancel and shut down.");
|
||||||
app::MessageKind::Error,
|
|
||||||
"Turn is running. Press Ctrl-D again to cancel and shut down.".into(),
|
|
||||||
));
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -234,17 +298,14 @@ fn handle_pause_or_quit(app: &mut App) -> Option<Method> {
|
||||||
if app.running {
|
if app.running {
|
||||||
return Some(Method::Pause);
|
return Some(Method::Pause);
|
||||||
}
|
}
|
||||||
if let Some(t) = app.quit_confirm {
|
if let Some(t) = app.quit_confirm
|
||||||
if t.elapsed() < CONFIRM_TIMEOUT {
|
&& t.elapsed() < CONFIRM_TIMEOUT
|
||||||
app.quit_confirm = None;
|
{
|
||||||
app.quit = true;
|
app.quit_confirm = None;
|
||||||
return None;
|
app.quit = true;
|
||||||
}
|
return None;
|
||||||
}
|
}
|
||||||
app.quit_confirm = Some(std::time::Instant::now());
|
app.quit_confirm = Some(std::time::Instant::now());
|
||||||
app.output_queue.push(app::OutputItem::Padded(
|
app.push_error("Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).");
|
||||||
app::MessageKind::Error,
|
|
||||||
"Press Ctrl-C again within 3 s to exit the TUI (the Pod keeps running).".into(),
|
|
||||||
));
|
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
|
|
||||||
117
crates/tui/src/scroll.rs
Normal file
117
crates/tui/src/scroll.rs
Normal file
|
|
@ -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<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
538
crates/tui/src/tool.rs
Normal file
538
crates/tui/src/tool.rs
Normal file
|
|
@ -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<Line<'static>>,
|
||||||
|
/// 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<Line<'static>>) -> 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<String> = group.iter().map(|tc| read_path(tc)).collect();
|
||||||
|
let count = paths.len();
|
||||||
|
|
||||||
|
let tool_style = Style::default().fg(Color::Cyan);
|
||||||
|
let mut lines: Vec<Line<'static>> = 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<Line<'static>> {
|
||||||
|
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<Line<'static>> {
|
||||||
|
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<Line<'static>> {
|
||||||
|
// 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<Line<'static>> = 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<Line<'static>> {
|
||||||
|
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<Line<'static>> {
|
||||||
|
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<serde_json::Value> {
|
||||||
|
tc.arguments
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(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<Line<'static>>, 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<Line<'static>>,
|
||||||
|
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" }
|
||||||
|
}
|
||||||
|
|
@ -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::Frame;
|
||||||
use ratatui::layout::{Alignment, Constraint, Layout, Position, Rect};
|
use ratatui::layout::{Constraint, Layout, Position, Rect};
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
use ratatui::text::{Line, Span};
|
use ratatui::text::{Line, Span};
|
||||||
use ratatui::widgets::{Block, BorderType, Borders, Padding, Paragraph, Wrap};
|
use ratatui::widgets::{Block as UiBlock, BorderType, Borders, Padding, Paragraph, Widget, Wrap};
|
||||||
use unicode_width::UnicodeWidthStr;
|
|
||||||
|
|
||||||
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).
|
/// Display density for the history view.
|
||||||
pub fn draw(frame: &mut Frame, app: &App) {
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
let area = frame.area();
|
pub enum Mode {
|
||||||
let chunks = Layout::vertical([
|
/// Every block fully expanded.
|
||||||
Constraint::Length(1), // separator
|
Detail,
|
||||||
Constraint::Length(1), // status
|
/// Completed blocks compressed to roughly 5–6 lines; in-progress
|
||||||
Constraint::Length(1), // input
|
/// tool blocks stay in detail.
|
||||||
])
|
Normal,
|
||||||
.split(area);
|
/// Each block rendered as a single line.
|
||||||
|
Overview,
|
||||||
draw_separator(frame, chunks[0]);
|
|
||||||
draw_status(frame, app, chunks[1]);
|
|
||||||
draw_input(frame, app, chunks[2]);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Flush queued output items above the inline viewport via insert_before.
|
impl Mode {
|
||||||
pub fn flush_output(
|
pub fn cycle(self) -> Self {
|
||||||
terminal: &mut ratatui::Terminal<ratatui::backend::CrosstermBackend<std::io::Stdout>>,
|
match self {
|
||||||
app: &mut App,
|
Mode::Detail => Mode::Normal,
|
||||||
) -> std::io::Result<()> {
|
Mode::Normal => Mode::Overview,
|
||||||
let items: Vec<OutputItem> = app.output_queue.drain(..).collect();
|
Mode::Overview => Mode::Detail,
|
||||||
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<Line> = 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<Line> = 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);
|
|
||||||
})?;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
pub fn draw(frame: &mut Frame, app: &mut App) {
|
||||||
if avail_width == 0 {
|
let area = frame.area();
|
||||||
return lines.len().max(1) as u16;
|
// 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<Line<'static>>,
|
||||||
|
pub turn_starts: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn compute_history(app: &App, width: u16) -> HistoryLayout {
|
||||||
|
let mut lines: Vec<Line<'static>> = Vec::new();
|
||||||
|
let mut turn_starts: Vec<usize> = 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<Line<'static>> {
|
||||||
|
if lines.is_empty() || area_height == 0 {
|
||||||
|
return Vec::new();
|
||||||
|
}
|
||||||
|
let mut out: Vec<Line<'static>> = 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<Line<'static>>,
|
||||||
|
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<Line<'static>>, 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<Line<'static>>,
|
||||||
|
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<Line<'static>>,
|
||||||
|
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<Line<'static>>, 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::<String>();
|
||||||
|
(
|
||||||
|
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::<u16>()
|
|
||||||
.max(1)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_separator(frame: &mut Frame, area: Rect) {
|
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![
|
let mut spans = vec![
|
||||||
conn,
|
conn,
|
||||||
Span::raw(" "),
|
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 {
|
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)));
|
spans.push(Span::styled(" idle", Style::default().fg(Color::DarkGray)));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Right-aligned mode / scroll indicator.
|
||||||
|
let mut right: Vec<Span<'static>> = 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(Line::from(spans)), area);
|
||||||
|
frame.render_widget(Paragraph::new(right_line), area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn draw_input(frame: &mut Frame, app: &App, area: Rect) {
|
fn draw_input(frame: &mut Frame, render: &crate::input::InputRender, area: Rect) {
|
||||||
let line = Line::from(vec![
|
// Prefix "> " on the first row, two-space gutter for continuation
|
||||||
Span::styled("> ", Style::default().fg(Color::DarkGray)),
|
// rows so multi-line input aligns visually.
|
||||||
Span::raw(&app.input),
|
let prompt_style = Style::default().fg(Color::DarkGray);
|
||||||
]);
|
let mut lines: Vec<Line<'static>> = Vec::with_capacity(render.lines.len());
|
||||||
frame.render_widget(Paragraph::new(line), area);
|
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_x = area.x + 2 + render.cursor_col;
|
||||||
let cursor_y = area.y;
|
let cursor_y = area.y + render.cursor_row;
|
||||||
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
|
if cursor_y < area.y + area.height {
|
||||||
|
frame.set_cursor_position(Position::new(cursor_x, cursor_y));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_greeting(lines: &mut Vec<Line<'static>>, 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<Span<'static>> = 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<Line<'static>> {
|
fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
|
||||||
|
|
@ -236,13 +552,21 @@ fn greeting_lines(g: &Greeting) -> Vec<Line<'static>> {
|
||||||
lines
|
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 {
|
match kind {
|
||||||
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
|
MessageKind::TurnHeader => Style::default().fg(Color::DarkGray),
|
||||||
MessageKind::User => Style::default().fg(Color::Green),
|
MessageKind::User => Style::default().fg(Color::Green),
|
||||||
MessageKind::Assistant => Style::default().fg(Color::White),
|
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::TurnStats => Style::default().fg(Color::DarkGray),
|
||||||
MessageKind::NoticeWarn => Style::default()
|
MessageKind::NoticeWarn => Style::default()
|
||||||
.fg(Color::Black)
|
.fg(Color::Black)
|
||||||
|
|
@ -255,4 +579,3 @@ pub fn kind_style(kind: &MessageKind) -> Style {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
use ratatui::widgets::Widget;
|
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,20 @@
|
||||||
| キー | 動作 |
|
| キー | 動作 |
|
||||||
|---|---|
|
|---|---|
|
||||||
| 文字キー | カーソル位置に挿入 |
|
| 文字キー | カーソル位置に挿入 |
|
||||||
| `Backspace` | カーソル直前を削除 |
|
| `Backspace` | カーソル直前を削除(ペーストプレースホルダは 1 回で全削除) |
|
||||||
| `Delete` | カーソル直後を削除 |
|
| `Delete` | カーソル直後を削除(同上) |
|
||||||
| `Left` / `Right` | カーソル移動 |
|
| `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)
|
## 送信(Enter)
|
||||||
|
|
||||||
|
|
@ -31,6 +41,50 @@ Paused 中に Enter すると、入力の有無で 2 通り:
|
||||||
3. 入力を新しい user メッセージとして append
|
3. 入力を新しい user メッセージとして append
|
||||||
4. ターン開始
|
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 制御
|
## Pod 制御
|
||||||
|
|
||||||
| キー | Running 中 | Idle / Paused |
|
| キー | Running 中 | Idle / Paused |
|
||||||
|
|
@ -61,3 +115,7 @@ Running 中に割り込みたい場合、ほとんどのケースで `Ctrl-C`(
|
||||||
|
|
||||||
- かつて存在した `Ctrl-R`(Resume 専用)は、空 Enter での Resume に統合されたため廃止
|
- かつて存在した `Ctrl-R`(Resume 専用)は、空 Enter での Resume に統合されたため廃止
|
||||||
- かつて存在した `Esc`(TUI 終了)は、`Ctrl-C` の 2 連打 UX に統合されたため廃止
|
- かつて存在した `Esc`(TUI 終了)は、`Ctrl-C` の 2 連打 UX に統合されたため廃止
|
||||||
|
- 旧 inline viewport 時代は履歴スクロールを端末側スクロールバックに
|
||||||
|
任せていたため TUI 内のスクロールキーは存在しなかった。全画面 alt screen
|
||||||
|
への移行(`tickets/tui-fullscreen-overhaul.md`)で `Shift-Up/Down` ほか
|
||||||
|
のナビゲーションキーが追加された
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue
Block a user