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:
@@ -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;
|
||||
|
||||
@@ -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
@@ -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(
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
Reference in New Issue
Block a user