3165 lines
112 KiB
Rust
3165 lines
112 KiB
Rust
use std::collections::VecDeque;
|
|
use std::time::{Duration, Instant};
|
|
|
|
use protocol::{
|
|
AlertLevel, AlertSource, CompletionEntry, CompletionKind, Event, Method, PodStatus,
|
|
RewindTarget, RunResult, Segment,
|
|
};
|
|
|
|
use crate::block::{
|
|
Block, CompactEvent, ThinkingBlock, ThinkingState, ToolCallBlock, ToolCallState,
|
|
};
|
|
use crate::cache::FileCache;
|
|
use crate::command::{
|
|
CommandCandidate, CommandEnvironment, CommandExecution, CommandInputMode, CommandRegistry,
|
|
};
|
|
use crate::input::InputBuffer;
|
|
use crate::scroll::Scroll;
|
|
use crate::task::TaskStore;
|
|
use crate::view_mode::Mode;
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum CommandCompletionApply {
|
|
Applied,
|
|
Ambiguous,
|
|
NoCandidates,
|
|
}
|
|
|
|
/// In-flight completion popup state. Lives on `App` while the user is
|
|
/// typing inside a `@` / `#` / `/` token. Cleared whenever the trigger
|
|
/// is invalidated (cursor moved out, whitespace landed inside the
|
|
/// token, the sigil was deleted, or the candidate was confirmed).
|
|
pub struct CompletionState {
|
|
pub kind: CompletionKind,
|
|
/// Atom index of the leading sigil (`@` / `#` / `/`).
|
|
pub prefix_start: usize,
|
|
/// Text typed after the sigil (sigil itself excluded).
|
|
pub prefix: String,
|
|
/// Latest candidate set returned by the Pod for `(kind, prefix)`.
|
|
/// Initially empty until `Event::Completions` lands.
|
|
pub entries: Vec<CompletionEntry>,
|
|
pub selected: usize,
|
|
}
|
|
|
|
impl CompletionState {
|
|
pub fn is_active(&self) -> bool {
|
|
!self.entries.is_empty()
|
|
}
|
|
|
|
/// Maximum rows the popup ever renders. Caller can clip to fewer
|
|
/// rows if vertical space is tight.
|
|
pub const MAX_VISIBLE: usize = 6;
|
|
}
|
|
|
|
#[derive(Debug, Clone, Default)]
|
|
pub struct RewindPickerScroll {
|
|
pub top_offset: usize,
|
|
pub total_lines: usize,
|
|
pub area_height: u16,
|
|
pub tail_top_offset: usize,
|
|
}
|
|
|
|
#[derive(Debug, Clone)]
|
|
pub struct RewindPickerState {
|
|
pub head_entries: usize,
|
|
pub targets: Vec<RewindTarget>,
|
|
pub selected: usize,
|
|
pub scroll: RewindPickerScroll,
|
|
}
|
|
|
|
impl RewindPickerState {
|
|
pub fn new(head_entries: usize, targets: Vec<RewindTarget>) -> Self {
|
|
let selected = targets.iter().position(|t| t.eligible).unwrap_or(0);
|
|
Self {
|
|
head_entries,
|
|
targets,
|
|
selected,
|
|
scroll: RewindPickerScroll::default(),
|
|
}
|
|
}
|
|
|
|
pub fn selected_target(&self) -> Option<&RewindTarget> {
|
|
self.targets.get(self.selected)
|
|
}
|
|
}
|
|
|
|
struct RollbackSubmitState {
|
|
text: String,
|
|
segments: Vec<Segment>,
|
|
block_start: usize,
|
|
turn_before: usize,
|
|
}
|
|
|
|
#[derive(Clone)]
|
|
pub struct QueuedInput {
|
|
segments: Vec<Segment>,
|
|
preview: String,
|
|
}
|
|
|
|
impl QueuedInput {
|
|
fn new(segments: Vec<Segment>) -> Self {
|
|
let preview = Segment::flatten_to_text(&segments);
|
|
Self { segments, preview }
|
|
}
|
|
|
|
pub fn preview(&self) -> &str {
|
|
&self.preview
|
|
}
|
|
}
|
|
|
|
const COMPOSER_INPUT_HISTORY_LIMIT: usize = 100;
|
|
|
|
struct ComposerInputHistory {
|
|
entries: VecDeque<Vec<Segment>>,
|
|
browse: Option<ComposerInputHistoryBrowse>,
|
|
}
|
|
|
|
struct ComposerInputHistoryBrowse {
|
|
index: usize,
|
|
draft: Vec<Segment>,
|
|
}
|
|
|
|
impl ComposerInputHistory {
|
|
fn new() -> Self {
|
|
Self {
|
|
entries: VecDeque::new(),
|
|
browse: None,
|
|
}
|
|
}
|
|
|
|
fn record(&mut self, segments: Vec<Segment>) {
|
|
if segments_are_blank(&segments) {
|
|
return;
|
|
}
|
|
self.browse = None;
|
|
if self.entries.back() == Some(&segments) {
|
|
return;
|
|
}
|
|
if self.entries.len() == COMPOSER_INPUT_HISTORY_LIMIT {
|
|
self.entries.pop_front();
|
|
}
|
|
self.entries.push_back(segments);
|
|
}
|
|
|
|
fn is_browsing(&self) -> bool {
|
|
self.browse.is_some()
|
|
}
|
|
|
|
fn browse_older(&mut self, draft: Vec<Segment>) -> Option<Vec<Segment>> {
|
|
if self.entries.is_empty() {
|
|
return None;
|
|
}
|
|
|
|
let index = match self.browse.as_mut() {
|
|
Some(browse) => {
|
|
if browse.index > 0 {
|
|
browse.index -= 1;
|
|
}
|
|
browse.index
|
|
}
|
|
None => {
|
|
let index = self.entries.len() - 1;
|
|
self.browse = Some(ComposerInputHistoryBrowse { index, draft });
|
|
index
|
|
}
|
|
};
|
|
|
|
self.entries.get(index).cloned()
|
|
}
|
|
|
|
fn browse_newer(&mut self) -> Option<Vec<Segment>> {
|
|
let browse = self.browse.as_mut()?;
|
|
if browse.index + 1 < self.entries.len() {
|
|
browse.index += 1;
|
|
return self.entries.get(browse.index).cloned();
|
|
}
|
|
|
|
self.browse.take().map(|browse| browse.draft)
|
|
}
|
|
|
|
fn cancel_browse(&mut self) {
|
|
self.browse = None;
|
|
}
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
#[allow(dead_code)]
|
|
pub enum ActionbarNoticeLevel {
|
|
Info,
|
|
Warn,
|
|
Error,
|
|
}
|
|
|
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
|
pub enum ActionbarNoticeSource {
|
|
Tui,
|
|
}
|
|
|
|
#[derive(Debug, Clone, PartialEq, Eq)]
|
|
pub struct ActionbarNotice {
|
|
pub text: String,
|
|
pub level: ActionbarNoticeLevel,
|
|
pub source: ActionbarNoticeSource,
|
|
pub expires_at: Instant,
|
|
}
|
|
|
|
impl ActionbarNotice {
|
|
pub fn is_expired(&self, now: Instant) -> bool {
|
|
now >= self.expires_at
|
|
}
|
|
}
|
|
|
|
pub struct App {
|
|
pub pod_name: String,
|
|
pub connected: bool,
|
|
/// Last controller status reported by the Pod. Drives the status line
|
|
/// and Ctrl-key routing; do not infer this solely from replayed history.
|
|
pub pod_status: PodStatus,
|
|
/// True while the Pod is in `PodStatus::Running`.
|
|
pub running: bool,
|
|
/// True while the Pod is in `PodStatus::Paused`.
|
|
pub paused: bool,
|
|
pub run_requests: usize,
|
|
/// Sum of `input_tokens - cache_read_input_tokens` across the
|
|
/// current turn's LLM requests — i.e. the net tokens this turn
|
|
/// actually had to upload at full price (cache writes included,
|
|
/// cache reads excluded). Reset on `RunEnd`.
|
|
pub run_upload_tokens: u64,
|
|
pub run_output_tokens: u64,
|
|
/// Latest session context tokens reported by the Pod. This is the raw
|
|
/// `input_tokens` value and is independent from per-run upload totals.
|
|
pub session_context_tokens: u64,
|
|
pub context_window: u64,
|
|
pub turn_index: usize,
|
|
pub current_tool: Option<String>,
|
|
/// Latest LLM wait/retry lifecycle event for actionbar observability.
|
|
pub latest_llm_wait_event: Option<String>,
|
|
/// Latest memory extract/consolidation lifecycle event for actionbar observability.
|
|
pub latest_memory_worker_event: Option<String>,
|
|
/// Current transient actionbar notice. Notices are local UI state only:
|
|
/// they are never appended to transcript/session history or LLM context.
|
|
actionbar_notice: Option<ActionbarNotice>,
|
|
/// Normal composer input that is submitted as `Method::Run`.
|
|
pub input: InputBuffer,
|
|
/// Separate command-line input. It is never submitted as a user message.
|
|
pub command_input: InputBuffer,
|
|
pub input_mode: CommandInputMode,
|
|
pub command_registry: CommandRegistry,
|
|
command_completion_selected: Option<usize>,
|
|
pub quit: bool,
|
|
/// 2-tap guard for `Ctrl-C` when the Pod is not running. First press
|
|
/// records the instant; a second press within the timeout exits the
|
|
/// TUI (the Pod itself stays alive).
|
|
pub quit_confirm: Option<std::time::Instant>,
|
|
/// Full display history in render order.
|
|
pub blocks: Vec<Block>,
|
|
pub scroll: Scroll,
|
|
pub mode: Mode,
|
|
pub cache: FileCache,
|
|
/// True when the latest AssistantText block is still being streamed
|
|
/// and future text deltas should append to it instead of starting a
|
|
/// fresh block.
|
|
assistant_streaming: bool,
|
|
/// Completion popup state, when an `@` / `#` / `/` token is in
|
|
/// flight. `None` whenever the trigger conditions don't hold.
|
|
pub completion: Option<CompletionState>,
|
|
/// Dedicated main-view rewind picker state.
|
|
pub rewind_picker: Option<RewindPickerState>,
|
|
rewind_request_pending: bool,
|
|
greeting: Option<protocol::Greeting>,
|
|
/// In-TUI mirror of the Pod's session task store, reconstructed
|
|
/// directly from observed `TaskCreate` / `TaskUpdate` tool calls and
|
|
/// `[Session TaskStore snapshot]` system messages — no protocol
|
|
/// surface added on the Pod side.
|
|
pub task_store: TaskStore,
|
|
/// Whether the right-side task pane is currently open.
|
|
pub task_pane_open: bool,
|
|
/// Top entry index of the task pane's visible window. Clamped on
|
|
/// render so it never points past the end of the list.
|
|
pub task_pane_scroll: usize,
|
|
/// TUI-local FIFO of user inputs submitted while the Pod is already running.
|
|
/// Entries have not been sent to the Pod yet, so they remain editable/cancellable locally.
|
|
queued_inputs: VecDeque<QueuedInput>,
|
|
/// TUI-local readline-style composer input history. This is intentionally
|
|
/// client-side only: recalled entries are plain drafts until submitted again.
|
|
input_history: ComposerInputHistory,
|
|
/// Local submit state kept until the accepted run either completes
|
|
/// normally or reports that the empty assistant turn was rolled back.
|
|
pending_submit_rollback: Option<RollbackSubmitState>,
|
|
/// Last rolled-back submit that could not be restored because the
|
|
/// composer already contained unsent user input.
|
|
last_rolled_back_input: Option<Vec<Segment>>,
|
|
}
|
|
|
|
impl App {
|
|
pub fn new(pod_name: String) -> Self {
|
|
Self {
|
|
pod_name,
|
|
connected: false,
|
|
pod_status: PodStatus::Idle,
|
|
running: false,
|
|
paused: false,
|
|
run_requests: 0,
|
|
run_upload_tokens: 0,
|
|
run_output_tokens: 0,
|
|
session_context_tokens: 0,
|
|
context_window: 0,
|
|
turn_index: 0,
|
|
current_tool: None,
|
|
latest_llm_wait_event: None,
|
|
latest_memory_worker_event: None,
|
|
actionbar_notice: None,
|
|
input: InputBuffer::new(),
|
|
command_input: InputBuffer::new(),
|
|
input_mode: CommandInputMode::Composer,
|
|
command_registry: CommandRegistry::default(),
|
|
command_completion_selected: None,
|
|
quit: false,
|
|
quit_confirm: None,
|
|
blocks: Vec::new(),
|
|
scroll: Scroll::default(),
|
|
mode: Mode::Normal,
|
|
cache: FileCache::new(),
|
|
assistant_streaming: false,
|
|
completion: None,
|
|
rewind_picker: None,
|
|
rewind_request_pending: false,
|
|
greeting: None,
|
|
task_store: TaskStore::new(),
|
|
task_pane_open: false,
|
|
task_pane_scroll: 0,
|
|
queued_inputs: VecDeque::new(),
|
|
input_history: ComposerInputHistory::new(),
|
|
pending_submit_rollback: None,
|
|
last_rolled_back_input: None,
|
|
}
|
|
}
|
|
|
|
pub fn toggle_task_pane(&mut self) {
|
|
self.task_pane_open = !self.task_pane_open;
|
|
if !self.task_pane_open {
|
|
self.task_pane_scroll = 0;
|
|
}
|
|
}
|
|
|
|
pub fn scroll_task_pane_up(&mut self, n: usize) {
|
|
self.task_pane_scroll = self.task_pane_scroll.saturating_sub(n);
|
|
}
|
|
|
|
pub fn scroll_task_pane_down(&mut self, n: usize) {
|
|
self.task_pane_scroll = self.task_pane_scroll.saturating_add(n);
|
|
}
|
|
|
|
pub fn set_pod_status(&mut self, status: PodStatus) {
|
|
self.pod_status = status;
|
|
self.running = status == PodStatus::Running;
|
|
self.paused = status == PodStatus::Paused;
|
|
if self.running {
|
|
self.quit_confirm = None;
|
|
}
|
|
}
|
|
|
|
/// Re-evaluate the completion popup against the current input.
|
|
/// Returns a `Method::ListCompletions` to send when the
|
|
/// `(kind, prefix_start, prefix)` triple changed; otherwise `None`.
|
|
/// Callers should invoke this after every input mutation that could
|
|
/// move the cursor or change atoms.
|
|
pub fn refresh_completion(&mut self) -> Option<Method> {
|
|
if self.is_command_mode() {
|
|
self.completion = None;
|
|
return None;
|
|
}
|
|
match self.input.pending_completion_prefix() {
|
|
Some((kind, start, prefix)) => {
|
|
let need_query = match &self.completion {
|
|
Some(c) => c.kind != kind || c.prefix_start != start || c.prefix != prefix,
|
|
None => true,
|
|
};
|
|
let entries = match self.completion.take() {
|
|
Some(c) if c.kind == kind && c.prefix_start == start => c.entries,
|
|
_ => Vec::new(),
|
|
};
|
|
self.completion = Some(CompletionState {
|
|
kind,
|
|
prefix_start: start,
|
|
prefix: prefix.clone(),
|
|
entries,
|
|
selected: 0,
|
|
});
|
|
if need_query {
|
|
Some(Method::ListCompletions { kind, prefix })
|
|
} else {
|
|
None
|
|
}
|
|
}
|
|
None => {
|
|
self.completion = None;
|
|
None
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn move_completion_up(&mut self) {
|
|
if let Some(c) = self.completion.as_mut()
|
|
&& !c.entries.is_empty()
|
|
{
|
|
c.selected = if c.selected == 0 {
|
|
c.entries.len() - 1
|
|
} else {
|
|
c.selected - 1
|
|
};
|
|
}
|
|
}
|
|
|
|
pub fn move_completion_down(&mut self) {
|
|
if let Some(c) = self.completion.as_mut()
|
|
&& !c.entries.is_empty()
|
|
{
|
|
c.selected = (c.selected + 1) % c.entries.len();
|
|
}
|
|
}
|
|
|
|
pub fn cancel_completion(&mut self) {
|
|
self.completion = None;
|
|
}
|
|
|
|
/// Tab path: insert the popup-selected entry's value (with a
|
|
/// trailing `/` when it's a directory) as raw text replacing the
|
|
/// in-flight `@<typed>` portion. The popup state is preserved so
|
|
/// the re-evaluated trigger can fetch fresh candidates for the new
|
|
/// prefix (drill-in for directories, narrow-to-one for files).
|
|
/// Returns the follow-up `Method::ListCompletions` to send when
|
|
/// the new prefix differs from the old one.
|
|
pub fn apply_completion_text(&mut self) -> Option<Method> {
|
|
let state = self.completion.as_ref()?;
|
|
if state.entries.is_empty() {
|
|
return None;
|
|
}
|
|
let entry = &state.entries[state.selected];
|
|
let text = if entry.is_dir {
|
|
format!("{}/", entry.value)
|
|
} else {
|
|
entry.value.clone()
|
|
};
|
|
// `prefix_start` indexes the sigil atom; the text we want to
|
|
// replace lives just after it (sigil itself stays).
|
|
let typed_start = state.prefix_start + 1;
|
|
self.input_history.cancel_browse();
|
|
self.input.replace_with_text_at(typed_start, &text);
|
|
self.refresh_completion()
|
|
}
|
|
|
|
/// Space path: replace the `@<typed>` range with a chip atom and
|
|
/// clear the popup if `prefix` (= the text the user has typed
|
|
/// after the sigil) resolves to a confirmable target. Three
|
|
/// matching modes:
|
|
///
|
|
/// 1. **Direct value match**: some entry's `value` equals `prefix`
|
|
/// (covers files and slash-less directory form).
|
|
/// 2. **Slashed directory match**: some directory entry's
|
|
/// `value + "/"` equals `prefix` (the form Tab inserts).
|
|
/// 3. **Drilled-into-directory match**: `prefix` ends with `/`
|
|
/// and at least one entry lives under it.
|
|
///
|
|
/// Directory chips always carry a trailing `/` so the rendered
|
|
/// label reads `@crates/`.
|
|
///
|
|
/// `selected` is intentionally ignored — terminating with a
|
|
/// space is a typed-based "I'm done with this token" signal,
|
|
/// so a race-y top entry shouldn't block confirmation when the
|
|
/// typed text matches another entry.
|
|
pub fn chipify_completion_if_exact_match(&mut self) -> bool {
|
|
let Some(state) = self.completion.as_ref() else {
|
|
return false;
|
|
};
|
|
let direct = state
|
|
.entries
|
|
.iter()
|
|
.find(|e| {
|
|
state.prefix == e.value || (e.is_dir && state.prefix == format!("{}/", e.value))
|
|
})
|
|
.map(|e| {
|
|
if e.is_dir {
|
|
format!("{}/", e.value)
|
|
} else {
|
|
e.value.clone()
|
|
}
|
|
});
|
|
let drilled = (direct.is_none() && state.prefix.ends_with('/'))
|
|
.then(|| {
|
|
state
|
|
.entries
|
|
.iter()
|
|
.any(|e| e.value.starts_with(&state.prefix))
|
|
.then(|| state.prefix.clone())
|
|
})
|
|
.flatten();
|
|
let Some(value) = direct.or(drilled) else {
|
|
return false;
|
|
};
|
|
let kind = state.kind;
|
|
let start = state.prefix_start;
|
|
self.input_history.cancel_browse();
|
|
match kind {
|
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
|
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
|
|
}
|
|
self.completion = None;
|
|
true
|
|
}
|
|
|
|
/// Enter path: commit the currently *selected* popup entry,
|
|
/// regardless of how much of its value the user has typed. This
|
|
/// is the popup-UI sense of "Enter accepts the highlighted
|
|
/// suggestion" — partial typing like `@README.` followed by
|
|
/// Enter should chip when the popup is on `README.md`.
|
|
///
|
|
/// Files (and Knowledge / Workflow entries, which have no dir
|
|
/// concept) chipify here. Directory file entries return `false`
|
|
/// so the caller can fall through to `apply_completion_text`
|
|
/// for drill-in — chip-ifying a directory on Enter would strand
|
|
/// the user with no way to inspect children.
|
|
pub fn chipify_selected_completion_if_committable(&mut self) -> bool {
|
|
let Some(state) = self.completion.as_ref() else {
|
|
return false;
|
|
};
|
|
if state.entries.is_empty() {
|
|
return false;
|
|
}
|
|
let entry = &state.entries[state.selected];
|
|
if state.kind == CompletionKind::File && entry.is_dir {
|
|
return false;
|
|
}
|
|
let kind = state.kind;
|
|
let start = state.prefix_start;
|
|
let value = entry.value.clone();
|
|
self.input_history.cancel_browse();
|
|
match kind {
|
|
CompletionKind::File => self.input.replace_with_file_ref(start, value),
|
|
CompletionKind::Knowledge => self.input.replace_with_knowledge_ref(start, value),
|
|
CompletionKind::Workflow => self.input.replace_with_workflow_invoke(start, value),
|
|
}
|
|
self.completion = None;
|
|
true
|
|
}
|
|
|
|
pub fn submit_input(&mut self) -> Option<Method> {
|
|
let segments = self.input.submit_segments();
|
|
if segments_are_blank(&segments) {
|
|
// Empty Enter only does something meaningful when the Pod
|
|
// is paused: resume the interrupted turn. Otherwise no-op.
|
|
if self.paused {
|
|
self.input_history.cancel_browse();
|
|
self.input.clear();
|
|
return Some(Method::Resume);
|
|
}
|
|
return None;
|
|
}
|
|
self.input_history.record(segments.clone());
|
|
if self.running {
|
|
self.queued_inputs.push_back(QueuedInput::new(segments));
|
|
self.input.clear();
|
|
self.completion = None;
|
|
return None;
|
|
}
|
|
self.input.clear();
|
|
Some(self.method_for_run(segments))
|
|
}
|
|
|
|
fn method_for_run(&mut self, segments: Vec<Segment>) -> Method {
|
|
// TurnHeader / UserMessage blocks are pushed only after the Pod
|
|
// emits `Event::UserMessage` from a committed `LogEntry::UserInput`.
|
|
// Locally we only clear the input buffer and forward the method,
|
|
// while remembering enough local state to undo the visible submit if
|
|
// the accepted run produced no assistant output and was rolled back.
|
|
self.pending_submit_rollback = Some(RollbackSubmitState {
|
|
text: Segment::flatten_to_text(&segments),
|
|
segments: segments.clone(),
|
|
block_start: self.blocks.len(),
|
|
turn_before: self.turn_index,
|
|
});
|
|
Method::Run { input: segments }
|
|
}
|
|
|
|
pub fn queued_input_count(&self) -> usize {
|
|
self.queued_inputs.len()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn input_history_len(&self) -> usize {
|
|
self.input_history.entries.len()
|
|
}
|
|
|
|
#[cfg(test)]
|
|
pub fn input_history_is_browsing(&self) -> bool {
|
|
self.input_history.is_browsing()
|
|
}
|
|
|
|
pub fn can_browse_input_history_older(&self) -> bool {
|
|
self.input_history.is_browsing() || self.input.cursor_at_start()
|
|
}
|
|
|
|
pub fn can_browse_input_history_newer(&self) -> bool {
|
|
self.input_history.is_browsing() && self.input.cursor_at_end()
|
|
}
|
|
|
|
pub fn browse_input_history_older(&mut self) -> bool {
|
|
if self.input_history.entries.is_empty() {
|
|
return false;
|
|
}
|
|
let draft = self.input.submit_segments();
|
|
let Some(segments) = self.input_history.browse_older(draft) else {
|
|
return false;
|
|
};
|
|
self.input.replace_with_segments(&segments);
|
|
self.completion = None;
|
|
true
|
|
}
|
|
|
|
pub fn browse_input_history_newer(&mut self) -> bool {
|
|
let Some(segments) = self.input_history.browse_newer() else {
|
|
return false;
|
|
};
|
|
self.input.replace_with_segments(&segments);
|
|
self.completion = None;
|
|
true
|
|
}
|
|
|
|
pub fn flash_actionbar_notice(
|
|
&mut self,
|
|
text: impl Into<String>,
|
|
level: ActionbarNoticeLevel,
|
|
source: ActionbarNoticeSource,
|
|
duration: Duration,
|
|
) {
|
|
self.flash_actionbar_notice_at(text, level, source, Instant::now(), duration);
|
|
}
|
|
|
|
pub fn flash_actionbar_notice_at(
|
|
&mut self,
|
|
text: impl Into<String>,
|
|
level: ActionbarNoticeLevel,
|
|
source: ActionbarNoticeSource,
|
|
now: Instant,
|
|
duration: Duration,
|
|
) {
|
|
self.actionbar_notice = Some(ActionbarNotice {
|
|
text: text.into(),
|
|
level,
|
|
source,
|
|
expires_at: now + duration,
|
|
});
|
|
}
|
|
|
|
pub fn current_actionbar_notice(&self, now: Instant) -> Option<&ActionbarNotice> {
|
|
self.actionbar_notice
|
|
.as_ref()
|
|
.filter(|notice| !notice.is_expired(now))
|
|
}
|
|
|
|
pub fn clear_expired_actionbar_notice(&mut self, now: Instant) {
|
|
if self
|
|
.actionbar_notice
|
|
.as_ref()
|
|
.is_some_and(|notice| notice.is_expired(now))
|
|
{
|
|
self.actionbar_notice = None;
|
|
}
|
|
}
|
|
|
|
pub fn next_queued_input_preview(&self) -> Option<&str> {
|
|
self.queued_inputs.front().map(QueuedInput::preview)
|
|
}
|
|
|
|
pub fn clear_queued_inputs(&mut self) -> usize {
|
|
let cleared = self.queued_inputs.len();
|
|
self.queued_inputs.clear();
|
|
cleared
|
|
}
|
|
|
|
pub fn restore_next_queued_input_to_composer(&mut self) -> bool {
|
|
if self.queued_inputs.is_empty() {
|
|
return false;
|
|
}
|
|
if !self.input.is_empty() {
|
|
self.push_error("Composer is not empty; clear it before editing queued input.");
|
|
return false;
|
|
}
|
|
let Some(queued) = self.queued_inputs.pop_front() else {
|
|
return false;
|
|
};
|
|
self.input_history.cancel_browse();
|
|
self.input.replace_with_segments(&queued.segments);
|
|
self.completion = None;
|
|
true
|
|
}
|
|
|
|
fn pop_next_queued_run(&mut self) -> Option<Method> {
|
|
let queued = self.queued_inputs.pop_front()?;
|
|
Some(self.method_for_run(queued.segments))
|
|
}
|
|
|
|
pub fn push_error(&mut self, message: impl Into<String>) {
|
|
self.blocks.push(Block::Alert {
|
|
level: AlertLevel::Error,
|
|
source: AlertSource::Pod,
|
|
message: message.into(),
|
|
});
|
|
}
|
|
|
|
fn push_history_item(&mut self, item: &serde_json::Value) {
|
|
let item_type = item["type"].as_str().unwrap_or("");
|
|
match item_type {
|
|
"message" => {
|
|
let role = item["role"].as_str().unwrap_or("");
|
|
let text = message_text(item);
|
|
match role {
|
|
"user" => {
|
|
self.turn_index += 1;
|
|
self.blocks.push(Block::TurnHeader {
|
|
turn: self.turn_index,
|
|
});
|
|
// Pod attaches the original `Vec<Segment>` to user
|
|
// messages from live submissions, so we can rebuild
|
|
// typed atoms (paste chips, refs) here. Seed history
|
|
// loaded post-compaction has no `segments` field —
|
|
// fall back to a single text segment.
|
|
let segments = item
|
|
.get("segments")
|
|
.and_then(|v| serde_json::from_value::<Vec<Segment>>(v.clone()).ok())
|
|
.unwrap_or_else(|| {
|
|
if text.is_empty() {
|
|
Vec::new()
|
|
} else {
|
|
vec![Segment::text(text.clone())]
|
|
}
|
|
});
|
|
if !segments.is_empty() {
|
|
self.blocks.push(Block::UserMessage { segments });
|
|
}
|
|
}
|
|
"assistant" if !text.is_empty() => {
|
|
self.blocks.push(Block::AssistantText { text });
|
|
}
|
|
"system" if !text.is_empty() => {
|
|
self.task_store.apply_system_message_text(&text);
|
|
self.blocks.push(Block::SystemMessage { text });
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
"tool_call" => {
|
|
// `Item::ToolCall` serializes the linking key as
|
|
// `call_id`; `id` is a separate optional item-level
|
|
// identifier. Use `call_id` so this matches how
|
|
// Event::ToolCallStart populates the block.
|
|
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
|
let name = item["name"].as_str().unwrap_or("?").to_owned();
|
|
let arguments = item["arguments"].as_str().map(|s| s.to_owned());
|
|
if let Some(args) = arguments.as_deref() {
|
|
self.task_store.apply_tool_call(&name, args);
|
|
}
|
|
self.blocks.push(Block::ToolCall(ToolCallBlock {
|
|
id,
|
|
name,
|
|
args_stream: arguments.clone().unwrap_or_default(),
|
|
arguments,
|
|
state: ToolCallState::Executing,
|
|
edit_snapshot: None,
|
|
}));
|
|
}
|
|
"reasoning" => {
|
|
let text = item["text"].as_str().unwrap_or("").to_owned();
|
|
let body = if text.is_empty() {
|
|
item["summary"]
|
|
.as_array()
|
|
.map(|arr| {
|
|
arr.iter()
|
|
.filter_map(|v| v.as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
})
|
|
.unwrap_or_default()
|
|
} else {
|
|
text
|
|
};
|
|
self.blocks.push(Block::Thinking(ThinkingBlock {
|
|
text: body,
|
|
state: ThinkingState::Finished { elapsed_secs: None },
|
|
}));
|
|
}
|
|
"tool_result" => {
|
|
let id = item["call_id"].as_str().unwrap_or("").to_owned();
|
|
let summary = item["summary"].as_str().unwrap_or("").to_owned();
|
|
let output = item["content"].as_str().map(|s| s.to_owned());
|
|
let is_error = item["is_error"].as_bool().unwrap_or(false);
|
|
let (name, args) = self
|
|
.find_tool_call_mut(&id)
|
|
.map(|b| (b.name.clone(), b.arguments.clone()))
|
|
.unwrap_or_default();
|
|
let edit_snapshot = if !is_error && name == "Edit" {
|
|
args.as_deref()
|
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
|
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
|
|
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
|
|
} else {
|
|
None
|
|
};
|
|
if let Some(tc) = self.find_tool_call_mut(&id) {
|
|
if edit_snapshot.is_some() {
|
|
tc.edit_snapshot = edit_snapshot;
|
|
}
|
|
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(),
|
|
);
|
|
}
|
|
}
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
pub fn handle_pod_event(&mut self, event: Event) -> Option<Method> {
|
|
match event {
|
|
Event::UserMessage { segments } => {
|
|
self.turn_index += 1;
|
|
self.blocks.push(Block::TurnHeader {
|
|
turn: self.turn_index,
|
|
});
|
|
self.blocks.push(Block::UserMessage { segments });
|
|
self.assistant_streaming = false;
|
|
}
|
|
Event::SegmentRotated { entry } => {
|
|
self.reset_for_rotation();
|
|
self.apply_log_entry_raw(&entry);
|
|
self.assistant_streaming = false;
|
|
}
|
|
Event::SystemItem { item } => {
|
|
self.apply_system_item(&item);
|
|
self.assistant_streaming = false;
|
|
}
|
|
Event::TurnStart { .. } => {
|
|
self.set_pod_status(PodStatus::Running);
|
|
self.run_requests += 1;
|
|
self.current_tool = None;
|
|
self.latest_llm_wait_event = None;
|
|
self.assistant_streaming = false;
|
|
}
|
|
// UI consumers of Invoke / LlmCall semantics are out of scope
|
|
// for `tickets/invoke-turn-llmcall-semantics.md`; events flow
|
|
// through to subscribers but the TUI currently derives its
|
|
// turn header from `UserMessage` / `SystemItem` arrivals.
|
|
Event::InvokeStart { .. } | Event::LlmCallStart { .. } | Event::LlmCallEnd { .. } => {
|
|
self.latest_llm_wait_event = None;
|
|
}
|
|
Event::LlmRetry {
|
|
failed_attempt,
|
|
max_attempts,
|
|
wait_ms,
|
|
status,
|
|
error,
|
|
..
|
|
} => {
|
|
let next_attempt = failed_attempt.saturating_add(1).min(max_attempts);
|
|
let reason = status
|
|
.map(|code| format!("HTTP {code}"))
|
|
.unwrap_or_else(|| error);
|
|
self.latest_llm_wait_event = Some(format!(
|
|
"retrying LLM request after {reason} (attempt {next_attempt}/{max_attempts} in {})",
|
|
fmt_millis(wait_ms)
|
|
));
|
|
}
|
|
Event::LlmContinuation {
|
|
attempt,
|
|
max_attempts,
|
|
reason,
|
|
..
|
|
} => {
|
|
self.latest_llm_wait_event = Some(format!(
|
|
"LLM stream interrupted; continuing generation ({attempt}/{max_attempts}): {reason}"
|
|
));
|
|
}
|
|
Event::TextDelta { text } => {
|
|
self.latest_llm_wait_event = None;
|
|
self.append_assistant_text(&text);
|
|
}
|
|
Event::TextDone { .. } => {
|
|
self.assistant_streaming = false;
|
|
}
|
|
Event::ThinkingStart => {
|
|
self.latest_llm_wait_event = None;
|
|
self.assistant_streaming = false;
|
|
self.blocks.push(Block::Thinking(ThinkingBlock {
|
|
text: String::new(),
|
|
state: ThinkingState::Streaming {
|
|
started_at: Instant::now(),
|
|
},
|
|
}));
|
|
}
|
|
Event::ThinkingDelta { text } => {
|
|
if let Some(b) = self.last_streaming_thinking_mut() {
|
|
b.text.push_str(&text);
|
|
}
|
|
}
|
|
Event::ThinkingDone { text } => {
|
|
if let Some(b) = self.last_streaming_thinking_mut() {
|
|
// Delta-accumulated text wins. `text` here is the
|
|
// Done payload (full body), used only as a fallback
|
|
// for providers that don't stream deltas.
|
|
if b.text.is_empty() {
|
|
b.text = text;
|
|
}
|
|
let elapsed = match &b.state {
|
|
ThinkingState::Streaming { started_at } => {
|
|
Some(started_at.elapsed().as_secs())
|
|
}
|
|
_ => None,
|
|
};
|
|
b.state = ThinkingState::Finished {
|
|
elapsed_secs: elapsed,
|
|
};
|
|
}
|
|
}
|
|
Event::TurnEnd { .. } => {
|
|
self.assistant_streaming = false;
|
|
self.mark_orphan_tool_calls_incomplete();
|
|
self.mark_orphan_thinking_incomplete();
|
|
self.current_tool = None;
|
|
}
|
|
Event::ToolCallStart { id, name } => {
|
|
self.latest_llm_wait_event = None;
|
|
self.current_tool = Some(name.clone());
|
|
self.assistant_streaming = false;
|
|
self.blocks.push(Block::ToolCall(ToolCallBlock {
|
|
id,
|
|
name,
|
|
args_stream: String::new(),
|
|
arguments: None,
|
|
state: ToolCallState::Pending,
|
|
edit_snapshot: None,
|
|
}));
|
|
}
|
|
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 { id, arguments, .. } => {
|
|
self.current_tool = None;
|
|
let name = self.find_tool_call_mut(&id).map(|b| b.name.clone());
|
|
if let Some(name) = name.as_deref() {
|
|
self.task_store.apply_tool_call(name, &arguments);
|
|
}
|
|
if let Some(b) = self.find_tool_call_mut(&id) {
|
|
b.arguments = Some(arguments);
|
|
// Only advance the state when it's still in-flight.
|
|
// If a ToolResult arrived out of order and already
|
|
// transitioned us to Done/Error, keep that.
|
|
if matches!(b.state, ToolCallState::Pending | ToolCallState::Streaming) {
|
|
b.state = ToolCallState::Executing;
|
|
}
|
|
}
|
|
}
|
|
Event::ToolResult {
|
|
id,
|
|
summary,
|
|
output,
|
|
is_error,
|
|
} => {
|
|
self.latest_llm_wait_event = None;
|
|
// Pull the name / args out first so we can look at the
|
|
// (immutable) cache before taking the mutable block
|
|
// borrow below.
|
|
let (name, args) = self
|
|
.find_tool_call_mut(&id)
|
|
.map(|b| (b.name.clone(), b.arguments.clone()))
|
|
.unwrap_or_default();
|
|
let edit_snapshot = if !is_error && name == "Edit" {
|
|
args.as_deref()
|
|
.and_then(|s| serde_json::from_str::<serde_json::Value>(s).ok())
|
|
.and_then(|v| v["file_path"].as_str().map(|s| s.to_owned()))
|
|
.and_then(|path| self.cache.get(&path).map(|s| s.to_owned()))
|
|
} else {
|
|
None
|
|
};
|
|
|
|
if let Some(b) = self.find_tool_call_mut(&id) {
|
|
if edit_snapshot.is_some() {
|
|
b.edit_snapshot = edit_snapshot;
|
|
}
|
|
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 {
|
|
// Result for an unknown tool call. Surface it as an
|
|
// alert so it isn't silently dropped.
|
|
let level = if is_error {
|
|
AlertLevel::Error
|
|
} else {
|
|
AlertLevel::Warn
|
|
};
|
|
self.blocks.push(Block::Alert {
|
|
level,
|
|
source: AlertSource::Pod,
|
|
message: format!("orphan tool result ({id}): {summary}"),
|
|
});
|
|
}
|
|
}
|
|
Event::Usage {
|
|
input_tokens,
|
|
output_tokens,
|
|
cache_read_input_tokens,
|
|
} => {
|
|
self.session_context_tokens = input_tokens.unwrap_or(0);
|
|
// Subtract the cache-hit portion so a tool loop that
|
|
// re-sends the same prefix on every request doesn't
|
|
// re-count it. cache_creation stays in (it is full
|
|
// price on this request).
|
|
let net_input = input_tokens
|
|
.unwrap_or(0)
|
|
.saturating_sub(cache_read_input_tokens.unwrap_or(0));
|
|
self.run_upload_tokens += net_input;
|
|
self.run_output_tokens += output_tokens.unwrap_or(0);
|
|
}
|
|
Event::Error { code, message } => {
|
|
self.push_error(format!("[{code:?}] {message}"));
|
|
}
|
|
Event::RunEnd { result } => {
|
|
self.latest_llm_wait_event = None;
|
|
if matches!(result, RunResult::RolledBack) {
|
|
self.handle_rolled_back_run();
|
|
} else {
|
|
self.blocks.push(Block::TurnStats {
|
|
requests: self.run_requests,
|
|
upload_tokens: self.run_upload_tokens,
|
|
output_tokens: self.run_output_tokens,
|
|
});
|
|
self.pending_submit_rollback = None;
|
|
self.reset_run_state(match result {
|
|
RunResult::Paused => PodStatus::Paused,
|
|
RunResult::Finished | RunResult::LimitReached | RunResult::RolledBack => {
|
|
PodStatus::Idle
|
|
}
|
|
});
|
|
if matches!(result, RunResult::Finished | RunResult::LimitReached) {
|
|
return self.pop_next_queued_run();
|
|
}
|
|
}
|
|
}
|
|
Event::CompactStart => {
|
|
self.blocks.push(Block::Compact(CompactEvent::Streaming {
|
|
started_at: Instant::now(),
|
|
}));
|
|
}
|
|
Event::CompactDone { new_segment_id } => {
|
|
self.session_context_tokens = 0;
|
|
if let Some(evt) = self.last_streaming_compact_mut() {
|
|
let elapsed_secs = match evt {
|
|
CompactEvent::Streaming { started_at } => {
|
|
Some(started_at.elapsed().as_secs())
|
|
}
|
|
_ => None,
|
|
};
|
|
*evt = CompactEvent::Done {
|
|
new_segment_id,
|
|
elapsed_secs,
|
|
};
|
|
} else {
|
|
self.blocks.push(Block::Compact(CompactEvent::Done {
|
|
new_segment_id,
|
|
elapsed_secs: None,
|
|
}));
|
|
}
|
|
}
|
|
Event::CompactFailed { error } => {
|
|
if let Some(evt) = self.last_streaming_compact_mut() {
|
|
let elapsed_secs = match evt {
|
|
CompactEvent::Streaming { started_at } => {
|
|
Some(started_at.elapsed().as_secs())
|
|
}
|
|
_ => None,
|
|
};
|
|
*evt = CompactEvent::Failed {
|
|
error,
|
|
elapsed_secs,
|
|
};
|
|
} else {
|
|
self.blocks.push(Block::Compact(CompactEvent::Failed {
|
|
error,
|
|
elapsed_secs: None,
|
|
}));
|
|
}
|
|
}
|
|
Event::Alert(alert) => {
|
|
self.blocks.push(Block::Alert {
|
|
level: alert.level,
|
|
source: alert.source,
|
|
message: alert.message,
|
|
});
|
|
}
|
|
Event::MemoryWorker(event) => {
|
|
self.latest_memory_worker_event = Some(event.message);
|
|
}
|
|
Event::Snapshot {
|
|
entries,
|
|
greeting,
|
|
status,
|
|
} => {
|
|
self.restore_snapshot(&entries, greeting);
|
|
self.set_pod_status(status);
|
|
}
|
|
Event::Status { status } => {
|
|
self.set_pod_status(status);
|
|
}
|
|
Event::Completions { kind, entries } => {
|
|
// Apply only if the popup is still on the same
|
|
// (kind, prefix) the request was issued for; an
|
|
// out-of-date reply (the user typed past it) is dropped.
|
|
if let Some(state) = self.completion.as_mut()
|
|
&& state.kind == kind
|
|
{
|
|
state.entries = entries;
|
|
state.selected = 0;
|
|
}
|
|
}
|
|
Event::RewindTargets {
|
|
head_entries,
|
|
targets,
|
|
} => {
|
|
if self.rewind_request_pending {
|
|
self.rewind_request_pending = false;
|
|
self.rewind_picker = Some(RewindPickerState::new(head_entries, targets));
|
|
}
|
|
}
|
|
Event::RewindApplied {
|
|
entries,
|
|
input,
|
|
summary,
|
|
} => {
|
|
if let Some(greeting) = self.greeting.clone() {
|
|
self.restore_snapshot(&entries, greeting);
|
|
}
|
|
let restored_composer = if self.input.is_empty() {
|
|
self.input.replace_with_segments(&input);
|
|
true
|
|
} else {
|
|
false
|
|
};
|
|
self.completion = None;
|
|
self.close_rewind_picker();
|
|
self.reset_run_state(self.pod_status);
|
|
let mut message = if restored_composer {
|
|
format!(
|
|
"Rewound session: discarded {} log entries; restored selected input to composer.",
|
|
summary.discarded_entries
|
|
)
|
|
} else {
|
|
format!(
|
|
"Rewound session: discarded {} log entries. Rewind applied; composer not overwritten because it was not empty.",
|
|
summary.discarded_entries
|
|
)
|
|
};
|
|
if summary.tool_side_effect_warning {
|
|
message.push_str(
|
|
" History suffix was discarded; tool side effects were not undone.",
|
|
);
|
|
}
|
|
self.blocks.push(Block::Alert {
|
|
level: AlertLevel::Warn,
|
|
source: AlertSource::Pod,
|
|
message,
|
|
});
|
|
}
|
|
Event::PodsListed { .. } | Event::PodRestored { .. } => {}
|
|
Event::PeerRegistered { result } => {
|
|
let source = result
|
|
.get("source")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("this Pod");
|
|
let peer = result
|
|
.get("peer")
|
|
.and_then(serde_json::Value::as_str)
|
|
.unwrap_or("peer Pod");
|
|
self.flash_actionbar_notice(
|
|
format!("Peer metadata registered: `{source}` ↔ `{peer}`"),
|
|
ActionbarNoticeLevel::Info,
|
|
ActionbarNoticeSource::Tui,
|
|
Duration::from_secs(4),
|
|
);
|
|
}
|
|
Event::Shutdown => {
|
|
self.mark_orphan_compacts_incomplete();
|
|
self.quit = true;
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn reset_run_state(&mut self, status: PodStatus) {
|
|
self.set_pod_status(status);
|
|
self.run_requests = 0;
|
|
self.run_upload_tokens = 0;
|
|
self.run_output_tokens = 0;
|
|
self.current_tool = None;
|
|
self.latest_llm_wait_event = None;
|
|
self.assistant_streaming = false;
|
|
}
|
|
|
|
fn handle_rolled_back_run(&mut self) {
|
|
let hint = if let Some(state) = self.pending_submit_rollback.take() {
|
|
self.blocks
|
|
.truncate(state.block_start.min(self.blocks.len()));
|
|
self.turn_index = state.turn_before;
|
|
if self.input.is_empty() {
|
|
self.input.replace_with_segments(&state.segments);
|
|
self.completion = None;
|
|
self.last_rolled_back_input = None;
|
|
"Rolled back empty assistant turn; restored your input.".to_owned()
|
|
} else {
|
|
let preview = rollback_input_preview(&state.text);
|
|
self.last_rolled_back_input = Some(state.segments);
|
|
format!(
|
|
"Rolled back empty assistant turn; composer was not empty, kept submitted input in backup: {preview}"
|
|
)
|
|
}
|
|
} else {
|
|
"Rolled back empty assistant turn; no local submitted input was available to restore."
|
|
.to_owned()
|
|
};
|
|
self.reset_run_state(PodStatus::Idle);
|
|
self.blocks.push(Block::Alert {
|
|
level: AlertLevel::Warn,
|
|
source: AlertSource::Pod,
|
|
message: hint,
|
|
});
|
|
}
|
|
|
|
fn append_assistant_text(&mut self, text: &str) {
|
|
if self.assistant_streaming {
|
|
if let Some(Block::AssistantText { text: existing }) = self.blocks.last_mut() {
|
|
existing.push_str(text);
|
|
return;
|
|
}
|
|
}
|
|
self.blocks.push(Block::AssistantText {
|
|
text: text.to_owned(),
|
|
});
|
|
self.assistant_streaming = true;
|
|
}
|
|
|
|
/// Walk the most recently pushed blocks looking for a thinking
|
|
/// block that's still in `Streaming`. Stops at the current
|
|
/// `TurnHeader` to avoid latching onto a thinking block from a
|
|
/// previous turn after it was somehow left dangling.
|
|
fn last_streaming_thinking_mut(&mut self) -> Option<&mut ThinkingBlock> {
|
|
for b in self.blocks.iter_mut().rev() {
|
|
match b {
|
|
Block::Thinking(t) if matches!(t.state, ThinkingState::Streaming { .. }) => {
|
|
return Some(t);
|
|
}
|
|
Block::TurnHeader { .. } => return None,
|
|
_ => continue,
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
fn mark_orphan_thinking_incomplete(&mut self) {
|
|
// A turn can carry several thinking blocks; we walk all the way
|
|
// to `TurnHeader` and convert every still-Streaming one rather
|
|
// than breaking on the first Finished hit (which is what the
|
|
// tool-call equivalent does, since tool calls finalize in
|
|
// submission order).
|
|
for b in self.blocks.iter_mut().rev() {
|
|
match b {
|
|
Block::Thinking(t) => {
|
|
if let ThinkingState::Streaming { started_at } = t.state {
|
|
t.state = ThinkingState::Incomplete {
|
|
elapsed_secs: Some(started_at.elapsed().as_secs()),
|
|
};
|
|
}
|
|
}
|
|
Block::TurnHeader { .. } => break,
|
|
_ => {}
|
|
}
|
|
}
|
|
}
|
|
|
|
fn last_streaming_compact_mut(&mut self) -> Option<&mut CompactEvent> {
|
|
for b in self.blocks.iter_mut().rev() {
|
|
match b {
|
|
Block::Compact(evt) if matches!(evt, CompactEvent::Streaming { .. }) => {
|
|
return Some(evt);
|
|
}
|
|
Block::Compact(_) => return None,
|
|
_ => continue,
|
|
}
|
|
}
|
|
None
|
|
}
|
|
|
|
pub(crate) fn mark_orphan_compacts_incomplete(&mut self) {
|
|
for b in self.blocks.iter_mut().rev() {
|
|
if let Block::Compact(evt) = b {
|
|
if let CompactEvent::Streaming { started_at } = evt {
|
|
*evt = CompactEvent::Incomplete {
|
|
elapsed_secs: Some(started_at.elapsed().as_secs()),
|
|
};
|
|
} else {
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
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;
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn is_command_mode(&self) -> bool {
|
|
self.input_mode == CommandInputMode::Command
|
|
}
|
|
|
|
pub fn enter_command_mode(&mut self) {
|
|
self.input_mode = CommandInputMode::Command;
|
|
self.completion = None;
|
|
self.command_completion_selected = None;
|
|
self.quit_confirm = None;
|
|
}
|
|
|
|
pub fn exit_command_mode(&mut self) {
|
|
self.input_mode = CommandInputMode::Composer;
|
|
self.command_input.clear();
|
|
self.command_completion_selected = None;
|
|
}
|
|
|
|
pub fn clear_command_input(&mut self) {
|
|
self.command_input.clear();
|
|
self.command_completion_selected = None;
|
|
}
|
|
|
|
pub fn command_text(&self) -> String {
|
|
self.command_input.plain_text()
|
|
}
|
|
|
|
pub fn command_suggestions(&self) -> Vec<CommandCandidate> {
|
|
self.command_registry.suggest(&self.command_text())
|
|
}
|
|
|
|
pub fn command_completion_selected(&self) -> Option<usize> {
|
|
let selected = self.command_completion_selected?;
|
|
(selected < self.command_suggestions().len()).then_some(selected)
|
|
}
|
|
|
|
pub fn command_completion_active(&self) -> bool {
|
|
!self.command_suggestions().is_empty()
|
|
}
|
|
|
|
pub fn move_command_completion_up(&mut self) {
|
|
let len = self.command_suggestions().len();
|
|
if len == 0 {
|
|
self.command_completion_selected = None;
|
|
return;
|
|
}
|
|
self.command_completion_selected = Some(match self.command_completion_selected() {
|
|
Some(0) | None => len - 1,
|
|
Some(selected) => selected - 1,
|
|
});
|
|
}
|
|
|
|
pub fn move_command_completion_down(&mut self) {
|
|
let len = self.command_suggestions().len();
|
|
if len == 0 {
|
|
self.command_completion_selected = None;
|
|
return;
|
|
}
|
|
self.command_completion_selected = Some(match self.command_completion_selected() {
|
|
Some(selected) => (selected + 1) % len,
|
|
None => 0,
|
|
});
|
|
}
|
|
|
|
pub fn apply_command_completion(&mut self) -> CommandCompletionApply {
|
|
let suggestions = self.command_suggestions();
|
|
let candidate = match self.command_completion_selected() {
|
|
Some(selected) => suggestions.get(selected),
|
|
None if suggestions.len() == 1 => suggestions.first(),
|
|
None if suggestions.is_empty() => return CommandCompletionApply::NoCandidates,
|
|
None => return self.ambiguous_command_completion(),
|
|
};
|
|
|
|
let Some(candidate) = candidate else {
|
|
self.command_completion_selected = None;
|
|
return CommandCompletionApply::NoCandidates;
|
|
};
|
|
self.replace_command_name(candidate.name);
|
|
self.command_completion_selected = None;
|
|
CommandCompletionApply::Applied
|
|
}
|
|
|
|
pub fn submit_command_with_completion(&mut self) -> Option<Method> {
|
|
let selected = self.command_completion_selected().is_some();
|
|
let command_text = self.command_text();
|
|
if command_text.trim().is_empty() && !selected {
|
|
return self.submit_command();
|
|
}
|
|
if !selected && self.command_name_is_complete(&command_text) {
|
|
return self.submit_command();
|
|
}
|
|
|
|
match self.apply_command_completion() {
|
|
CommandCompletionApply::Applied | CommandCompletionApply::NoCandidates => {
|
|
self.submit_command()
|
|
}
|
|
CommandCompletionApply::Ambiguous => None,
|
|
}
|
|
}
|
|
|
|
fn ambiguous_command_completion(&mut self) -> CommandCompletionApply {
|
|
self.push_command_diagnostic(
|
|
"Ambiguous command completion; select a candidate with Up/Down or keep typing.",
|
|
);
|
|
CommandCompletionApply::Ambiguous
|
|
}
|
|
|
|
fn command_name_is_complete(&self, command_line: &str) -> bool {
|
|
let trimmed = command_line.trim_start();
|
|
let name = trimmed
|
|
.find(char::is_whitespace)
|
|
.map(|idx| &trimmed[..idx])
|
|
.unwrap_or(trimmed);
|
|
!name.is_empty() && self.command_registry.find(name).is_some()
|
|
}
|
|
|
|
fn replace_command_name(&mut self, canonical_name: &str) {
|
|
let command_line = self.command_text();
|
|
let leading_len = command_line.len() - command_line.trim_start().len();
|
|
let after_leading = &command_line[leading_len..];
|
|
let name_end = after_leading
|
|
.find(char::is_whitespace)
|
|
.map(|idx| leading_len + idx)
|
|
.unwrap_or(command_line.len());
|
|
let rest = &command_line[name_end..];
|
|
|
|
let mut completed = String::with_capacity(command_line.len().max(canonical_name.len() + 1));
|
|
completed.push_str(&command_line[..leading_len]);
|
|
completed.push_str(canonical_name);
|
|
if rest.is_empty() {
|
|
completed.push(' ');
|
|
} else {
|
|
completed.push_str(rest);
|
|
}
|
|
|
|
self.command_input.clear();
|
|
self.command_input.insert_str(&completed);
|
|
}
|
|
|
|
pub fn request_rewind_picker(&mut self) -> Option<Method> {
|
|
if !self.connected {
|
|
self.push_command_diagnostic("cannot rewind before the Pod is connected");
|
|
return None;
|
|
}
|
|
if self.running {
|
|
self.push_command_diagnostic("cannot rewind while the Pod is running");
|
|
return None;
|
|
}
|
|
self.completion = None;
|
|
self.rewind_picker = None;
|
|
self.rewind_request_pending = true;
|
|
Some(Method::ListRewindTargets)
|
|
}
|
|
|
|
pub fn close_rewind_picker(&mut self) {
|
|
self.rewind_picker = None;
|
|
self.rewind_request_pending = false;
|
|
}
|
|
|
|
pub fn rewind_picker_up(&mut self) {
|
|
if let Some(picker) = self.rewind_picker.as_mut() {
|
|
if picker.targets.is_empty() {
|
|
return;
|
|
}
|
|
picker.selected = if picker.selected == 0 {
|
|
picker.targets.len() - 1
|
|
} else {
|
|
picker.selected - 1
|
|
};
|
|
}
|
|
}
|
|
|
|
pub fn rewind_picker_down(&mut self) {
|
|
if let Some(picker) = self.rewind_picker.as_mut() {
|
|
if !picker.targets.is_empty() {
|
|
picker.selected = (picker.selected + 1) % picker.targets.len();
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn submit_rewind_picker(&mut self) -> Option<Method> {
|
|
if self.paused {
|
|
self.push_command_diagnostic(
|
|
"cannot apply rewind while the Pod is paused; resume or wait for idle first",
|
|
);
|
|
return None;
|
|
}
|
|
if !self.input.is_empty() {
|
|
self.push_command_diagnostic(
|
|
"cannot apply rewind while composer is not empty; clear it before restoring rewind input",
|
|
);
|
|
return None;
|
|
}
|
|
let Some(picker) = self.rewind_picker.as_ref() else {
|
|
return None;
|
|
};
|
|
let Some(target) = picker.selected_target() else {
|
|
self.push_command_diagnostic("no rewind target is available");
|
|
return None;
|
|
};
|
|
if !target.eligible {
|
|
self.push_command_diagnostic(
|
|
target
|
|
.disabled_reason
|
|
.clone()
|
|
.unwrap_or_else(|| "rewind target is disabled".into()),
|
|
);
|
|
return None;
|
|
}
|
|
Some(Method::RewindTo {
|
|
target: target.id.clone(),
|
|
expected_head_entries: target.expected_head_entries,
|
|
})
|
|
}
|
|
|
|
fn command_environment(&self) -> CommandEnvironment {
|
|
CommandEnvironment {
|
|
connected: self.connected,
|
|
running: self.running,
|
|
paused: self.paused,
|
|
}
|
|
}
|
|
|
|
pub fn submit_command(&mut self) -> Option<Method> {
|
|
let command_line = self.command_text();
|
|
let environment = self.command_environment();
|
|
let result = self.command_registry.dispatch(&command_line, &environment);
|
|
self.apply_command_execution(result)
|
|
}
|
|
|
|
fn apply_command_execution(&mut self, result: CommandExecution) -> Option<Method> {
|
|
for diagnostic in result.diagnostics {
|
|
self.push_command_diagnostic(diagnostic.message);
|
|
}
|
|
if result.clear_input {
|
|
self.command_input.clear();
|
|
self.command_completion_selected = None;
|
|
}
|
|
if result.exit_command_mode {
|
|
self.input_mode = CommandInputMode::Composer;
|
|
self.command_completion_selected = None;
|
|
}
|
|
if let Some(Method::ListRewindTargets) = result.method.as_ref() {
|
|
self.completion = None;
|
|
self.rewind_picker = None;
|
|
self.rewind_request_pending = true;
|
|
}
|
|
result.method
|
|
}
|
|
|
|
fn push_command_diagnostic(&mut self, message: impl Into<String>) {
|
|
self.blocks.push(Block::Alert {
|
|
level: AlertLevel::Warn,
|
|
source: AlertSource::Pod,
|
|
message: format!("TUI command: {}", message.into()),
|
|
});
|
|
}
|
|
|
|
fn active_input_mut(&mut self) -> &mut InputBuffer {
|
|
if self.is_command_mode() {
|
|
&mut self.command_input
|
|
} else {
|
|
&mut self.input
|
|
}
|
|
}
|
|
|
|
// Input manipulation — thin forwarders so call sites in main.rs
|
|
// stay readable. In command mode these operate on the command line,
|
|
// keeping the normal composer buffer intact.
|
|
pub fn insert_char(&mut self, c: char) {
|
|
let command_mode = self.is_command_mode();
|
|
if !command_mode {
|
|
self.input_history.cancel_browse();
|
|
}
|
|
self.active_input_mut().insert_char(c);
|
|
if command_mode {
|
|
self.command_completion_selected = None;
|
|
}
|
|
}
|
|
pub fn insert_newline(&mut self) {
|
|
let command_mode = self.is_command_mode();
|
|
if !command_mode {
|
|
self.input_history.cancel_browse();
|
|
}
|
|
self.active_input_mut().insert_newline();
|
|
if command_mode {
|
|
self.command_completion_selected = None;
|
|
}
|
|
}
|
|
pub fn insert_paste(&mut self, content: String) {
|
|
if self.is_command_mode() {
|
|
self.command_input.insert_str(&content);
|
|
self.command_completion_selected = None;
|
|
} else {
|
|
self.input_history.cancel_browse();
|
|
self.input.insert_paste(content);
|
|
}
|
|
}
|
|
pub fn delete_char_before(&mut self) {
|
|
let command_mode = self.is_command_mode();
|
|
if !command_mode {
|
|
self.input_history.cancel_browse();
|
|
}
|
|
self.active_input_mut().delete_before();
|
|
if command_mode {
|
|
self.command_completion_selected = None;
|
|
}
|
|
}
|
|
pub fn delete_char_after(&mut self) {
|
|
let command_mode = self.is_command_mode();
|
|
if !command_mode {
|
|
self.input_history.cancel_browse();
|
|
}
|
|
self.active_input_mut().delete_after();
|
|
if command_mode {
|
|
self.command_completion_selected = None;
|
|
}
|
|
}
|
|
pub fn move_cursor_left(&mut self) {
|
|
self.active_input_mut().move_left();
|
|
}
|
|
pub fn move_cursor_right(&mut self) {
|
|
self.active_input_mut().move_right();
|
|
}
|
|
pub fn move_cursor_word_left(&mut self) {
|
|
self.active_input_mut().move_word_left();
|
|
}
|
|
pub fn move_cursor_word_right(&mut self) {
|
|
self.active_input_mut().move_word_right();
|
|
}
|
|
pub fn delete_word_before_cursor(&mut self) {
|
|
let command_mode = self.is_command_mode();
|
|
if !command_mode {
|
|
self.input_history.cancel_browse();
|
|
}
|
|
self.active_input_mut().delete_word_before();
|
|
if command_mode {
|
|
self.command_completion_selected = None;
|
|
}
|
|
}
|
|
pub fn move_cursor_start(&mut self) {
|
|
self.active_input_mut().move_start();
|
|
}
|
|
pub fn move_cursor_home(&mut self) {
|
|
self.active_input_mut().move_home();
|
|
}
|
|
pub fn move_cursor_end(&mut self) {
|
|
self.active_input_mut().move_end();
|
|
}
|
|
pub fn move_cursor_up(&mut self) {
|
|
self.active_input_mut().move_up();
|
|
}
|
|
pub fn move_cursor_down(&mut self) {
|
|
self.active_input_mut().move_down();
|
|
}
|
|
|
|
/// Reset the block list and replay a connect-time `Event::Snapshot`.
|
|
///
|
|
/// Walks the session-log entries in commit order, expanding each
|
|
/// LogEntry variant into the same blocks live events would have
|
|
/// produced. Followed by `Event::Entry` updates for anything
|
|
/// committed after the snapshot.
|
|
fn restore_snapshot(&mut self, entries: &[serde_json::Value], greeting: protocol::Greeting) {
|
|
self.greeting = Some(greeting.clone());
|
|
self.context_window = greeting.context_window;
|
|
self.session_context_tokens = greeting.context_tokens;
|
|
self.turn_index = 0;
|
|
self.blocks.clear();
|
|
self.cache = FileCache::new();
|
|
self.task_store = TaskStore::new();
|
|
self.task_pane_scroll = 0;
|
|
self.blocks.push(Block::Greeting(greeting));
|
|
self.assistant_streaming = false;
|
|
|
|
for entry in entries {
|
|
self.apply_log_entry_raw(entry);
|
|
}
|
|
|
|
self.mark_orphan_tool_calls_incomplete_pass();
|
|
}
|
|
|
|
/// Drop the derived view in preparation for replaying a new
|
|
/// `SegmentStart` (compaction / fork). Greeting is preserved
|
|
/// because the Pod identity hasn't changed.
|
|
fn reset_for_rotation(&mut self) {
|
|
let greeting = self.blocks.iter().find_map(|b| match b {
|
|
Block::Greeting(g) => Some(g.clone()),
|
|
_ => None,
|
|
});
|
|
self.turn_index = 0;
|
|
self.blocks.clear();
|
|
self.cache = FileCache::new();
|
|
self.task_store = TaskStore::new();
|
|
self.task_pane_scroll = 0;
|
|
if let Some(g) = greeting {
|
|
self.blocks.push(Block::Greeting(g));
|
|
}
|
|
}
|
|
|
|
/// Walk a single `LogEntry` JSON value and translate it into blocks
|
|
/// the live event path would have produced. Shared between
|
|
/// `restore_snapshot` (replay path) and `apply_log_entry` (live
|
|
/// path).
|
|
fn apply_log_entry_raw(&mut self, value: &serde_json::Value) {
|
|
let Ok(entry) = serde_json::from_value::<session_store::LogEntry>(value.clone()) else {
|
|
return;
|
|
};
|
|
match entry {
|
|
session_store::LogEntry::SegmentStart { history, .. } => {
|
|
for logged in history {
|
|
let item: llm_worker::Item = logged.into();
|
|
let item_value = serde_json::to_value(&item).expect("Item is Serialize");
|
|
self.push_history_item(&item_value);
|
|
}
|
|
}
|
|
session_store::LogEntry::UserInput { segments, .. } => {
|
|
self.turn_index += 1;
|
|
self.blocks.push(Block::TurnHeader {
|
|
turn: self.turn_index,
|
|
});
|
|
if !segments.is_empty() {
|
|
self.blocks.push(Block::UserMessage { segments });
|
|
}
|
|
}
|
|
session_store::LogEntry::AssistantItem { item, .. }
|
|
| session_store::LogEntry::ToolResult { item, .. } => {
|
|
let it: llm_worker::Item = item.into();
|
|
let item_value = serde_json::to_value(&it).expect("Item is Serialize");
|
|
self.push_history_item(&item_value);
|
|
}
|
|
session_store::LogEntry::SystemItem { item, .. } => {
|
|
let value = serde_json::to_value(&item).expect("SystemItem is Serialize");
|
|
self.apply_system_item(&value);
|
|
}
|
|
// Non-history-bearing variants don't affect the block view.
|
|
_ => {}
|
|
}
|
|
}
|
|
|
|
/// Dispatch one `SystemItem` JSON value into the appropriate block.
|
|
///
|
|
/// Kind-based routing replaces the old free-text `[Notification]` /
|
|
/// `[File: …]` parsing path: each kind maps directly to a typed
|
|
/// block (`Block::Notify`, `Block::PodEvent`, …).
|
|
fn apply_system_item(&mut self, value: &serde_json::Value) {
|
|
let Ok(item) = serde_json::from_value::<session_store::SystemItem>(value.clone()) else {
|
|
// Unknown / forward-compat shape: fall back to rendering the
|
|
// raw text payload (if any) as a generic system message.
|
|
if let Some(text) = value.get("body").and_then(|b| b.as_str()) {
|
|
self.task_store.apply_system_message_text(text);
|
|
self.blocks.push(Block::SystemMessage {
|
|
text: text.to_owned(),
|
|
});
|
|
}
|
|
return;
|
|
};
|
|
match item {
|
|
session_store::SystemItem::Notification { message, .. } => {
|
|
self.blocks.push(Block::Notify { message });
|
|
}
|
|
session_store::SystemItem::PodEvent { event, .. } => {
|
|
self.blocks.push(Block::PodEvent { event });
|
|
}
|
|
session_store::SystemItem::FileAttachment { body, .. }
|
|
| session_store::SystemItem::Knowledge { body, .. }
|
|
| session_store::SystemItem::Workflow { body, .. }
|
|
| session_store::SystemItem::TaskReminder { body, .. }
|
|
| session_store::SystemItem::Interrupt { body } => {
|
|
self.task_store.apply_system_message_text(&body);
|
|
self.blocks.push(Block::SystemMessage { text: body });
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Sweep all current tool-call blocks: any that never resolved into
|
|
/// a Done / Error state get marked Incomplete. Called after a
|
|
/// snapshot replay so dangling in-flight tool calls in the seed
|
|
/// log match live semantics.
|
|
fn mark_orphan_tool_calls_incomplete_pass(&mut self) {
|
|
for b in self.blocks.iter_mut() {
|
|
if let Block::ToolCall(tc) = b
|
|
&& matches!(
|
|
tc.state,
|
|
ToolCallState::Executing | ToolCallState::Pending | ToolCallState::Streaming
|
|
)
|
|
{
|
|
tc.state = ToolCallState::Incomplete;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn fmt_tokens(n: u64) -> String {
|
|
if n >= 1_000_000 {
|
|
format!("{:.1}M", n as f64 / 1_000_000.0)
|
|
} else if n >= 1_000 {
|
|
format!("{:.1}k", n as f64 / 1_000.0)
|
|
} else {
|
|
n.to_string()
|
|
}
|
|
}
|
|
|
|
fn fmt_millis(ms: u64) -> String {
|
|
if ms >= 1_000 {
|
|
format!("{:.1}s", ms as f64 / 1_000.0)
|
|
} else {
|
|
format!("{ms}ms")
|
|
}
|
|
}
|
|
|
|
fn message_text(item: &serde_json::Value) -> String {
|
|
item["content"]
|
|
.as_array()
|
|
.map(|parts| {
|
|
parts
|
|
.iter()
|
|
.filter_map(|p| p["text"].as_str())
|
|
.collect::<Vec<_>>()
|
|
.join("\n")
|
|
})
|
|
.unwrap_or_default()
|
|
}
|
|
|
|
/// Strip the `cat -n` line-number gutter that the Read tool prepends to
|
|
/// its output (one `"{n:>6}\t{content}"` per line) and return the raw
|
|
/// file body. Lines that don't match the pattern are kept verbatim, so
|
|
/// unrelated payloads pass through unharmed.
|
|
fn strip_cat_n_prefix(formatted: &str) -> String {
|
|
let mut out = String::with_capacity(formatted.len());
|
|
let mut first = true;
|
|
for line in formatted.split('\n') {
|
|
if !first {
|
|
out.push('\n');
|
|
}
|
|
first = false;
|
|
match line.split_once('\t') {
|
|
Some((prefix, rest)) if prefix.trim().chars().all(|c| c.is_ascii_digit()) => {
|
|
out.push_str(rest);
|
|
}
|
|
_ => out.push_str(line),
|
|
}
|
|
}
|
|
out
|
|
}
|
|
|
|
fn rollback_input_preview(text: &str) -> String {
|
|
const MAX_CHARS: usize = 80;
|
|
let mut one_line = text.replace('\n', "⏎");
|
|
if one_line.chars().count() > MAX_CHARS {
|
|
one_line = one_line.chars().take(MAX_CHARS).collect::<String>();
|
|
one_line.push('…');
|
|
}
|
|
one_line
|
|
}
|
|
|
|
/// True if the submitted segment list carries no user-visible content
|
|
/// (only whitespace / newlines, no paste, no typed atoms). Used to
|
|
/// decide whether an empty Enter should be a no-op or trigger a
|
|
/// `Resume` when the Pod is paused.
|
|
fn segments_are_blank(segments: &[Segment]) -> bool {
|
|
segments.iter().all(|s| match s {
|
|
Segment::Text { content } => content.trim().is_empty(),
|
|
_ => false,
|
|
})
|
|
}
|
|
|
|
pub fn alert_source_label(source: AlertSource) -> &'static str {
|
|
match source {
|
|
AlertSource::Pod => "pod",
|
|
AlertSource::Worker => "worker",
|
|
AlertSource::Compactor => "compactor",
|
|
AlertSource::AgentsMd => "AGENTS.md",
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod llm_wait_event_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn llm_retry_updates_and_progress_clears_transient_status() {
|
|
let mut app = App::new("test".into());
|
|
app.handle_pod_event(Event::LlmRetry {
|
|
llm_call: 2,
|
|
failed_attempt: 1,
|
|
max_attempts: 4,
|
|
wait_ms: 1_200,
|
|
elapsed_ms: 50,
|
|
status: Some(504),
|
|
error: "gateway timeout".into(),
|
|
});
|
|
assert_eq!(
|
|
app.latest_llm_wait_event.as_deref(),
|
|
Some("retrying LLM request after HTTP 504 (attempt 2/4 in 1.2s)")
|
|
);
|
|
|
|
app.handle_pod_event(Event::TextDelta { text: "ok".into() });
|
|
assert!(app.latest_llm_wait_event.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn llm_continuation_updates_transient_status() {
|
|
let mut app = App::new("test".into());
|
|
app.handle_pod_event(Event::LlmContinuation {
|
|
llm_call: 3,
|
|
attempt: 1,
|
|
max_attempts: 3,
|
|
reason: "SSE parse error: closed".into(),
|
|
});
|
|
assert_eq!(
|
|
app.latest_llm_wait_event.as_deref(),
|
|
Some("LLM stream interrupted; continuing generation (1/3): SSE parse error: closed")
|
|
);
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod actionbar_notice_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn actionbar_notice_expires_from_injected_time_source() {
|
|
let mut app = App::new("test".into());
|
|
let now = Instant::now();
|
|
let duration = Duration::from_secs(2);
|
|
|
|
app.flash_actionbar_notice_at(
|
|
"Pod keeps running",
|
|
ActionbarNoticeLevel::Warn,
|
|
ActionbarNoticeSource::Tui,
|
|
now,
|
|
duration,
|
|
);
|
|
|
|
let notice = app.current_actionbar_notice(now).expect("notice is active");
|
|
assert_eq!(notice.text, "Pod keeps running");
|
|
assert_eq!(notice.level, ActionbarNoticeLevel::Warn);
|
|
assert_eq!(notice.source, ActionbarNoticeSource::Tui);
|
|
assert_eq!(notice.expires_at, now + duration);
|
|
assert!(
|
|
app.current_actionbar_notice(now + duration - Duration::from_millis(1))
|
|
.is_some()
|
|
);
|
|
assert!(app.current_actionbar_notice(now + duration).is_none());
|
|
|
|
app.clear_expired_actionbar_notice(now + duration);
|
|
assert!(app.current_actionbar_notice(now).is_none());
|
|
}
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod completion_flow_tests {
|
|
use super::*;
|
|
|
|
#[test]
|
|
fn typing_at_creates_completion_state_and_emits_query() {
|
|
let mut app = App::new("test".into());
|
|
app.insert_char('@');
|
|
let method = app.refresh_completion();
|
|
match method {
|
|
Some(Method::ListCompletions { kind, prefix }) => {
|
|
assert_eq!(kind, CompletionKind::File);
|
|
assert_eq!(prefix, "");
|
|
}
|
|
other => panic!("expected ListCompletions, got {other:?}"),
|
|
}
|
|
assert!(app.completion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn appending_to_token_emits_updated_query() {
|
|
let mut app = App::new("test".into());
|
|
app.insert_char('@');
|
|
let _ = app.refresh_completion();
|
|
app.insert_char('s');
|
|
let method = app.refresh_completion();
|
|
match method {
|
|
Some(Method::ListCompletions { kind, prefix }) => {
|
|
assert_eq!(kind, CompletionKind::File);
|
|
assert_eq!(prefix, "s");
|
|
}
|
|
other => panic!("expected ListCompletions, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn space_after_token_clears_completion_state() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@x".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
assert!(app.completion.is_some());
|
|
app.insert_char(' ');
|
|
let method = app.refresh_completion();
|
|
assert!(method.is_none());
|
|
assert!(app.completion.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_inserts_entry_value_as_text_for_file() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@s".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "src/main.rs".into(),
|
|
is_dir: false,
|
|
}];
|
|
// Tab path: text inserted, popup re-triggered with new prefix
|
|
// (still File kind since the typed range stays after `@`).
|
|
let _ = app.apply_completion_text();
|
|
// The input now reads `@src/main.rs` as plain Char atoms; no
|
|
// chip yet.
|
|
let segs = app.input.submit_segments();
|
|
assert_eq!(segs.len(), 1);
|
|
assert!(matches!(&segs[0], Segment::Text { content } if content == "@src/main.rs"));
|
|
assert!(app.completion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn tab_appends_trailing_slash_for_directory() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@cr".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "crates".into(),
|
|
is_dir: true,
|
|
}];
|
|
let _ = app.apply_completion_text();
|
|
// Typed prefix advances to `crates/` so the next query can
|
|
// descend into the directory.
|
|
assert_eq!(app.completion.as_ref().unwrap().prefix, "crates/");
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::Text { content } if content == "@crates/"));
|
|
}
|
|
|
|
#[test]
|
|
fn space_chipifies_on_exact_match() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@src/main.rs".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "src/main.rs".into(),
|
|
is_dir: false,
|
|
}];
|
|
assert!(app.chipify_completion_if_exact_match());
|
|
assert!(app.completion.is_none());
|
|
let segs = app.input.submit_segments();
|
|
assert_eq!(segs.len(), 1);
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs"));
|
|
}
|
|
|
|
#[test]
|
|
fn space_does_not_chipify_on_partial_match() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@s".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "src/main.rs".into(),
|
|
is_dir: false,
|
|
}];
|
|
// typed = "s", expected = "src/main.rs" → no match, no chip.
|
|
assert!(!app.chipify_completion_if_exact_match());
|
|
let segs = app.input.submit_segments();
|
|
assert_eq!(segs.len(), 1);
|
|
assert!(matches!(&segs[0], Segment::Text { content } if content == "@s"));
|
|
}
|
|
|
|
#[test]
|
|
fn space_chipifies_directory_with_or_without_trailing_slash() {
|
|
// Slash-less typed form chipifies the directory; the chip's
|
|
// path keeps a trailing slash so the rendered label is `@crates/`.
|
|
let mut app = App::new("test".into());
|
|
for c in "@crates".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "crates".into(),
|
|
is_dir: true,
|
|
}];
|
|
assert!(app.chipify_completion_if_exact_match());
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/"));
|
|
|
|
// Slashed typed form (the shape Tab inserts) — same chip.
|
|
let mut app = App::new("test".into());
|
|
for c in "@crates/".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "crates".into(),
|
|
is_dir: true,
|
|
}];
|
|
assert!(app.chipify_completion_if_exact_match());
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/"));
|
|
}
|
|
|
|
#[test]
|
|
fn space_chipifies_directory_when_popup_shows_its_children() {
|
|
// `@crates/` is the form Tab leaves you in after picking a
|
|
// directory; the popup is showing the children of `crates/`.
|
|
// Hitting space at this point should chipify `crates`, not
|
|
// require the user to back up and remove the trailing slash.
|
|
let mut app = App::new("test".into());
|
|
for c in "@crates/".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![
|
|
CompletionEntry {
|
|
value: "crates/daemon".into(),
|
|
is_dir: true,
|
|
},
|
|
CompletionEntry {
|
|
value: "crates/llm-worker".into(),
|
|
is_dir: true,
|
|
},
|
|
];
|
|
assert!(app.chipify_completion_if_exact_match());
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "crates/"));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_does_not_chipify_directory_so_drill_in_works() {
|
|
// Enter on a selected directory entry must NOT chipify —
|
|
// otherwise the user can never drill into the dir to see
|
|
// its children.
|
|
let mut app = App::new("test".into());
|
|
for c in "@crates".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "crates".into(),
|
|
is_dir: true,
|
|
}];
|
|
assert!(!app.chipify_selected_completion_if_committable());
|
|
// Popup is still active so the caller can fall through to
|
|
// apply_completion_text.
|
|
assert!(app.completion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn enter_path_appends_trailing_space_after_file_chip() {
|
|
// Mirrors the main.rs Enter handler sequence: chipify the
|
|
// selected entry, then insert a space so the cursor is ready
|
|
// for the next token without a manual separator.
|
|
let mut app = App::new("test".into());
|
|
for c in "@README.".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "README.md".into(),
|
|
is_dir: false,
|
|
}];
|
|
assert!(app.chipify_selected_completion_if_committable());
|
|
app.insert_char(' ');
|
|
let segs = app.input.submit_segments();
|
|
assert_eq!(segs.len(), 2);
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "README.md"));
|
|
assert!(matches!(&segs[1], Segment::Text { content } if content == " "));
|
|
}
|
|
|
|
#[test]
|
|
fn enter_chipifies_selected_file_even_when_typed_is_partial() {
|
|
// Enter respects the selected entry: typed text may be a
|
|
// prefix of the entry's value, but the popup-highlighted
|
|
// file should still chipify on Enter.
|
|
let mut app = App::new("test".into());
|
|
for c in "@README.".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "README.md".into(),
|
|
is_dir: false,
|
|
}];
|
|
assert!(app.chipify_selected_completion_if_committable());
|
|
assert!(app.completion.is_none());
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "README.md"));
|
|
}
|
|
|
|
#[test]
|
|
fn space_does_not_chipify_drilled_state_with_unrelated_entries() {
|
|
// Stale entries that don't live under the typed prefix should
|
|
// not satisfy the drilled-into-directory rule.
|
|
let mut app = App::new("test".into());
|
|
for c in "@xyz/".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![CompletionEntry {
|
|
value: "crates/daemon".into(),
|
|
is_dir: true,
|
|
}];
|
|
assert!(!app.chipify_completion_if_exact_match());
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::Text { content } if content == "@xyz/"));
|
|
}
|
|
|
|
#[test]
|
|
fn chipify_finds_match_outside_selected_index() {
|
|
// Regression guard for the race where a stale reply leaves a
|
|
// non-matching entry at index 0 but an entry deeper in the
|
|
// list does match the current typed text.
|
|
let mut app = App::new("test".into());
|
|
for c in "@src/main.rs".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
app.completion.as_mut().unwrap().entries = vec![
|
|
CompletionEntry {
|
|
value: "src/main.rs.bak".into(),
|
|
is_dir: false,
|
|
},
|
|
CompletionEntry {
|
|
value: "src/main.rs".into(),
|
|
is_dir: false,
|
|
},
|
|
];
|
|
// selected stays at 0 (the non-matching one) but find() should
|
|
// still locate the match.
|
|
assert!(app.chipify_completion_if_exact_match());
|
|
let segs = app.input.submit_segments();
|
|
assert!(matches!(&segs[0], Segment::FileRef { path } if path == "src/main.rs"));
|
|
}
|
|
|
|
#[test]
|
|
fn apply_completion_text_with_no_entries_is_a_noop() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@x".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
// No `Event::Completions` arrived yet — entries is still empty.
|
|
assert!(app.apply_completion_text().is_none());
|
|
assert!(app.completion.is_some());
|
|
}
|
|
|
|
#[test]
|
|
fn outdated_completions_event_is_dropped() {
|
|
let mut app = App::new("test".into());
|
|
for c in "@x".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
let _ = app.refresh_completion();
|
|
// Reply for a different kind shouldn't overwrite state.
|
|
app.handle_pod_event(Event::Completions {
|
|
kind: CompletionKind::Workflow,
|
|
entries: vec![CompletionEntry {
|
|
value: "stale".into(),
|
|
is_dir: false,
|
|
}],
|
|
});
|
|
assert!(app.completion.as_ref().unwrap().entries.is_empty());
|
|
}
|
|
|
|
#[test]
|
|
fn committed_user_message_survives_fresh_segment_rotation() {
|
|
let mut app = App::new("test".into());
|
|
let start = session_store::LogEntry::SegmentStart {
|
|
ts: session_store::segment_log::now_millis(),
|
|
session_id: uuid::Uuid::nil(),
|
|
system_prompt: None,
|
|
config: llm_worker::llm_client::RequestConfig::default(),
|
|
history: vec![],
|
|
forked_from: None,
|
|
compacted_from: None,
|
|
};
|
|
|
|
app.handle_pod_event(Event::SegmentRotated {
|
|
entry: serde_json::to_value(start).expect("LogEntry is Serialize"),
|
|
});
|
|
app.handle_pod_event(Event::UserMessage {
|
|
segments: vec![Segment::text("first persisted message")],
|
|
});
|
|
|
|
assert_eq!(app.turn_index, 1);
|
|
assert!(app.blocks.iter().any(|b| matches!(
|
|
b,
|
|
Block::UserMessage { segments }
|
|
if Segment::flatten_to_text(segments) == "first persisted message"
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn rolled_back_run_restores_input_and_removes_submit_blocks() {
|
|
let mut app = App::new("test".into());
|
|
let submitted = submit_text(&mut app, "please wait");
|
|
assert_eq!(input_text(&app), "");
|
|
|
|
app.handle_pod_event(Event::UserMessage {
|
|
segments: submitted,
|
|
});
|
|
// Simulate run-derived attachment display after the submitted user line.
|
|
app.blocks.push(Block::SystemMessage {
|
|
text: "[File: README.md]".into(),
|
|
});
|
|
app.handle_pod_event(Event::TurnStart { turn: 1 });
|
|
app.handle_pod_event(Event::Usage {
|
|
input_tokens: Some(100),
|
|
output_tokens: Some(0),
|
|
cache_read_input_tokens: Some(40),
|
|
});
|
|
app.handle_pod_event(Event::RunEnd {
|
|
result: RunResult::RolledBack,
|
|
});
|
|
|
|
assert_eq!(input_text(&app), "please wait");
|
|
assert_eq!(app.turn_index, 0);
|
|
assert!(app.blocks.iter().all(|b| !matches!(
|
|
b,
|
|
Block::TurnHeader { .. }
|
|
| Block::UserMessage { .. }
|
|
| Block::SystemMessage { .. }
|
|
| Block::TurnStats { .. }
|
|
)));
|
|
assert!(warning_contains(&app, "restored your input"));
|
|
assert!(matches!(app.pod_status, PodStatus::Idle));
|
|
assert!(!app.running);
|
|
assert!(!app.paused);
|
|
assert_eq!(app.run_requests, 0);
|
|
assert_eq!(app.run_upload_tokens, 0);
|
|
assert_eq!(app.run_output_tokens, 0);
|
|
assert!(app.current_tool.is_none());
|
|
}
|
|
|
|
#[test]
|
|
fn rolled_back_run_does_not_overwrite_existing_unsent_input() {
|
|
let mut app = App::new("test".into());
|
|
let submitted = submit_text(&mut app, "original submit");
|
|
app.handle_pod_event(Event::UserMessage {
|
|
segments: submitted,
|
|
});
|
|
for c in "draft while running".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
|
|
app.handle_pod_event(Event::RunEnd {
|
|
result: RunResult::RolledBack,
|
|
});
|
|
|
|
assert_eq!(input_text(&app), "draft while running");
|
|
assert_eq!(
|
|
Segment::flatten_to_text(app.last_rolled_back_input.as_ref().unwrap()),
|
|
"original submit"
|
|
);
|
|
assert!(warning_contains(&app, "composer was not empty"));
|
|
assert!(app.blocks.iter().all(|b| !matches!(
|
|
b,
|
|
Block::TurnHeader { .. } | Block::UserMessage { .. } | Block::TurnStats { .. }
|
|
)));
|
|
}
|
|
|
|
#[test]
|
|
fn non_rolled_back_run_end_keeps_submitted_blocks_and_does_not_restore_input() {
|
|
for result in [RunResult::Paused, RunResult::Finished] {
|
|
let mut app = App::new("test".into());
|
|
let submitted = submit_text(&mut app, "normal run");
|
|
app.handle_pod_event(Event::UserMessage {
|
|
segments: submitted,
|
|
});
|
|
app.handle_pod_event(Event::RunEnd { result });
|
|
|
|
assert_eq!(input_text(&app), "");
|
|
assert!(
|
|
app.blocks
|
|
.iter()
|
|
.any(|b| matches!(b, Block::TurnHeader { .. }))
|
|
);
|
|
assert!(
|
|
app.blocks
|
|
.iter()
|
|
.any(|b| matches!(b, Block::UserMessage { .. }))
|
|
);
|
|
assert!(
|
|
app.blocks
|
|
.iter()
|
|
.any(|b| matches!(b, Block::TurnStats { .. }))
|
|
);
|
|
assert!(!warning_contains(&app, "Rolled back empty assistant turn"));
|
|
assert!(app.last_rolled_back_input.is_none());
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn running_submit_is_queued_locally_and_clears_composer() {
|
|
let mut app = App::new("test".into());
|
|
app.set_pod_status(PodStatus::Running);
|
|
insert_text(&mut app, "queued turn");
|
|
|
|
assert!(app.submit_input().is_none());
|
|
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
assert_eq!(app.next_queued_input_preview(), Some("queued turn"));
|
|
assert_eq!(input_text(&app), "");
|
|
}
|
|
|
|
#[test]
|
|
fn finished_run_auto_sends_next_queued_input() {
|
|
let mut app = App::new("test".into());
|
|
app.set_pod_status(PodStatus::Running);
|
|
insert_text(&mut app, "next turn");
|
|
assert!(app.submit_input().is_none());
|
|
|
|
let method = app.handle_pod_event(Event::RunEnd {
|
|
result: RunResult::Finished,
|
|
});
|
|
|
|
match method {
|
|
Some(Method::Run { input }) => {
|
|
assert_eq!(Segment::flatten_to_text(&input), "next turn");
|
|
}
|
|
other => panic!("expected queued Run, got {other:?}"),
|
|
}
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn limit_reached_run_auto_sends_next_queued_input() {
|
|
let mut app = App::new("test".into());
|
|
app.set_pod_status(PodStatus::Running);
|
|
insert_text(&mut app, "next after limit");
|
|
assert!(app.submit_input().is_none());
|
|
|
|
let method = app.handle_pod_event(Event::RunEnd {
|
|
result: RunResult::LimitReached,
|
|
});
|
|
|
|
match method {
|
|
Some(Method::Run { input }) => {
|
|
assert_eq!(Segment::flatten_to_text(&input), "next after limit");
|
|
}
|
|
other => panic!("expected queued Run, got {other:?}"),
|
|
}
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn paused_and_rolled_back_run_do_not_auto_send_queue() {
|
|
for result in [RunResult::Paused, RunResult::RolledBack] {
|
|
let mut app = App::new("test".into());
|
|
app.set_pod_status(PodStatus::Running);
|
|
insert_text(&mut app, "held turn");
|
|
assert!(app.submit_input().is_none());
|
|
|
|
let method = app.handle_pod_event(Event::RunEnd { result });
|
|
|
|
assert!(method.is_none());
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
assert_eq!(app.next_queued_input_preview(), Some("held turn"));
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn paused_empty_submit_still_resumes_immediately() {
|
|
let mut app = App::new("test".into());
|
|
app.set_pod_status(PodStatus::Paused);
|
|
|
|
assert!(matches!(app.submit_input(), Some(Method::Resume)));
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
}
|
|
|
|
#[test]
|
|
fn queued_input_can_be_restored_to_composer_or_cleared() {
|
|
let mut app = App::new("test".into());
|
|
app.set_pod_status(PodStatus::Running);
|
|
insert_text(&mut app, "edit me");
|
|
assert!(app.submit_input().is_none());
|
|
|
|
assert!(app.restore_next_queued_input_to_composer());
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
assert_eq!(input_text(&app), "edit me");
|
|
|
|
app.input.clear();
|
|
insert_text(&mut app, "clear me");
|
|
assert!(app.submit_input().is_none());
|
|
assert_eq!(app.clear_queued_inputs(), 1);
|
|
assert_eq!(app.queued_input_count(), 0);
|
|
}
|
|
|
|
fn insert_text(app: &mut App, text: &str) {
|
|
for c in text.chars() {
|
|
app.insert_char(c);
|
|
}
|
|
}
|
|
|
|
fn submit_text(app: &mut App, text: &str) -> Vec<Segment> {
|
|
for c in text.chars() {
|
|
app.insert_char(c);
|
|
}
|
|
match app.submit_input() {
|
|
Some(Method::Run { input }) => input,
|
|
other => panic!("expected Run, got {other:?}"),
|
|
}
|
|
}
|
|
|
|
fn input_text(app: &App) -> String {
|
|
Segment::flatten_to_text(&app.input.submit_segments())
|
|
}
|
|
|
|
fn warning_contains(app: &App, needle: &str) -> bool {
|
|
app.blocks.iter().any(|block| {
|
|
matches!(
|
|
block,
|
|
Block::Alert {
|
|
level: AlertLevel::Warn,
|
|
message,
|
|
..
|
|
} if message.contains(needle)
|
|
)
|
|
})
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_renders_system_message_block_from_session_start() {
|
|
let mut app = App::new("test".into());
|
|
let session_start = session_store::LogEntry::SegmentStart {
|
|
ts: 1,
|
|
session_id: uuid::Uuid::nil(),
|
|
system_prompt: None,
|
|
config: Default::default(),
|
|
history: vec![session_store::LoggedItem::from(
|
|
&llm_worker::Item::system_message("[File: src/main.rs]\nfn main() {}"),
|
|
)],
|
|
forked_from: None,
|
|
compacted_from: None,
|
|
};
|
|
let session_start_value = serde_json::to_value(&session_start).unwrap();
|
|
app.handle_pod_event(Event::Snapshot {
|
|
greeting: test_greeting(),
|
|
entries: vec![session_start_value],
|
|
status: PodStatus::Running,
|
|
});
|
|
|
|
assert!(matches!(app.pod_status, PodStatus::Running));
|
|
assert!(app.running);
|
|
assert!(matches!(
|
|
app.blocks.get(1),
|
|
Some(Block::SystemMessage { text }) if text == "[File: src/main.rs]\nfn main() {}"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn live_system_item_workflow_appends_system_message_block() {
|
|
let mut app = App::new("test".into());
|
|
let item = serde_json::json!({
|
|
"kind": "workflow",
|
|
"slug": "build",
|
|
"body": "[Workflow /build]\nRun the build",
|
|
});
|
|
app.handle_pod_event(Event::SystemItem { item });
|
|
|
|
assert!(matches!(
|
|
app.blocks.as_slice(),
|
|
[Block::SystemMessage { text }] if text == "[Workflow /build]\nRun the build"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn live_system_item_notification_appends_notify_block() {
|
|
let mut app = App::new("test".into());
|
|
let item = serde_json::json!({
|
|
"kind": "notification",
|
|
"message": "hi",
|
|
"body": "[Notification] hi",
|
|
});
|
|
app.handle_pod_event(Event::SystemItem { item });
|
|
assert!(matches!(
|
|
app.blocks.as_slice(),
|
|
[Block::Notify { message }] if message == "hi"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn live_system_item_pod_event_appends_pod_event_block() {
|
|
let mut app = App::new("test".into());
|
|
let item = serde_json::json!({
|
|
"kind": "pod_event",
|
|
"event": { "kind": "turn_ended", "pod_name": "child" },
|
|
"body": "[Notification] pod `child` finished a turn",
|
|
});
|
|
app.handle_pod_event(Event::SystemItem { item });
|
|
assert_eq!(app.blocks.len(), 1);
|
|
match &app.blocks[0] {
|
|
Block::PodEvent {
|
|
event: protocol::PodEvent::TurnEnded { pod_name },
|
|
} => assert_eq!(pod_name, "child"),
|
|
_ => panic!("expected a PodEvent block"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn compact_done_replaces_live_block() {
|
|
let mut app = App::new("test".into());
|
|
let id = uuid::Uuid::parse_str("12345678-1234-5678-1234-567812345678").unwrap();
|
|
|
|
app.handle_pod_event(Event::CompactStart);
|
|
app.handle_pod_event(Event::CompactDone { new_segment_id: id });
|
|
|
|
assert_eq!(compact_block_count(&app), 1);
|
|
assert!(matches!(
|
|
app.blocks.as_slice(),
|
|
[Block::Compact(CompactEvent::Done {
|
|
new_segment_id,
|
|
elapsed_secs: Some(_),
|
|
})] if *new_segment_id == id
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn compact_failed_replaces_live_block() {
|
|
let mut app = App::new("test".into());
|
|
|
|
app.handle_pod_event(Event::CompactStart);
|
|
app.handle_pod_event(Event::CompactFailed {
|
|
error: "provider 429".into(),
|
|
});
|
|
|
|
assert_eq!(compact_block_count(&app), 1);
|
|
assert!(matches!(
|
|
app.blocks.as_slice(),
|
|
[Block::Compact(CompactEvent::Failed {
|
|
error,
|
|
elapsed_secs: Some(_),
|
|
})] if error == "provider 429"
|
|
));
|
|
}
|
|
|
|
#[test]
|
|
fn shutdown_marks_live_compact_incomplete() {
|
|
let mut app = App::new("test".into());
|
|
|
|
app.handle_pod_event(Event::CompactStart);
|
|
app.handle_pod_event(Event::Shutdown);
|
|
|
|
assert!(app.quit);
|
|
assert!(matches!(
|
|
app.blocks.as_slice(),
|
|
[Block::Compact(CompactEvent::Incomplete {
|
|
elapsed_secs: Some(_),
|
|
})]
|
|
));
|
|
}
|
|
|
|
fn compact_block_count(app: &App) -> usize {
|
|
app.blocks
|
|
.iter()
|
|
.filter(|block| matches!(block, Block::Compact(_)))
|
|
.count()
|
|
}
|
|
|
|
fn test_greeting() -> protocol::Greeting {
|
|
protocol::Greeting {
|
|
pod_name: "test".into(),
|
|
cwd: "/tmp".into(),
|
|
provider: "test-provider".into(),
|
|
model: "test-model".into(),
|
|
scope_summary: String::new(),
|
|
tools: Vec::new(),
|
|
context_window: 200_000,
|
|
context_tokens: 0,
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_initializes_context_usage() {
|
|
let mut app = App::new("test".into());
|
|
let mut greeting = test_greeting();
|
|
greeting.context_window = 123_000;
|
|
greeting.context_tokens = 45_000;
|
|
|
|
app.handle_pod_event(Event::Snapshot {
|
|
entries: Vec::new(),
|
|
greeting,
|
|
status: PodStatus::Idle,
|
|
});
|
|
|
|
assert_eq!(app.context_window, 123_000);
|
|
assert_eq!(app.session_context_tokens, 45_000);
|
|
}
|
|
|
|
#[test]
|
|
fn usage_updates_session_context_tokens_without_cache_discount() {
|
|
let mut app = App::new("test".into());
|
|
|
|
app.handle_pod_event(Event::Usage {
|
|
input_tokens: Some(42_000),
|
|
output_tokens: Some(9),
|
|
cache_read_input_tokens: Some(40_000),
|
|
});
|
|
|
|
assert_eq!(app.session_context_tokens, 42_000);
|
|
assert_eq!(app.run_upload_tokens, 2_000);
|
|
assert_eq!(app.run_output_tokens, 9);
|
|
}
|
|
|
|
#[test]
|
|
fn memory_worker_event_updates_actionbar_state() {
|
|
let mut app = App::new("test".into());
|
|
|
|
app.handle_pod_event(Event::MemoryWorker(protocol::MemoryWorkerEvent {
|
|
worker: "extract".into(),
|
|
status: "done".into(),
|
|
run_id: "00000000-0000-0000-0000-000000000000".into(),
|
|
trigger: "token_threshold".into(),
|
|
reason: "completed_staging_written".into(),
|
|
message: "memory extract done: completed_staging_written".into(),
|
|
timestamp_ms: 0,
|
|
}));
|
|
|
|
assert_eq!(
|
|
app.latest_memory_worker_event.as_deref(),
|
|
Some("memory extract done: completed_staging_written")
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn compact_done_resets_session_context_tokens() {
|
|
let mut app = App::new("test".into());
|
|
app.session_context_tokens = 42_000;
|
|
|
|
app.handle_pod_event(Event::CompactDone {
|
|
new_segment_id: uuid::Uuid::nil(),
|
|
});
|
|
|
|
assert_eq!(app.session_context_tokens, 0);
|
|
}
|
|
|
|
#[test]
|
|
fn turn_start_and_run_end_do_not_reset_session_context_tokens() {
|
|
let mut app = App::new("test".into());
|
|
app.session_context_tokens = 42_000;
|
|
|
|
app.handle_pod_event(Event::TurnStart { turn: 1 });
|
|
app.handle_pod_event(Event::RunEnd {
|
|
result: RunResult::Finished,
|
|
});
|
|
|
|
assert_eq!(app.session_context_tokens, 42_000);
|
|
}
|
|
|
|
#[test]
|
|
fn live_task_create_updates_task_store() {
|
|
let mut app = App::new("test".into());
|
|
app.handle_pod_event(Event::ToolCallStart {
|
|
id: "c1".into(),
|
|
name: "TaskCreate".into(),
|
|
});
|
|
app.handle_pod_event(Event::ToolCallDone {
|
|
id: "c1".into(),
|
|
name: "TaskCreate".into(),
|
|
arguments: r#"{"subject":"impl tasks","description":"do it"}"#.into(),
|
|
});
|
|
let tasks = app.task_store.tasks();
|
|
assert_eq!(tasks.len(), 1);
|
|
assert_eq!(tasks[0].subject, "impl tasks");
|
|
assert_eq!(tasks[0].status, crate::task::TaskStatus::Pending);
|
|
}
|
|
|
|
#[test]
|
|
fn live_task_update_advances_status() {
|
|
let mut app = App::new("test".into());
|
|
for (id, args) in [
|
|
("c1", r#"{"subject":"a","description":"A"}"#),
|
|
("u1", r#"{"taskid":1,"status":"completed"}"#),
|
|
] {
|
|
let name = if id.starts_with('c') {
|
|
"TaskCreate"
|
|
} else {
|
|
"TaskUpdate"
|
|
};
|
|
app.handle_pod_event(Event::ToolCallStart {
|
|
id: id.into(),
|
|
name: name.into(),
|
|
});
|
|
app.handle_pod_event(Event::ToolCallDone {
|
|
id: id.into(),
|
|
name: name.into(),
|
|
arguments: args.into(),
|
|
});
|
|
}
|
|
assert_eq!(
|
|
app.task_store.tasks()[0].status,
|
|
crate::task::TaskStatus::Completed
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn live_system_snapshot_replaces_task_store() {
|
|
let mut app = App::new("test".into());
|
|
// Stale entry that the snapshot must wipe out.
|
|
app.handle_pod_event(Event::ToolCallStart {
|
|
id: "c1".into(),
|
|
name: "TaskCreate".into(),
|
|
});
|
|
app.handle_pod_event(Event::ToolCallDone {
|
|
id: "c1".into(),
|
|
name: "TaskCreate".into(),
|
|
arguments: r#"{"subject":"stale","description":""}"#.into(),
|
|
});
|
|
|
|
let snapshot = "[Session TaskStore snapshot]\n\n\
|
|
TaskStore: 1 task(s)\n\n\
|
|
```json\n{\n \"tasks\": [\n {\n \"taskid\": 4,\n \
|
|
\"status\": \"inprogress\",\n \"subject\": \"from snapshot\",\n \
|
|
\"description\": \"d\"\n }\n ]\n}\n```\n";
|
|
// Snapshot text injected as a workflow body (kind doesn't matter
|
|
// for task-store parsing, only the text contents do).
|
|
app.handle_pod_event(Event::SystemItem {
|
|
item: serde_json::json!({
|
|
"kind": "workflow",
|
|
"slug": "task-snapshot",
|
|
"body": snapshot,
|
|
}),
|
|
});
|
|
|
|
let tasks = app.task_store.tasks();
|
|
assert_eq!(tasks.len(), 1);
|
|
assert_eq!(tasks[0].taskid, 4);
|
|
assert_eq!(tasks[0].subject, "from snapshot");
|
|
}
|
|
|
|
#[test]
|
|
fn snapshot_reconstructs_task_store() {
|
|
let mut app = App::new("test".into());
|
|
// Live tool call before the snapshot lands — restore must wipe
|
|
// this so it doesn't double-count after replay.
|
|
app.handle_pod_event(Event::ToolCallStart {
|
|
id: "live".into(),
|
|
name: "TaskCreate".into(),
|
|
});
|
|
app.handle_pod_event(Event::ToolCallDone {
|
|
id: "live".into(),
|
|
name: "TaskCreate".into(),
|
|
arguments: r#"{"subject":"live","description":""}"#.into(),
|
|
});
|
|
|
|
let assistant_item_entries = vec![
|
|
serde_json::json!({
|
|
"kind": "assistant_item",
|
|
"ts": 1,
|
|
"item": {
|
|
"kind": "tool_call",
|
|
"call_id": "c1",
|
|
"name": "TaskCreate",
|
|
"arguments": r#"{"subject":"a","description":"A"}"#,
|
|
},
|
|
}),
|
|
serde_json::json!({
|
|
"kind": "assistant_item",
|
|
"ts": 2,
|
|
"item": {
|
|
"kind": "tool_call",
|
|
"call_id": "c2",
|
|
"name": "TaskCreate",
|
|
"arguments": r#"{"subject":"b","description":"B"}"#,
|
|
},
|
|
}),
|
|
serde_json::json!({
|
|
"kind": "assistant_item",
|
|
"ts": 3,
|
|
"item": {
|
|
"kind": "tool_call",
|
|
"call_id": "u1",
|
|
"name": "TaskUpdate",
|
|
"arguments": r#"{"taskid":2,"status":"inprogress"}"#,
|
|
},
|
|
}),
|
|
];
|
|
app.handle_pod_event(Event::Snapshot {
|
|
greeting: test_greeting(),
|
|
entries: assistant_item_entries,
|
|
status: PodStatus::Running,
|
|
});
|
|
|
|
let tasks = app.task_store.tasks();
|
|
assert_eq!(tasks.len(), 2);
|
|
assert_eq!(tasks[0].subject, "a");
|
|
assert_eq!(tasks[1].subject, "b");
|
|
assert_eq!(tasks[1].status, crate::task::TaskStatus::Inprogress);
|
|
}
|
|
|
|
#[test]
|
|
fn input_history_records_queued_inputs_and_suppresses_consecutive_duplicates() {
|
|
let mut app = App::new("test".into());
|
|
app.running = true;
|
|
|
|
for c in "repeat".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(app.submit_input().is_none());
|
|
assert_eq!(app.input_history_len(), 1);
|
|
assert_eq!(app.queued_input_count(), 1);
|
|
|
|
for c in "repeat".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(app.submit_input().is_none());
|
|
assert_eq!(app.input_history_len(), 1);
|
|
assert_eq!(app.queued_input_count(), 2);
|
|
|
|
app.insert_char(' ');
|
|
assert!(app.submit_input().is_none());
|
|
assert_eq!(app.input_history_len(), 1);
|
|
}
|
|
|
|
#[test]
|
|
fn input_history_preserves_typed_segments() {
|
|
let mut app = App::new("test".into());
|
|
let original = vec![
|
|
Segment::Text {
|
|
content: "see ".into(),
|
|
},
|
|
Segment::FileRef {
|
|
path: "src/main.rs".into(),
|
|
},
|
|
Segment::Text {
|
|
content: " and ".into(),
|
|
},
|
|
Segment::KnowledgeRef {
|
|
slug: "design-note".into(),
|
|
},
|
|
Segment::Text {
|
|
content: " then ".into(),
|
|
},
|
|
Segment::WorkflowInvoke {
|
|
slug: "review".into(),
|
|
},
|
|
Segment::Paste {
|
|
id: 1,
|
|
chars: 13,
|
|
lines: 1,
|
|
content: "literal paste".into(),
|
|
},
|
|
];
|
|
app.input.replace_with_segments(&original);
|
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
|
|
|
assert!(app.browse_input_history_older());
|
|
assert_eq!(app.input.submit_segments(), original);
|
|
}
|
|
|
|
#[test]
|
|
fn input_history_restores_non_empty_draft_after_newest() {
|
|
let mut app = App::new("test".into());
|
|
for c in "sent".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
|
|
|
for c in "draft".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(app.browse_input_history_older());
|
|
assert_eq!(input_text(&app), "sent");
|
|
assert!(app.browse_input_history_newer());
|
|
assert_eq!(input_text(&app), "draft");
|
|
assert!(!app.input_history_is_browsing());
|
|
}
|
|
|
|
#[test]
|
|
fn editing_recalled_input_exits_history_browse_mode() {
|
|
let mut app = App::new("test".into());
|
|
for c in "sent".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
|
|
|
assert!(app.browse_input_history_older());
|
|
assert!(app.input_history_is_browsing());
|
|
app.insert_char('!');
|
|
assert!(!app.input_history_is_browsing());
|
|
assert_eq!(input_text(&app), "sent!");
|
|
assert!(!app.browse_input_history_newer());
|
|
assert_eq!(input_text(&app), "sent!");
|
|
}
|
|
|
|
#[test]
|
|
fn submitting_recalled_history_sends_normally_and_records_if_not_duplicate() {
|
|
let mut app = App::new("test".into());
|
|
for c in "first".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
|
for c in "second".chars() {
|
|
app.insert_char(c);
|
|
}
|
|
assert!(matches!(app.submit_input(), Some(Method::Run { .. })));
|
|
|
|
assert!(app.browse_input_history_older());
|
|
assert!(app.browse_input_history_older());
|
|
let method = app.submit_input();
|
|
match method {
|
|
Some(Method::Run { input }) => assert_eq!(Segment::flatten_to_text(&input), "first"),
|
|
other => panic!("expected recalled run, got {other:?}"),
|
|
}
|
|
assert_eq!(app.input_history_len(), 3);
|
|
assert!(!app.input_history_is_browsing());
|
|
}
|
|
|
|
#[test]
|
|
fn task_pane_toggle_flips_state_and_resets_scroll() {
|
|
let mut app = App::new("test".into());
|
|
app.task_pane_scroll = 7;
|
|
assert!(!app.task_pane_open);
|
|
app.toggle_task_pane();
|
|
assert!(app.task_pane_open);
|
|
// Scroll position is preserved on open so the user keeps their
|
|
// place if they re-open after closing.
|
|
assert_eq!(app.task_pane_scroll, 7);
|
|
app.toggle_task_pane();
|
|
assert!(!app.task_pane_open);
|
|
assert_eq!(app.task_pane_scroll, 0);
|
|
}
|
|
}
|
|
|
|
/// 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 {
|
|
// The Read tool emits a `cat -n` style display: each
|
|
// line is "{lineno:>6}\tcontent". Strip that framing
|
|
// so the cache mirrors the real file body and the
|
|
// Edit diff renderer has a faithful "before" view.
|
|
cache.put(path, strip_cat_n_prefix(content));
|
|
}
|
|
}
|
|
"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);
|
|
}
|
|
_ => {}
|
|
}
|
|
}
|