refactor(tui/ui): extract vim-mode, workspace-context, streaming-thinking

Three more cohesive function clusters move out of the 10k-line ui.rs:

* `tui::vim_mode` (new) — `handle_vim_normal_key`, the composer's
  Normal-mode dispatch (h/j/k/l, w/b, x/dd, i/a/o/v/G, plus the
  pending-`d` operator state).

* `tui::workspace_context` (new) — the composer-header git context
  badge: `refresh_if_needed`, `collect`, `branch`, `change_summary`,
  `run_git`, the `ChangeSummary` struct, and the `REFRESH_SECS` TTL
  constant. Replaces `refresh_workspace_context_if_needed` /
  `collect_workspace_context` / `workspace_git_branch` /
  `workspace_git_change_summary` / `run_git_query` / `WorkspaceChangeSummary`
  / `WORKSPACE_CONTEXT_REFRESH_SECS` in ui.rs.

* `tui::streaming_thinking` (new) — the 10-function lifecycle that
  manages the live "Thinking" entry inside `active_cell`: ensuring an
  entry exists, appending chunks, animating the translation
  placeholder, replacing it with finalized text, starting / finalizing
  a block, and stashing the reasoning buffer onto `app.last_reasoning`
  so it survives compaction.

Plus the unused `ActiveCell` import in ui.rs that became dead after
the streaming-thinking move.

ui.rs is now ~9434 lines (down from ~10025). All 954 tui::* tests
still pass; no runtime behavior change.
This commit is contained in:
Hunter Bown
2026-05-13 01:41:15 -05:00
parent 838ad20d40
commit 7744ee781a
6 changed files with 530 additions and 485 deletions
+3
View File
@@ -15,6 +15,8 @@ pub mod active_cell;
pub mod app;
pub mod approval;
pub mod auto_router;
pub mod vim_mode;
pub mod workspace_context;
pub mod backtrack;
pub mod clipboard;
mod color_compat;
@@ -50,6 +52,7 @@ pub mod selection;
pub mod session_picker;
mod shell_job_routing;
pub mod sidebar;
pub mod streaming_thinking;
pub mod slash_menu;
pub mod streaming;
mod subagent_routing;
+246
View File
@@ -0,0 +1,246 @@
//! Streaming-thinking lifecycle for the active cell.
//!
//! DeepSeek V4 emits `reasoning_content` chunks before final answers.
//! These get rendered as a "Thinking" entry inside the per-turn active
//! cell. This module is the single source of truth for:
//!
//! - creating a streaming thinking entry on first chunk
//! - appending chunks to the live entry
//! - showing a localized placeholder while a translation is in-flight
//! (and animating its elapsed/spinner suffix)
//! - replacing the placeholder when the translation arrives
//! - finalizing the entry (stopping the spinner, stamping duration)
//! when a thinking block ends
//! - stashing the reasoning buffer onto `app.last_reasoning` so the
//! summary survives compaction
use std::time::Instant;
use crate::tui::active_cell::ActiveCell;
use crate::tui::app::App;
use crate::tui::history::HistoryCell;
/// Ensure an in-flight Thinking entry exists in `active_cell` and return its
/// entry index. If no thinking entry is currently streaming, push a fresh one.
/// P2.3: thinking shares the active cell with subsequent tool calls so the
/// pair render as one logical "Working…" block.
pub(super) fn ensure_active_entry(app: &mut App) -> usize {
if let Some(idx) = app.streaming_thinking_active_entry {
return idx;
}
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.push_thinking(HistoryCell::Thinking {
content: String::new(),
streaming: true,
duration_secs: None,
});
app.streaming_thinking_active_entry = Some(entry_idx);
app.bump_active_cell_revision();
entry_idx
}
/// Append text to a streaming Thinking entry inside `active_cell`. Bumps the
/// active-cell revision so the renderer re-draws the live tail.
pub(super) fn append(app: &mut App, entry_idx: usize, text: &str) {
if text.is_empty() {
return;
}
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
{
content.push_str(text);
true
} else {
false
};
if mutated {
app.bump_active_cell_revision();
}
}
/// Build the spinner-decorated placeholder shown in the thinking entry
/// while a translation is in flight (`Thinking… (1.2s |)`).
pub(super) fn translation_placeholder_frame(app: &App) -> String {
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let elapsed = app
.thinking_started_at
.or(app.turn_started_at)
.map(|started| started.elapsed().as_secs_f32())
.unwrap_or_default();
let frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 {
0 => "|",
1 => "/",
2 => "-",
_ => "\\",
};
format!("{base} ({elapsed:.1}s {frame})")
}
/// If the given entry is empty or still showing the translation
/// placeholder prefix, replace it with the latest animated frame.
pub(super) fn set_placeholder(app: &mut App, entry_idx: usize) {
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let next = translation_placeholder_frame(app);
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
&& (content.is_empty() || content.starts_with(base))
{
if *content != next {
*content = next;
true
} else {
false
}
} else {
false
};
if mutated {
app.bump_active_cell_revision();
}
}
/// Advance the spinner suffix on every existing translation placeholder
/// in `active_cell`. Returns true when at least one cell was updated so
/// the dispatch loop can schedule another tick.
pub(super) fn animate_pending_translation(app: &mut App, translation_pending: bool) -> bool {
if !app.translation_enabled {
return false;
}
let thinking_streaming = app.streaming_thinking_active_entry.is_some();
if !translation_pending && !thinking_streaming {
return false;
}
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let next = translation_placeholder_frame(app);
if let Some(active) = app.active_cell.as_mut() {
for idx in (0..active.entry_count()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
&& content.starts_with(base)
&& *content != next
{
*content = next.clone();
app.bump_active_cell_revision();
return true;
}
}
}
false
}
/// Replace a translation placeholder with the finished translated text.
/// Searches the active cell first, then the finalized history (covers
/// the case where the translation lands after the thinking block was
/// already moved into history).
pub(super) fn replace_pending_translation(
app: &mut App,
placeholder: &str,
translated_text: String,
) {
if let Some(active) = app.active_cell.as_mut() {
for idx in (0..active.entry_count()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
&& content.starts_with(placeholder)
{
*content = translated_text;
app.bump_active_cell_revision();
return;
}
}
}
for idx in (0..app.history.len()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = app.history.get_mut(idx)
&& content.starts_with(placeholder)
{
*content = translated_text;
app.bump_history_cell(idx);
return;
}
}
}
/// Start a new streaming thinking block. If another thinking block is still
/// active, first drain its pending UI tail so a late block boundary cannot
/// discard content buffered inside `StreamingState`.
pub(super) fn start_block(app: &mut App) -> bool {
let finalized_previous = if app.streaming_thinking_active_entry.is_some() {
let finalized = finalize_current(app);
stash_reasoning_buffer_into_last_reasoning(app);
finalized
} else {
false
};
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.thinking_started_at = Some(Instant::now());
app.streaming_state.reset();
app.streaming_state.start_thinking(0, None);
let _ = ensure_active_entry(app);
finalized_previous
}
/// Finalize the currently-streaming thinking entry: drain the pending
/// state buffer, compute elapsed duration, stop the spinner.
pub(super) fn finalize_current(app: &mut App) -> bool {
let duration = app
.thinking_started_at
.take()
.map(|t| t.elapsed().as_secs_f32());
let remaining = app.streaming_state.finalize_block_text(0);
finalize_active_entry(app, duration, &remaining)
}
/// Move the in-flight reasoning buffer onto `app.last_reasoning` so the
/// summary survives compaction or transcript trimming.
pub(super) fn stash_reasoning_buffer_into_last_reasoning(app: &mut App) {
if app.reasoning_buffer.is_empty() {
return;
}
if let Some(existing) = app.last_reasoning.as_mut()
&& !existing.is_empty()
{
if !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&app.reasoning_buffer);
} else {
app.last_reasoning = Some(app.reasoning_buffer.clone());
}
app.reasoning_buffer.clear();
}
/// Finalize the in-flight thinking entry in `active_cell`: append the
/// collector's remaining buffered text, stop the spinner, and stamp the
/// duration. Returns `true` when a thinking entry was finalized (so the
/// dispatch loop knows the transcript was touched). No-op if no thinking
/// entry is currently streaming.
pub(super) fn finalize_active_entry(
app: &mut App,
duration: Option<f32>,
remaining: &str,
) -> bool {
let Some(entry_idx) = app.streaming_thinking_active_entry.take() else {
return false;
};
if !remaining.is_empty() {
append(app, entry_idx, remaining);
}
if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking {
streaming,
duration_secs,
..
}) = active.entry_mut(entry_idx)
{
*streaming = false;
*duration_secs = duration;
}
app.bump_active_cell_revision();
true
}
+25 -459
View File
@@ -70,6 +70,9 @@ use crate::tui::event_broker::EventBroker;
use crate::tui::live_transcript::LiveTranscriptOverlay;
use crate::tui::mcp_routing::{add_mcp_message, open_mcp_manager_pager};
use crate::tui::auto_router;
use crate::tui::vim_mode;
use crate::tui::streaming_thinking;
use crate::tui::workspace_context;
use crate::tui::onboarding;
use crate::tui::pager::PagerView;
use crate::tui::persistence_actor::{self, PersistRequest};
@@ -94,7 +97,6 @@ use crate::tui::ui_text::{history_cell_to_text, line_to_plain, slice_text, text_
use crate::tui::user_input::UserInputView;
use crate::tui::views::subagent_view_agents;
use super::active_cell::ActiveCell;
use super::app::{
App, AppAction, AppMode, OnboardingState, QueuedMessage, ReasoningEffort, SidebarFocus,
StatusToastLevel, SubmitDisposition, TaskPanelEntry, ToolDetailRecord, TuiOptions,
@@ -142,7 +144,6 @@ const DISPATCH_WATCHDOG_TIMEOUT: Duration = Duration::from_secs(30);
// the per-tool spinner pulse — keep this fast enough that the spout reads as
// motion (~12 fps) instead of teleport-frames.
const UI_STATUS_ANIMATION_MS: u64 = 80;
const WORKSPACE_CONTEXT_REFRESH_SECS: u64 = 15;
const SIDEBAR_VISIBLE_MIN_WIDTH: u16 = 100;
const DEFAULT_TERMINAL_PROBE_TIMEOUT_MS: u64 = 500;
const PERIODIC_FULL_REPAINT_EVERY_N: u64 = 50;
@@ -838,7 +839,7 @@ async fn run_event_loop(
.to_string()
}
};
replace_pending_thinking_translation(app, &placeholder, text);
streaming_thinking::replace_pending_translation(app, &placeholder, text);
if pending_translations == 0
&& !matches!(app.runtime_turn_status.as_deref(), Some("in_progress"))
{
@@ -910,10 +911,10 @@ async fn run_event_loop(
// thinking entry here so this branch is order-
// independent.
if app.streaming_thinking_active_entry.is_some() {
if finalize_current_streaming_thinking(app) {
if streaming_thinking::finalize_current(app) {
transcript_batch_updated = true;
}
stash_reasoning_buffer_into_last_reasoning(app);
streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
}
let mut completed_message_index = None;
if let Some(index) = app.streaming_message_index.take() {
@@ -990,12 +991,12 @@ async fn run_event_loop(
// P2.3: thinking lives in the active cell so it groups
// visually with the tool calls that follow until the
// next assistant prose chunk flushes the group.
if start_streaming_thinking_block(app) {
if streaming_thinking::start_block(app) {
transcript_batch_updated = true;
}
if app.translation_enabled {
let entry_idx = ensure_streaming_thinking_active_entry(app);
set_streaming_thinking_placeholder(app, entry_idx);
let entry_idx = streaming_thinking::ensure_active_entry(app);
streaming_thinking::set_placeholder(app, entry_idx);
transcript_batch_updated = true;
}
}
@@ -1009,14 +1010,14 @@ async fn run_event_loop(
app.reasoning_header = extract_reasoning_header(&app.reasoning_buffer);
}
let entry_idx = ensure_streaming_thinking_active_entry(app);
let entry_idx = streaming_thinking::ensure_active_entry(app);
app.streaming_state.push_content(0, &sanitized);
let committed = app.streaming_state.commit_text(0);
if !committed.is_empty() {
if app.translation_enabled {
set_streaming_thinking_placeholder(app, entry_idx);
streaming_thinking::set_placeholder(app, entry_idx);
} else {
append_streaming_thinking(app, entry_idx, &committed);
streaming_thinking::append(app, entry_idx, &committed);
}
transcript_batch_updated = true;
}
@@ -1029,7 +1030,7 @@ async fn run_event_loop(
.thinking_started_at
.take()
.map(|t| t.elapsed().as_secs_f32());
if finalize_streaming_thinking_active_entry(app, duration, "") {
if streaming_thinking::finalize_active_entry(app, duration, "") {
transcript_batch_updated = true;
}
if !original_thinking.is_empty()
@@ -1076,16 +1077,16 @@ async fn run_event_loop(
crate::localization::thinking_translation_placeholder(
app.ui_locale,
);
replace_pending_thinking_translation(
streaming_thinking::replace_pending_translation(
app,
placeholder,
original_thinking,
);
}
} else if finalize_current_streaming_thinking(app) {
} else if streaming_thinking::finalize_current(app) {
transcript_batch_updated = true;
}
stash_reasoning_buffer_into_last_reasoning(app);
streaming_thinking::stash_reasoning_buffer_into_last_reasoning(app);
}
EngineEvent::ToolCallStarted { id, name, input } => {
app.pending_tool_uses
@@ -1773,9 +1774,9 @@ async fn run_event_loop(
let committed = app.streaming_state.commit_text(0);
if !committed.is_empty() {
if app.translation_enabled {
set_streaming_thinking_placeholder(app, entry_idx);
streaming_thinking::set_placeholder(app, entry_idx);
} else {
append_streaming_thinking(app, entry_idx, &committed);
streaming_thinking::append(app, entry_idx, &committed);
}
transcript_batch_updated = true;
}
@@ -1835,7 +1836,7 @@ async fn run_event_loop(
&& last_status_frame.elapsed()
>= Duration::from_millis(status_animation_interval_ms(app))
{
if animate_pending_thinking_translation(app, pending_thinking_translations > 0) {
if streaming_thinking::animate_pending_translation(app, pending_thinking_translations > 0) {
app.mark_history_updated();
}
if !app.low_motion && history_has_live_motion(&app.history) {
@@ -1890,7 +1891,7 @@ async fn run_event_loop(
tick_selection_autoscroll(app);
let allow_workspace_context_refresh =
!app.is_loading && !has_running_agents && !app.is_compacting;
refresh_workspace_context_if_needed(app, now, allow_workspace_context_refresh);
workspace_context::refresh_if_needed(app, now, allow_workspace_context_refresh);
// Draw is gated by the frame-rate limiter (120 FPS cap). When a
// redraw is needed but the limiter says we're inside the cooldown
@@ -3334,7 +3335,7 @@ async fn run_event_loop(
&& !mention_menu_open
&& app.view_stack.is_empty() =>
{
handle_vim_normal_key(app, c);
vim_mode::handle_vim_normal_key(app, c);
continue;
}
// Vim composer: in Visual mode plain chars are ignored
@@ -3359,86 +3360,6 @@ async fn run_event_loop(
}
}
/// Handle a plain character key press when the composer is in vim Normal mode.
///
/// Implements the core set of normal-mode bindings:
/// - `h` / `l` — left / right by character
/// - `j` / `k` — down / up by logical line (falls back to prev/next history)
/// - `w` / `b` — word forward / backward
/// - `0` / `$` — line start / end
/// - `x` — delete character under cursor
/// - `d` (×2) — delete current line (`dd`)
/// - `i` — enter Insert before cursor
/// - `a` — enter Insert after cursor
/// - `o` — open new line below and enter Insert
/// - `v` — enter Visual mode
/// - `G` — move to end of buffer
fn handle_vim_normal_key(app: &mut App, c: char) {
use crate::tui::app::VimMode;
// Handle pending `d` (waiting for second `d` to complete `dd`).
if app.composer.vim_pending_d {
app.composer.vim_pending_d = false;
if c == 'd' {
app.vim_delete_line();
}
// Any other key cancels the pending operator.
return;
}
match c {
'h' => {
app.move_cursor_left();
}
'l' => {
app.move_cursor_right();
}
'j' => {
app.vim_move_down();
}
'k' => {
app.vim_move_up();
}
'w' => {
app.vim_move_word_forward();
}
'b' => {
app.vim_move_word_backward();
}
'0' => {
app.vim_move_line_start();
}
'$' => {
app.vim_move_line_end();
}
'x' => {
app.vim_delete_char_under_cursor();
}
'd' => {
// Start the `dd` operator sequence.
app.composer.vim_pending_d = true;
}
'i' => {
app.vim_enter_insert();
}
'a' => {
app.vim_enter_append();
}
'o' => {
app.vim_open_line_below();
}
'v' => {
app.composer.vim_mode = VimMode::Visual;
app.needs_redraw = true;
}
'G' => {
app.move_cursor_end();
}
_ => {
// Unknown normal-mode key — silently ignored in Normal mode.
}
}
}
fn apply_alt_4_shortcut(app: &mut App, _modifiers: KeyModifiers) {
app.set_sidebar_focus(SidebarFocus::Agents);
@@ -3658,7 +3579,7 @@ pub(crate) fn apply_engine_error_to_app(
let recoverable = envelope.recoverable;
let message = envelope.message.clone();
let severity = envelope.severity;
finalize_current_streaming_thinking(app);
streaming_thinking::finalize_current(app);
app.streaming_state.reset();
app.streaming_message_index = None;
app.streaming_thinking_active_entry = None;
@@ -3882,7 +3803,7 @@ fn notification_text_summary(text: &str) -> Option<String> {
}
/// Ensure an in-flight streaming Assistant cell exists in history and return
/// its index. Thinking cells go through `ensure_streaming_thinking_active_entry`
/// its index. Thinking cells go through `streaming_thinking::ensure_active_entry`
/// (active cell) instead.
fn ensure_streaming_assistant_history_cell(app: &mut App) -> usize {
if let Some(index) = app.streaming_message_index {
@@ -3972,211 +3893,7 @@ fn replace_matching_assistant_text(
false
}
/// Ensure an in-flight Thinking entry exists in `active_cell` and return its
/// entry index. If no thinking entry is currently streaming, push a fresh one.
/// P2.3: thinking shares the active cell with subsequent tool calls so the
/// pair render as one logical "Working…" block.
fn ensure_streaming_thinking_active_entry(app: &mut App) -> usize {
if let Some(idx) = app.streaming_thinking_active_entry {
return idx;
}
if app.active_cell.is_none() {
app.active_cell = Some(ActiveCell::new());
}
let active = app.active_cell.as_mut().expect("active_cell just ensured");
let entry_idx = active.push_thinking(HistoryCell::Thinking {
content: String::new(),
streaming: true,
duration_secs: None,
});
app.streaming_thinking_active_entry = Some(entry_idx);
app.bump_active_cell_revision();
entry_idx
}
/// Append text to a streaming Thinking entry inside `active_cell`. Bumps the
/// active-cell revision so the renderer re-draws the live tail.
fn append_streaming_thinking(app: &mut App, entry_idx: usize, text: &str) {
if text.is_empty() {
return;
}
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
{
content.push_str(text);
true
} else {
false
};
if mutated {
app.bump_active_cell_revision();
}
}
fn thinking_translation_placeholder_frame(app: &App) -> String {
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let elapsed = app
.thinking_started_at
.or(app.turn_started_at)
.map(|started| started.elapsed().as_secs_f32())
.unwrap_or_default();
let frame = match (elapsed.mul_add(2.0, 0.0) as usize) % 4 {
0 => "|",
1 => "/",
2 => "-",
_ => "\\",
};
format!("{base} ({elapsed:.1}s {frame})")
}
fn set_streaming_thinking_placeholder(app: &mut App, entry_idx: usize) {
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let next = thinking_translation_placeholder_frame(app);
let mutated = if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(entry_idx)
&& (content.is_empty() || content.starts_with(base))
{
if *content != next {
*content = next;
true
} else {
false
}
} else {
false
};
if mutated {
app.bump_active_cell_revision();
}
}
fn animate_pending_thinking_translation(app: &mut App, translation_pending: bool) -> bool {
if !app.translation_enabled {
return false;
}
let thinking_streaming = app.streaming_thinking_active_entry.is_some();
if !translation_pending && !thinking_streaming {
return false;
}
let base = crate::localization::thinking_translation_placeholder(app.ui_locale);
let next = thinking_translation_placeholder_frame(app);
if let Some(active) = app.active_cell.as_mut() {
for idx in (0..active.entry_count()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
&& content.starts_with(base)
&& *content != next
{
*content = next.clone();
app.bump_active_cell_revision();
return true;
}
}
}
false
}
fn replace_pending_thinking_translation(app: &mut App, placeholder: &str, translated_text: String) {
if let Some(active) = app.active_cell.as_mut() {
for idx in (0..active.entry_count()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = active.entry_mut(idx)
&& content.starts_with(placeholder)
{
*content = translated_text;
app.bump_active_cell_revision();
return;
}
}
}
for idx in (0..app.history.len()).rev() {
if let Some(HistoryCell::Thinking { content, .. }) = app.history.get_mut(idx)
&& content.starts_with(placeholder)
{
*content = translated_text;
app.bump_history_cell(idx);
return;
}
}
}
/// Start a new streaming thinking block. If another thinking block is still
/// active, first drain its pending UI tail so a late block boundary cannot
/// discard content buffered inside `StreamingState`.
fn start_streaming_thinking_block(app: &mut App) -> bool {
let finalized_previous = if app.streaming_thinking_active_entry.is_some() {
let finalized = finalize_current_streaming_thinking(app);
stash_reasoning_buffer_into_last_reasoning(app);
finalized
} else {
false
};
app.reasoning_buffer.clear();
app.reasoning_header = None;
app.thinking_started_at = Some(Instant::now());
app.streaming_state.reset();
app.streaming_state.start_thinking(0, None);
let _ = ensure_streaming_thinking_active_entry(app);
finalized_previous
}
fn finalize_current_streaming_thinking(app: &mut App) -> bool {
let duration = app
.thinking_started_at
.take()
.map(|t| t.elapsed().as_secs_f32());
let remaining = app.streaming_state.finalize_block_text(0);
finalize_streaming_thinking_active_entry(app, duration, &remaining)
}
fn stash_reasoning_buffer_into_last_reasoning(app: &mut App) {
if app.reasoning_buffer.is_empty() {
return;
}
if let Some(existing) = app.last_reasoning.as_mut()
&& !existing.is_empty()
{
if !existing.ends_with('\n') {
existing.push('\n');
}
existing.push_str(&app.reasoning_buffer);
} else {
app.last_reasoning = Some(app.reasoning_buffer.clone());
}
app.reasoning_buffer.clear();
}
/// Finalize the in-flight thinking entry in `active_cell`: append the
/// collector's remaining buffered text, stop the spinner, and stamp the
/// duration. Returns `true` when a thinking entry was finalized (so the
/// dispatch loop knows the transcript was touched). No-op if no thinking
/// entry is currently streaming.
fn finalize_streaming_thinking_active_entry(
app: &mut App,
duration: Option<f32>,
remaining: &str,
) -> bool {
let Some(entry_idx) = app.streaming_thinking_active_entry.take() else {
return false;
};
if !remaining.is_empty() {
append_streaming_thinking(app, entry_idx, remaining);
}
if let Some(active) = app.active_cell.as_mut()
&& let Some(HistoryCell::Thinking {
streaming,
duration_secs,
..
}) = active.entry_mut(entry_idx)
{
*streaming = false;
*duration_secs = duration;
}
app.bump_active_cell_revision();
true
}
// Streaming-thinking lifecycle helpers moved to `tui/streaming_thinking.rs`.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum EscapeAction {
@@ -7040,157 +6757,6 @@ fn compact_user_context_display(content: &str) -> String {
.to_string()
}
fn refresh_workspace_context_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
// Drain the async cell result into the live field first, so the render
// path always reads the latest value (#399 S1).
if let Ok(mut cell) = app.workspace_context_cell.lock()
&& let Some(ctx) = cell.take()
{
app.workspace_context = Some(ctx);
}
if app
.workspace_context_refreshed_at
.is_some_and(|refreshed_at| {
now.duration_since(refreshed_at) < Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS)
})
{
return;
}
if !allow_refresh {
return;
}
// Offload git query to a background thread when a Tokio runtime is
// available. Fall back to synchronous execution for tests and other
// non-async contexts (#399 S1).
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let ctx = app.workspace_context_cell.clone();
let workspace = app.workspace.clone();
handle.spawn_blocking(move || {
let result = collect_workspace_context(&workspace);
if let Ok(mut guard) = ctx.lock() {
*guard = result;
}
});
} else {
// No runtime — run synchronously so tests and one-shot callers
// still get a result immediately.
app.workspace_context = collect_workspace_context(&app.workspace);
}
app.workspace_context_refreshed_at = Some(now);
}
#[derive(Debug, Default, Clone, Copy)]
struct WorkspaceChangeSummary {
staged: usize,
modified: usize,
untracked: usize,
conflicts: usize,
}
impl WorkspaceChangeSummary {
fn is_clean(&self) -> bool {
self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
}
}
fn collect_workspace_context(workspace: &Path) -> Option<String> {
let branch = workspace_git_branch(workspace)?;
let summary = workspace_git_change_summary(workspace)?;
let mut parts = Vec::new();
if summary.staged > 0 {
parts.push(format!("{} staged", summary.staged));
}
if summary.modified > 0 {
parts.push(format!("{} modified", summary.modified));
}
if summary.untracked > 0 {
parts.push(format!("{} untracked", summary.untracked));
}
if summary.conflicts > 0 {
parts.push(format!("{} conflicts", summary.conflicts));
}
let status = if summary.is_clean() {
"clean".to_string()
} else {
parts.join(", ")
};
Some(format!("{branch} | {status}"))
}
fn workspace_git_branch(workspace: &Path) -> Option<String> {
let branch = run_git_query(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let branch = branch.trim().to_string();
if branch == "HEAD" || branch.is_empty() {
let short_hash = run_git_query(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
let short_hash = short_hash.trim();
if short_hash.is_empty() {
return None;
}
return Some(format!("detached:{short_hash}"));
}
Some(branch)
}
fn workspace_git_change_summary(workspace: &Path) -> Option<WorkspaceChangeSummary> {
let status = run_git_query(
workspace,
&["status", "--short", "--untracked-files=normal"],
)
.ok()?;
if status.trim().is_empty() {
return Some(WorkspaceChangeSummary::default());
}
let mut summary = WorkspaceChangeSummary::default();
for line in status.lines() {
if line.trim().is_empty() {
continue;
}
let mut chars = line.chars();
let staged = chars.next()?;
let modified = chars.next().unwrap_or(' ');
if staged == ' ' && modified == ' ' {
continue;
}
if staged == '?' && modified == '?' {
summary.untracked = summary.untracked.saturating_add(1);
continue;
}
if staged == 'U' || modified == 'U' {
summary.conflicts = summary.conflicts.saturating_add(1);
}
if staged != ' ' && staged != '?' {
summary.staged = summary.staged.saturating_add(1);
}
if modified != ' ' && modified != '?' {
summary.modified = summary.modified.saturating_add(1);
}
}
Some(summary)
}
fn run_git_query(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(workspace)
.output()?;
if !output.status.success() {
return Err(std::io::Error::other("git command failed"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}
fn pause_terminal(
terminal: &mut AppTerminal,
use_alt_screen: bool,
@@ -7902,7 +7468,7 @@ fn render_footer_from(
}
fn footer_git_branch_spans(app: &App) -> Vec<Span<'static>> {
let Some(branch) = workspace_git_branch(&app.workspace) else {
let Some(branch) = workspace_context::branch(&app.workspace) else {
return Vec::new();
};
vec![Span::styled(
+26 -26
View File
@@ -2654,12 +2654,12 @@ fn workspace_context_refresh_is_deferred_while_ui_is_busy() {
app.workspace = repo.path().to_path_buf();
let now = Instant::now();
refresh_workspace_context_if_needed(&mut app, now, false);
crate::tui::workspace_context::refresh_if_needed(&mut app, now, false);
assert!(app.workspace_context.is_none());
assert!(app.workspace_context_refreshed_at.is_none());
refresh_workspace_context_if_needed(&mut app, now, true);
crate::tui::workspace_context::refresh_if_needed(&mut app, now, true);
let context = app
.workspace_context
@@ -2676,7 +2676,7 @@ fn workspace_context_refresh_respects_ttl_before_requerying_git() {
app.workspace = repo.path().to_path_buf();
let start = Instant::now();
refresh_workspace_context_if_needed(&mut app, start, true);
crate::tui::workspace_context::refresh_if_needed(&mut app, start, true);
let initial = app
.workspace_context
.clone()
@@ -2684,12 +2684,12 @@ fn workspace_context_refresh_respects_ttl_before_requerying_git() {
std::fs::write(repo.path().join("dirty.txt"), "dirty").expect("write dirty marker");
let before_ttl = start + Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS - 1);
refresh_workspace_context_if_needed(&mut app, before_ttl, true);
let before_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS - 1);
crate::tui::workspace_context::refresh_if_needed(&mut app, before_ttl, true);
assert_eq!(app.workspace_context.as_deref(), Some(initial.as_str()));
let after_ttl = start + Duration::from_secs(WORKSPACE_CONTEXT_REFRESH_SECS);
refresh_workspace_context_if_needed(&mut app, after_ttl, true);
let after_ttl = start + Duration::from_secs(crate::tui::workspace_context::REFRESH_SECS);
crate::tui::workspace_context::refresh_if_needed(&mut app, after_ttl, true);
let refreshed = app
.workspace_context
.as_deref()
@@ -4043,8 +4043,8 @@ fn thinking_then_tools_share_active_cell_until_text_flushes() {
let mut app = create_test_app();
// 1. Thinking starts and streams a delta.
let thinking_idx = ensure_streaming_thinking_active_entry(&mut app);
append_streaming_thinking(&mut app, thinking_idx, "planning the read");
let thinking_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
crate::tui::streaming_thinking::append(&mut app, thinking_idx, "planning the read");
assert!(
app.history.is_empty(),
"thinking must not write into history mid-turn"
@@ -4085,7 +4085,7 @@ fn thinking_then_tools_share_active_cell_until_text_flushes() {
));
// 3. Thinking finalizes — entry stays in active cell, just stops streaming.
let finalized = finalize_streaming_thinking_active_entry(&mut app, Some(1.5), "");
let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.5), "");
assert!(finalized, "finalizer reports it touched the active cell");
let HistoryCell::Thinking {
streaming,
@@ -4134,8 +4134,8 @@ fn flush_active_cell_finalizes_unclosed_thinking_block() {
// assistant text arrives, `flush_active_cell` must still stop the
// spinner so the migrated history cell isn't perpetually streaming.
let mut app = create_test_app();
let _ = ensure_streaming_thinking_active_entry(&mut app);
append_streaming_thinking(&mut app, 0, "incomplete");
let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
crate::tui::streaming_thinking::append(&mut app, 0, "incomplete");
app.flush_active_cell();
@@ -4163,9 +4163,9 @@ fn open_thinking_pager_finds_thinking_in_active_cell() {
// transcript — not just `app.history` — or the promise is a lie.
// Regression guard for the v0.8.29 affordance/handler mismatch.
let mut app = create_test_app();
let _ = ensure_streaming_thinking_active_entry(&mut app);
append_streaming_thinking(&mut app, 0, "deliberating");
let finalized = finalize_streaming_thinking_active_entry(&mut app, Some(1.2), "");
let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
crate::tui::streaming_thinking::append(&mut app, 0, "deliberating");
let finalized = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(1.2), "");
assert!(finalized);
assert!(
app.history.is_empty(),
@@ -4316,7 +4316,7 @@ fn engine_error_finalizes_active_thinking_block() {
use crate::error_taxonomy::StreamError;
let mut app = create_test_app();
let entry_idx = ensure_streaming_thinking_active_entry(&mut app);
let entry_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
app.thinking_started_at = Some(Instant::now());
app.streaming_state.start_thinking(0, None);
app.streaming_state.push_content(0, "partial reasoning");
@@ -4356,7 +4356,7 @@ fn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {
// remainder of `MessageComplete` reads it.
let mut app = create_test_app();
let _ = ensure_streaming_thinking_active_entry(&mut app);
let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
app.thinking_started_at = Some(Instant::now());
app.streaming_state.start_thinking(0, None);
app.streaming_state.push_content(0, "deep reasoning text");
@@ -4375,8 +4375,8 @@ fn message_complete_drain_preserves_thinking_when_thinking_complete_lost() {
// Mirror the head of `EngineEvent::MessageComplete` — the new defensive
// drain installed by the #861 RC3 fix.
if app.streaming_thinking_active_entry.is_some() {
let _ = finalize_current_streaming_thinking(&mut app);
stash_reasoning_buffer_into_last_reasoning(&mut app);
let _ = crate::tui::streaming_thinking::finalize_current(&mut app);
crate::tui::streaming_thinking::stash_reasoning_buffer_into_last_reasoning(&mut app);
}
assert!(
@@ -4399,9 +4399,9 @@ fn second_thinking_block_appends_new_entry_in_same_active_cell() {
// the SAME active cell rather than flush the first group prematurely.
let mut app = create_test_app();
let _ = ensure_streaming_thinking_active_entry(&mut app);
append_streaming_thinking(&mut app, 0, "first plan");
let _ = finalize_streaming_thinking_active_entry(&mut app, Some(0.5), "");
let _ = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
crate::tui::streaming_thinking::append(&mut app, 0, "first plan");
let _ = crate::tui::streaming_thinking::finalize_active_entry(&mut app, Some(0.5), "");
handle_tool_call_started(
&mut app,
@@ -4411,12 +4411,12 @@ fn second_thinking_block_appends_new_entry_in_same_active_cell() {
);
// Second Thinking block.
let second_idx = ensure_streaming_thinking_active_entry(&mut app);
let second_idx = crate::tui::streaming_thinking::ensure_active_entry(&mut app);
assert_eq!(
second_idx, 2,
"second thinking entry follows the tool entry"
);
append_streaming_thinking(&mut app, second_idx, "second plan");
crate::tui::streaming_thinking::append(&mut app, second_idx, "second plan");
let active = app.active_cell.as_ref().expect("active cell present");
assert_eq!(active.entry_count(), 3);
@@ -4436,14 +4436,14 @@ fn second_thinking_block_appends_new_entry_in_same_active_cell() {
fn new_thinking_block_drains_pending_tail_from_previous_block() {
let mut app = create_test_app();
assert!(!start_streaming_thinking_block(&mut app));
assert!(!crate::tui::streaming_thinking::start_block(&mut app));
let first_idx = app
.streaming_thinking_active_entry
.expect("first thinking entry active");
app.reasoning_buffer.push_str("first tail");
app.streaming_state.push_content(0, "first tail");
assert!(start_streaming_thinking_block(&mut app));
assert!(crate::tui::streaming_thinking::start_block(&mut app));
let second_idx = app
.streaming_thinking_active_entry
.expect("second thinking entry active");
+56
View File
@@ -0,0 +1,56 @@
//! Composer vim Normal-mode keybindings.
use crate::tui::app::{App, VimMode};
/// Handle a plain character key press when the composer is in vim Normal mode.
///
/// Implements the core set of normal-mode bindings:
/// - `h` / `l` — left / right by character
/// - `j` / `k` — down / up by logical line (falls back to prev/next history)
/// - `w` / `b` — word forward / backward
/// - `0` / `$` — line start / end
/// - `x` — delete character under cursor
/// - `d` (×2) — delete current line (`dd`)
/// - `i` — enter Insert before cursor
/// - `a` — enter Insert after cursor
/// - `o` — open new line below and enter Insert
/// - `v` — enter Visual mode
/// - `G` — move to end of buffer
pub(super) fn handle_vim_normal_key(app: &mut App, c: char) {
// Handle pending `d` (waiting for second `d` to complete `dd`).
if app.composer.vim_pending_d {
app.composer.vim_pending_d = false;
if c == 'd' {
app.vim_delete_line();
}
// Any other key cancels the pending operator.
return;
}
match c {
'h' => app.move_cursor_left(),
'l' => app.move_cursor_right(),
'j' => app.vim_move_down(),
'k' => app.vim_move_up(),
'w' => app.vim_move_word_forward(),
'b' => app.vim_move_word_backward(),
'0' => app.vim_move_line_start(),
'$' => app.vim_move_line_end(),
'x' => app.vim_delete_char_under_cursor(),
'd' => {
// Start the `dd` operator sequence.
app.composer.vim_pending_d = true;
}
'i' => app.vim_enter_insert(),
'a' => app.vim_enter_append(),
'o' => app.vim_open_line_below(),
'v' => {
app.composer.vim_mode = VimMode::Visual;
app.needs_redraw = true;
}
'G' => app.move_cursor_end(),
_ => {
// Unknown normal-mode key — silently ignored in Normal mode.
}
}
}
+174
View File
@@ -0,0 +1,174 @@
//! Per-workspace git context shown in the composer header.
//!
//! The TUI shows a "branch | clean/N modified/…" badge sourced from
//! `git status` and `git rev-parse`. To avoid spawning git on every
//! render, the result is cached and only refreshed every
//! `REFRESH_SECS` seconds. The refresh prefers spawn-blocking on the
//! current Tokio runtime; tests and non-async callers fall through to
//! a synchronous call.
use std::path::Path;
use std::process::Command;
use std::time::{Duration, Instant};
use crate::tui::app::App;
/// How often (seconds) the workspace context badge is allowed to
/// re-query git. Exposed for tests that exercise the TTL.
pub(crate) const REFRESH_SECS: u64 = 15;
/// Pull a fresh workspace context from disk if the cached value is
/// older than [`REFRESH_SECS`] and `allow_refresh` is true. Always
/// drains any pending async result into `app.workspace_context` first
/// so the render pass sees the latest value (#399 S1).
pub(super) fn refresh_if_needed(app: &mut App, now: Instant, allow_refresh: bool) {
// Drain the async cell result into the live field first, so the render
// path always reads the latest value (#399 S1).
if let Ok(mut cell) = app.workspace_context_cell.lock()
&& let Some(ctx) = cell.take()
{
app.workspace_context = Some(ctx);
}
if app
.workspace_context_refreshed_at
.is_some_and(|refreshed_at| now.duration_since(refreshed_at) < Duration::from_secs(REFRESH_SECS))
{
return;
}
if !allow_refresh {
return;
}
// Offload git query to a background thread when a Tokio runtime is
// available. Fall back to synchronous execution for tests and other
// non-async contexts (#399 S1).
if let Ok(handle) = tokio::runtime::Handle::try_current() {
let ctx = app.workspace_context_cell.clone();
let workspace = app.workspace.clone();
handle.spawn_blocking(move || {
let result = collect(&workspace);
if let Ok(mut guard) = ctx.lock() {
*guard = result;
}
});
} else {
// No runtime — run synchronously so tests and one-shot callers
// still get a result immediately.
app.workspace_context = collect(&app.workspace);
}
app.workspace_context_refreshed_at = Some(now);
}
#[derive(Debug, Default, Clone, Copy)]
struct ChangeSummary {
staged: usize,
modified: usize,
untracked: usize,
conflicts: usize,
}
impl ChangeSummary {
fn is_clean(&self) -> bool {
self.staged == 0 && self.modified == 0 && self.untracked == 0 && self.conflicts == 0
}
}
/// Build the human-readable workspace context string ("branch | status")
/// from `git rev-parse` + `git status`. Returns `None` if the workspace
/// is not a git repository or git itself is unavailable.
fn collect(workspace: &Path) -> Option<String> {
let branch = branch(workspace)?;
let summary = change_summary(workspace)?;
let mut parts = Vec::new();
if summary.staged > 0 {
parts.push(format!("{} staged", summary.staged));
}
if summary.modified > 0 {
parts.push(format!("{} modified", summary.modified));
}
if summary.untracked > 0 {
parts.push(format!("{} untracked", summary.untracked));
}
if summary.conflicts > 0 {
parts.push(format!("{} conflicts", summary.conflicts));
}
let status = if summary.is_clean() {
"clean".to_string()
} else {
parts.join(", ")
};
Some(format!("{branch} | {status}"))
}
pub(super) fn branch(workspace: &Path) -> Option<String> {
let branch = run_git(workspace, &["rev-parse", "--abbrev-ref", "HEAD"]).ok()?;
let branch = branch.trim().to_string();
if branch == "HEAD" || branch.is_empty() {
let short_hash = run_git(workspace, &["rev-parse", "--short", "HEAD"]).ok()?;
let short_hash = short_hash.trim();
if short_hash.is_empty() {
return None;
}
return Some(format!("detached:{short_hash}"));
}
Some(branch)
}
fn change_summary(workspace: &Path) -> Option<ChangeSummary> {
let status = run_git(
workspace,
&["status", "--short", "--untracked-files=normal"],
)
.ok()?;
if status.trim().is_empty() {
return Some(ChangeSummary::default());
}
let mut summary = ChangeSummary::default();
for line in status.lines() {
if line.trim().is_empty() {
continue;
}
let mut chars = line.chars();
let staged = chars.next()?;
let modified = chars.next().unwrap_or(' ');
if staged == ' ' && modified == ' ' {
continue;
}
if staged == '?' && modified == '?' {
summary.untracked = summary.untracked.saturating_add(1);
continue;
}
if staged == 'U' || modified == 'U' {
summary.conflicts = summary.conflicts.saturating_add(1);
}
if staged != ' ' && staged != '?' {
summary.staged = summary.staged.saturating_add(1);
}
if modified != ' ' && modified != '?' {
summary.modified = summary.modified.saturating_add(1);
}
}
Some(summary)
}
fn run_git(workspace: &Path, args: &[&str]) -> std::io::Result<String> {
let output = Command::new("git")
.args(args)
.current_dir(workspace)
.output()?;
if !output.status.success() {
return Err(std::io::Error::other("git command failed"));
}
Ok(String::from_utf8_lossy(&output.stdout).to_string())
}