Merge audit lane U (TUI interface, 48 findings) into the 0.10.1 integration

Signed-off-by: CodeWhale Bot <[email protected]>
This commit is contained in:
CodeWhale Bot committed 2026-09-30 13:19:32 -07:00
commit fbb4572fbc
35 files changed
+757 -176

No files matched your search

+14 -3
View File
@@ -334,9 +334,10 @@ fn clear_stash_at(path: &Path) -> io::Result<usize> {
if !path.exists() {
return Ok(0);
}
let entries = load_stash_from(path);
let count = entries.len();
if count == 0 {
let count = load_stash_from(path).len();
// Clear means the file is empty afterwards, not that no *valid* draft was
// found: malformed-only lines are still stashed text on disk (U01-m3).
if count == 0 && fs::metadata(path)?.len() == 0 {
return Ok(0);
}
crate::utils::write_atomic(path, b"")?;
@@ -468,6 +469,16 @@ this is not json
assert_eq!(clear_stash_at(&path).unwrap(), 0);
}
/// U01-m3: a file holding only unparseable lines still holds stashed
/// text; `/stash clear` must leave it empty, not report success over it.
#[test]
fn clear_empties_a_file_of_malformed_lines() {
let (_tmp, path) = temp_stash_path();
std::fs::write(&path, "not json\n{\"ts\":1}\n").unwrap();
assert_eq!(clear_stash_at(&path).unwrap(), 0);
assert_eq!(std::fs::read(&path).unwrap(), b"");
}
#[test]
fn clear_drops_entries_and_reports_count() {
let (_tmp, path) = temp_stash_path();
+12
View File
@@ -165,6 +165,18 @@ pub fn decide(kind: NotificationKind, now_ms: u64, bell_transport: bool) -> Soun
.unwrap_or(SoundDecision::Suppress(SuppressReason::Disabled))
}
/// Give back a repeat slot that `decide` reserved for a cue that did not play.
/// Only the exact reservation is released, so a later decision in the same
/// category keeps its own slot.
pub fn release(kind: NotificationKind, reserved_ms: u64) {
if let Ok(mut policy) = policy_cell().write() {
let slot = &mut policy.last_played_ms[event_for_kind(kind).index()];
if *slot == Some(reserved_ms) {
*slot = None;
}
}
}
/// Decide from this request's configuration while holding the shared history
/// lock. Request snapshots never replace the installed TUI/model policy.
pub fn decide_configured(
+6
View File
@@ -2275,6 +2275,12 @@ impl RemoteControlController {
gate
}
pub fn pending_approval_tool_id(&self, gate: &str) -> Option<String> {
self.pending_approvals
.get(gate)
.map(|approval| approval.tool_id.clone())
}
pub fn take_pending_approval(&mut self, gate: &str) -> Option<String> {
self.pending_approvals
.remove(gate)
+10
View File
@@ -2378,6 +2378,10 @@ pub struct App {
/// DeepSeek account balance, refreshed once per turn completion.
/// Shared cell updated by background fetch tasks; read lock in the UI thread.
pub balance_cell: std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>>,
/// The route `balance_cell` belongs to (provider, endpoint, key
/// fingerprint). A route change swaps in a fresh cell; see
/// `provider_routes::balance_cell_for_route`.
pub balance_route: Option<String>,
/// Shared cell for async fleet-profile model-draft delivery. A background
/// task fills it (model label + drafted profile or a failure reason) so
/// the drafting network call never parks the event loop (#3757 review).
@@ -3872,7 +3876,13 @@ impl App {
/// contaminate the replacement session after clear/load/new.
#[must_use]
pub fn session_transition_blocked(&self) -> bool {
// A dispatch still resolving its route, and a locally cancelled turn
// whose terminal event has not landed, both belong to this session:
// switching now would hand the next session a stale suppression that
// cancels its first turn, or a dispatch bound to the old one (U02-10).
self.is_loading
|| self.dispatch_in_flight
|| self.suppress_stream_events_until_turn_complete
|| self.runtime_turn_status.as_deref() == Some("in_progress")
|| self.is_compacting
|| self.manual_compaction_queued
+23 -9
View File
@@ -81,6 +81,13 @@ pub(crate) fn prev_grapheme_boundary(text: &str, char_index: usize) -> usize {
acc
}
/// Char index of the grapheme-cluster boundary at or before `char_index`:
/// the start of the cluster that contains it. Vim column moves land here so a
/// line change never parks the cursor inside a ZWJ or combining sequence.
pub(crate) fn floor_grapheme_boundary(text: &str, char_index: usize) -> usize {
prev_grapheme_boundary(text, char_index.saturating_add(1)).min(char_index)
}
/// Char index of the first grapheme-cluster boundary strictly after
/// `char_index` — i.e. where the cursor lands after one "right" step.
/// Returns the total char count when already at or past the end.
@@ -1404,10 +1411,10 @@ impl App {
let end = next_grapheme_boundary(&self.input, pos);
remove_char_range(&mut self.input, pos, end);
self.resync_command_line_claim();
// Keep cursor in bounds after deletion.
// Keep cursor in bounds after deletion, on the last cluster's start.
let new_total = char_count(&self.input);
if self.cursor_position > 0 && self.cursor_position >= new_total {
self.cursor_position = new_total.saturating_sub(1);
self.cursor_position = prev_grapheme_boundary(&self.input, new_total);
}
self.needs_redraw = true;
}
@@ -1450,7 +1457,8 @@ impl App {
pub fn vim_enter_append(&mut self) {
let total = char_count(&self.input);
if self.cursor_position < total {
self.cursor_position += 1;
// After the whole cluster under the cursor, not one scalar in.
self.cursor_position = next_grapheme_boundary(&self.input, self.cursor_position);
}
self.vim_mode = VimMode::Insert;
self.needs_redraw = true;
@@ -1468,10 +1476,11 @@ impl App {
pub fn vim_enter_normal(&mut self) {
self.vim_mode = VimMode::Normal;
self.vim_pending_d = false;
// In Normal mode the cursor sits on a character, not after the last one.
// In Normal mode the cursor sits on a character, not after the last
// one: on the last grapheme cluster's start (U01-m4).
let total = char_count(&self.input);
if self.cursor_position > 0 && self.cursor_position >= total {
self.cursor_position = total.saturating_sub(1);
self.cursor_position = prev_grapheme_boundary(&self.input, total);
}
self.needs_redraw = true;
}
@@ -1497,7 +1506,8 @@ impl App {
let next_line_char_len =
char_count(&text[next_line_start..next_line_start + next_line_len]);
let target_col = col.min(next_line_char_len);
self.cursor_position = char_count(&text[..next_line_start]) + target_col;
self.cursor_position =
floor_grapheme_boundary(&text, char_count(&text[..next_line_start]) + target_col);
self.needs_redraw = true;
} else {
self.history_down();
@@ -1518,7 +1528,8 @@ impl App {
let prev_start = text[..prev_line_end].rfind('\n').map_or(0, |i| i + 1);
let prev_line_len = char_count(&text[prev_start..prev_line_end]);
let target_col = col.min(prev_line_len);
self.cursor_position = char_count(&text[..prev_start]) + target_col;
self.cursor_position =
floor_grapheme_boundary(&text, char_count(&text[..prev_start]) + target_col);
self.needs_redraw = true;
} else {
self.history_up();
@@ -1747,8 +1758,11 @@ impl App {
self.input_history.drain(0..excess);
}
// Mirror prompts and commands to the persisted cross-session history
// so arrow-up recall works across restarts (#366, #6006).
crate::composer_history::append_history(&input);
// so arrow-up recall works across restarts (#366, #6006). A history
// limit of zero saves nothing (U01-m2).
if self.max_input_history > 0 {
crate::composer_history::append_history(&input);
}
self.history_index = None;
self.history_navigation_draft = None;
self.clear_input();
+5 -1
View File
@@ -660,7 +660,10 @@ impl App {
plugin_registry.as_ref(),
);
let input_history = crate::composer_history::load_history();
// The recall cap applies from the first keystroke, not only after the
// first submit: the persisted file keeps its own larger cap (U01-m2).
let mut input_history = crate::composer_history::load_history();
input_history.drain(..input_history.len().saturating_sub(max_input_history));
let mention_cwd = std::env::current_dir().ok();
let start_remote_control = matches!(initial_input, Some(InitialInput::RemoteControl));
let (initial_input_text, initial_input_cursor, auto_submit_initial_input) =
@@ -1116,6 +1119,7 @@ impl App {
cumulative_turn_duration: std::time::Duration::ZERO,
session_metrics: crate::tui::session_metrics::SessionMetrics::default(),
balance_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
balance_route: None,
draft_gen: std::sync::Arc::new(std::sync::atomic::AtomicU64::new(0)),
fleet_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
constitution_draft_cell: std::sync::Arc::new(std::sync::Mutex::new(None)),
+31
View File
@@ -6066,6 +6066,37 @@ fn cursor_moves_by_grapheme_over_emoji_and_cjk() {
assert_eq!(app.cursor_position, 0); // clamped at start
}
/// U01-m4: vim Normal-mode clamps, `a`, and `j`/`k` column moves land on
/// grapheme-cluster starts, never inside a combining or skin-tone sequence.
#[test]
fn vim_cursor_moves_never_split_a_grapheme_cluster() {
let mut app = App::new(test_options(false), &Config::default());
app.vim_enabled = true;
// "e" + COMBINING ACUTE: two scalars, one cluster.
app.input = "e\u{301}".to_string();
app.cursor_position = char_count(&app.input);
app.vim_enter_normal();
assert_eq!(
app.cursor_position, 0,
"Normal mode sits on the cluster start"
);
app.vim_enter_append();
assert_eq!(
app.cursor_position, 2,
"`a` appends after the whole cluster"
);
// Scalar column 1 falls inside the other line's skin-tone emoji.
app.input = "ab\n\u{1f44d}\u{1f3fd}c".to_string();
app.cursor_position = 1; // on `b`
app.vim_move_down();
assert_eq!(app.cursor_position, 3, "`j` lands on the emoji's start");
app.input = "\u{1f44d}\u{1f3fd}c\nab".to_string();
app.cursor_position = 5; // on `b`
app.vim_move_up();
assert_eq!(app.cursor_position, 0, "`k` lands on the emoji's start");
}
#[test]
fn backspace_removes_whole_emoji_cluster() {
let mut app = App::new(test_options(false), &Config::default());
+30 -29
View File
@@ -15,14 +15,16 @@ pub(super) fn should_resolve_auto_model_selection(app: &App) -> bool {
/// Build a compact recent-context summary for the auto-route prompt.
///
/// Walks `api_messages` from the most recent turn back, skipping the
/// final draft (which is what the router is being asked to classify),
/// collects up to six non-empty rows, and reverses them so the prompt
/// reads oldest-first. Each row is `<role>: <truncated content>` and
/// is capped at 900 characters.
/// Walks `api_messages` from the most recent turn back, collects up to six
/// non-empty rows, and reverses them so the prompt reads oldest-first. Each
/// row is `<role>: <truncated content>` and is capped at 900 characters.
///
/// Both callers build this context before the draft being classified is
/// appended to `api_messages`, so every message here is prior context; the
/// old `.skip(1)` dropped the newest of them, not the draft (U08-m3).
pub(super) fn recent_auto_router_context(messages: &[Message]) -> String {
let mut rows = Vec::new();
for message in messages.iter().rev().skip(1) {
for message in messages.iter().rev() {
if rows.len() >= 6 {
break;
}
@@ -107,9 +109,9 @@ mod tests {
}
#[test]
fn recent_auto_router_context_skips_final_message_and_caps_rows() {
// Eight messages; final one (the draft being routed) is skipped,
// so we expect at most six of the remaining seven.
fn recent_auto_router_context_keeps_newest_prior_message_and_caps_rows() {
// Eight prior messages (the draft is not in `api_messages` yet): the
// newest one is context, and only the six most recent are kept.
let msgs: Vec<Message> = (0..8)
.map(|i| {
make_msg(
@@ -119,12 +121,15 @@ mod tests {
})
.collect();
let context = recent_auto_router_context(&msgs);
assert!(!context.contains("turn 7"), "final draft must be skipped");
assert!(
context.contains("turn 7"),
"the newest prior message is context"
);
let row_count = context.lines().count();
assert_eq!(row_count, 6);
// Output is oldest-first.
let first = context.lines().next().unwrap();
assert!(first.contains("turn 1"), "got: {context}");
assert!(first.contains("turn 2"), "got: {context}");
}
#[test]
@@ -134,28 +139,24 @@ mod tests {
#[test]
fn recent_auto_router_context_excludes_hidden_thinking() {
let msgs = vec![
Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
signature: None,
state: None,
thinking: "The user seems to be asking me to classify myself.".to_string(),
},
ContentBlock::Text {
text: "Visible assistant answer.".to_string(),
cache_control: None,
},
],
},
make_msg("user", "latest draft"),
];
let msgs = vec![Message {
role: Role::Assistant,
content: vec![
ContentBlock::Thinking {
signature: None,
state: None,
thinking: "The user seems to be asking me to classify myself.".to_string(),
},
ContentBlock::Text {
text: "Visible assistant answer.".to_string(),
cache_control: None,
},
],
}];
let context = recent_auto_router_context(&msgs);
assert!(context.contains("Visible assistant answer."));
assert!(!context.contains("The user seems"));
assert!(!context.contains("latest draft"));
}
}
+6 -2
View File
@@ -981,9 +981,13 @@ impl ModalView for ContextInspectorView {
let value = tr(self.locale, MessageId::CtxInspRowTokens)
.replace("{tokens}", &row.tokens.to_string())
.replace("{percent}", &format!("{:.1}", row.percent));
let label_width = width.saturating_sub(value.len() + 5);
// Display cells, not bytes: a CJK or localized row measured in
// bytes padded short and pushed the value off the row (U04-m5).
let value_width = crate::tui::ui_text::text_display_width(&value);
let label_width = width.saturating_sub(value_width + 5);
let label = crate::tui::ui_text::semantic_truncate(&row.label, label_width);
let gap = width.saturating_sub(label.len() + value.len() + 3);
let gap = width
.saturating_sub(crate::tui::ui_text::text_display_width(&label) + value_width + 3);
let y = content
.y
.saturating_add(u16::try_from(lines.len()).unwrap_or(u16::MAX));
+130 -20
View File
@@ -1374,20 +1374,24 @@ fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str)
}
};
let mut names = entries
.filter_map(|entry| entry.ok())
.map(|entry| {
let marker = entry
.file_type()
.ok()
.filter(|ty| ty.is_dir())
.map_or("", |_| "/");
format!("{}{}", entry.file_name().to_string_lossy(), marker)
})
.collect::<Vec<_>>();
names.sort();
let total = names.len();
names.truncate(MAX_DIRECTORY_MENTION_ENTRIES);
// Keep only the first MAX entries in sort order while counting the rest:
// a max-heap of that size bounds memory to the output instead of
// materializing and sorting the whole directory (U07-03).
let mut total = 0usize;
let mut kept = std::collections::BinaryHeap::with_capacity(MAX_DIRECTORY_MENTION_ENTRIES + 1);
for entry in entries.filter_map(|entry| entry.ok()) {
total += 1;
let marker = entry
.file_type()
.ok()
.filter(|ty| ty.is_dir())
.map_or("", |_| "/");
kept.push(format!("{}{}", entry.file_name().to_string_lossy(), marker));
if kept.len() > MAX_DIRECTORY_MENTION_ENTRIES {
kept.pop();
}
}
let names = kept.into_sorted_vec();
let mut body = names.join("\n");
if total > MAX_DIRECTORY_MENTION_ENTRIES {
let omitted = total - MAX_DIRECTORY_MENTION_ENTRIES;
@@ -1398,13 +1402,15 @@ fn render_directory_mention_context(raw: &str, path: &Path, display_path: &str)
/// Bounded read of a mention's file content, optionally sliced to a line
/// range. Returns `(text, truncated, beyond_eof)`: `truncated` mirrors the
/// full-file byte bound; `beyond_eof` is set only when the requested range
/// starts past the end of the file.
/// full-file byte bound, except for a range that starts past that bound,
/// where it says whether the streamed range itself was cut; `beyond_eof` is
/// set only when the requested range starts past the end of the file.
fn read_file_content(
path: &Path,
range: Option<FileRange>,
) -> std::io::Result<(String, bool, bool)> {
let (text, truncated) = read_text_prefix(path)?;
let mut file = std::fs::File::open(path)?;
let (text, truncated) = read_text_prefix(&mut file)?;
let Some(FileRange { start, end }) = range else {
return Ok((text, truncated, false));
};
@@ -1414,14 +1420,78 @@ fn read_file_content(
}
let start_idx = usize::try_from(start.saturating_sub(1)).unwrap_or(usize::MAX);
if start_idx >= lines.len() {
if truncated {
// The prefix ended, not the file: read the range from the file
// itself instead of calling it past EOF (U07-04).
std::io::Seek::rewind(&mut file)?;
return read_line_range_past_prefix(file, u64::from(start), u64::from(end));
}
return Ok((String::new(), truncated, true));
}
let end_idx = usize::try_from(end).unwrap_or(usize::MAX).min(lines.len());
Ok((lines[start_idx..end_idx].join("\n"), truncated, false))
}
fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
let mut file = std::fs::File::open(path)?;
/// Stream a 1-based inclusive line range that starts beyond the bounded
/// prefix. Skipped lines are scanned, never stored; the range text itself is
/// held to the same byte budget as a whole-file mention.
fn read_line_range_past_prefix(
file: impl Read,
start: u64,
end: u64,
) -> std::io::Result<(String, bool, bool)> {
use std::io::BufRead;
let mut reader = std::io::BufReader::new(file);
let mut line = 1u64;
while line < start {
let buf = reader.fill_buf()?;
if buf.is_empty() {
return Ok((String::new(), false, true));
}
let consumed = match buf.iter().position(|byte| *byte == b'\n') {
Some(newline) => {
line += 1;
newline + 1
}
None => buf.len(),
};
reader.consume(consumed);
}
if reader.fill_buf()?.is_empty() {
return Ok((String::new(), false, true));
}
let budget = usize::try_from(MAX_MENTION_FILE_BYTES).unwrap_or(usize::MAX);
let mut buffer = Vec::new();
let mut truncated = false;
while line <= end {
let buf = reader.fill_buf()?;
if buf.is_empty() {
break;
}
let (take, newline) = match buf.iter().position(|byte| *byte == b'\n') {
Some(newline) => (newline + 1, true),
None => (buf.len(), false),
};
let room = budget - buffer.len();
if take > room {
buffer.extend_from_slice(&buf[..room]);
truncated = true;
break;
}
buffer.extend_from_slice(&buf[..take]);
reader.consume(take);
if newline {
line += 1;
}
}
let mut text = decode_bounded_text(buffer, truncated)?;
if text.ends_with('\n') {
text.pop();
}
Ok((text, truncated, false))
}
fn read_text_prefix(file: &mut impl Read) -> std::io::Result<(String, bool)> {
let mut buffer = Vec::new();
file.by_ref()
.take(MAX_MENTION_FILE_BYTES + 1)
@@ -1429,6 +1499,13 @@ fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
let truncated = buffer.len() as u64 > MAX_MENTION_FILE_BYTES;
if truncated {
buffer.truncate(MAX_MENTION_FILE_BYTES as usize);
}
Ok((decode_bounded_text(buffer, truncated)?, truncated))
}
/// Decode a budget-bounded read as UTF-8 text, rejecting binary content.
fn decode_bounded_text(mut buffer: Vec<u8>, truncated: bool) -> std::io::Result<String> {
if truncated {
// Round down to the nearest valid UTF-8 character boundary so a
// multi-byte sequence (CJK, emoji, etc.) is never split at the cut point.
// Only adjust when error_len() is None — that means truncation landed
@@ -1450,7 +1527,7 @@ fn read_text_prefix(path: &Path) -> std::io::Result<(String, bool)> {
let text = std::str::from_utf8(&buffer)
.map_err(|_| std::io::Error::new(std::io::ErrorKind::InvalidData, "file is not UTF-8"))?
.to_string();
Ok((text, truncated))
Ok(text)
}
fn is_media_path(path: &Path) -> bool {
@@ -1964,6 +2041,39 @@ mod tests {
}
}
/// U07-04: a range that starts past the bounded prefix is read from the
/// file, not reported as past EOF; a range past the real end still is.
#[test]
fn ranged_file_mention_reads_past_the_bounded_prefix() {
let tmp = TempDir::new().expect("tempdir");
let path = tmp.path().join("long.txt");
let body: String = (1..=20_000).map(|n| format!("line {n}\n")).collect();
assert!(body.len() as u64 > MAX_MENTION_FILE_BYTES);
std::fs::write(&path, body).expect("write");
let (text, truncated, beyond_eof) = read_file_content(
&path,
Some(FileRange {
start: 19_998,
end: 20_000,
}),
)
.expect("range read");
assert!(!beyond_eof, "the file has these lines");
assert!(!truncated, "three short lines fit the budget");
assert_eq!(text, "line 19998\nline 19999\nline 20000");
let (_, _, beyond_eof) = read_file_content(
&path,
Some(FileRange {
start: 20_001,
end: 20_002,
}),
)
.expect("range read");
assert!(beyond_eof, "the file really ends at line 20000");
}
#[test]
fn ranged_file_mention_slices_lines_and_reports_beyond_eof() {
let tmp = TempDir::new().expect("tempdir");
+21 -12
View File
@@ -717,23 +717,29 @@ impl ModalView for FilePickerView {
}
}
/// Keep the path's tail within `max` display cells, cutting only between
/// grapheme clusters: counting chars overflowed CJK rows and could split a
/// ZWJ or combining sequence (U07-06). ASCII output is unchanged.
fn truncate_path(path: &str, max: usize) -> String {
use unicode_segmentation::UnicodeSegmentation;
if max == 0 {
return String::new();
}
if path.chars().count() <= max {
if crate::tui::ui_text::text_display_width(path) <= max {
return path.to_string();
}
let take = max.saturating_sub(1);
let truncated: String = path
.chars()
.rev()
.take(take)
.collect::<Vec<_>>()
.into_iter()
.rev()
.collect();
format!("…{truncated}")
let budget = max.saturating_sub(1);
let mut used = 0usize;
let mut start = path.len();
for (idx, grapheme) in path.grapheme_indices(true).rev() {
let width = crate::tui::ui_text::text_display_width(grapheme);
if used + width > budget {
break;
}
used += width;
start = idx;
}
format!("…{}", &path[start..])
}
/// Single-pass walk that collects workspace-relative paths. `max_depth` of
@@ -935,7 +941,10 @@ fn push_matching_files(
}
let path = entry.path();
let rel = path.strip_prefix(display_root).unwrap_or(path);
if rel.as_os_str().is_empty() {
// A pick becomes @-mention text that must resolve back to this file.
// A non-UTF-8 name cannot round-trip through text, and its lossy form
// can collide with another file's name, so it is not offered (U07-06).
if rel.as_os_str().is_empty() || rel.to_str().is_none() {
continue;
}
let display = path_to_workspace_string(rel);
+14 -3
View File
@@ -113,8 +113,11 @@ impl ChangeCounts {
self.untracked = self.untracked.saturating_add(1);
return;
}
// An unmerged path is a conflict and nothing else: its two letters
// name the merge sides, not a staged and a worktree change (U08-m2).
if unmerged {
self.conflicts = self.conflicts.saturating_add(1);
return;
}
if x != ' ' && x != '?' {
self.staged = self.staged.saturating_add(1);
@@ -849,8 +852,10 @@ locked
assert_eq!(
status.changes,
ChangeCounts {
staged: 3,
modified: 2,
// The UU record is a conflict only, not also staged and
// modified (U08-m2).
staged: 2,
modified: 1,
untracked: 1,
conflicts: 1,
}
@@ -872,7 +877,7 @@ locked
);
assert_eq!(
status_line(&status).as_deref(),
Some("main | 3 staged, 2 modified, 1 untracked, 1 conflicts")
Some("main | 2 staged, 1 modified, 1 untracked, 1 conflicts")
);
}
@@ -884,9 +889,15 @@ locked
);
let status = parse_porcelain_v2(&raw).unwrap();
assert_eq!(status.changes.conflicts, 1, "{code}");
assert_eq!(
(status.changes.staged, status.changes.modified),
(0, 0),
"{code} is not also staged or modified"
);
assert!(status_line(&status).unwrap().contains("1 conflicts"));
let (legacy, _, _) = parse_porcelain_v1(&format!("{code} conflict.rs\n"));
assert_eq!(legacy.conflicts, 1, "legacy {code}");
assert_eq!((legacy.staged, legacy.modified), (0, 0), "legacy {code}");
}
}
+14 -3
View File
@@ -2566,13 +2566,24 @@ fn details_affordance_line(text: &str, style: Style) -> Line<'static> {
])
}
/// Truncate to `max_len` display cells, cutting only at grapheme-cluster
/// boundaries. Counting chars let CJK text overflow a width budget and could
/// split a ZWJ or combining sequence (U04-m5); ASCII output is unchanged.
fn truncate_text(text: &str, max_len: usize) -> String {
if text.chars().count() <= max_len {
use unicode_segmentation::UnicodeSegmentation;
if crate::tui::ui_text::text_display_width(text) <= max_len {
return text.to_string();
}
let budget = max_len.saturating_sub(3);
let mut out = String::new();
for ch in text.chars().take(max_len.saturating_sub(3)) {
out.push(ch);
let mut used = 0usize;
for grapheme in text.graphemes(true) {
let width = crate::tui::ui_text::text_display_width(grapheme);
if used + width > budget {
break;
}
used += width;
out.push_str(grapheme);
}
out.push_str("...");
out
+56 -19
View File
@@ -990,31 +990,38 @@ fn render_latex_to_string(latex: &str) -> String {
render_latex_to_string(&bot_s)
));
pos = after_bot;
} else {
// Malformed: keep the command literal and advance,
// or the loop would re-read it forever.
out.push_str("\\binom");
pos = total_cmd_end;
}
}
// --- Square root ---
"sqrt" => {
let after = &input[total_cmd_end..];
// Optional [n] root index
let (root_text, after_root) =
if let Some(after_lb) = after.strip_prefix('[') {
let end_bracket = after_lb.find(']').map(|i| i + 1);
if let Some(e) = end_bracket {
(Some(&after_lb[..e]), total_cmd_end + e + 1)
} else {
(None, total_cmd_end)
}
} else {
(None, total_cmd_end)
};
if let Some((arg, _new_pos)) = read_braced_at(input, after_root) {
let r = render_latex_to_string(&arg);
if let Some(_root) = root_text {
out.push_str(&format!("\u{221a}({r})"));
} else {
out.push_str(&format!("\u{221a}({r})"));
// Optional [n] root index, without its brackets.
let (root_text, after_root) = match after
.strip_prefix('[')
.and_then(|after_lb| after_lb.find(']').map(|end| (after_lb, end)))
{
Some((after_lb, end)) => {
(Some(&after_lb[..end]), total_cmd_end + end + 2)
}
out.push('\u{221a}');
None => (None, total_cmd_end),
};
if let Some(root) = root_text {
append_superscript(&render_latex_to_string(root), &mut out);
}
out.push('\u{221a}');
// One radical, and resume after the whole argument:
// the old path pushed a second radical and re-read the
// argument, and a radicand without braces never
// advanced at all (U04-m2).
if let Some((arg, after_arg)) = read_braced_at(input, after_root) {
out.push_str(&format!("({})", render_latex_to_string(&arg)));
pos = after_arg;
} else {
pos = after_root;
}
}
@@ -1659,6 +1666,36 @@ mod tests {
fn test_superscript() {
assert_eq!(render_latex_to_string("x^2"), "x\u{00b2}");
}
/// U04-m2: one radical, the index as a superscript, and rendering
/// resumes after the argument instead of printing it twice.
#[test]
fn sqrt_renders_one_radical_and_consumes_its_argument() {
assert_eq!(render_latex_to_string(r"\sqrt{x}+1"), "\u{221a}(x)+1");
assert_eq!(
render_latex_to_string(r"\sqrt[3]{x}"),
"\u{00b3}\u{221a}(x)"
);
}
/// A radicand or binomial without braces must still finish rendering;
/// the old branches never advanced and spun the render loop forever.
#[test]
fn malformed_sqrt_and_binom_terminate() {
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let _ = tx.send((
render_latex_to_string(r"\sqrt 2"),
render_latex_to_string(r"\binom{n} k"),
));
});
let (sqrt, binom) = rx
.recv_timeout(std::time::Duration::from_secs(5))
.expect("latex rendering must terminate");
assert!(sqrt.starts_with('\u{221a}'), "{sqrt}");
assert!(sqrt.ends_with('2'), "{sqrt}");
assert!(binom.contains("binom"), "{binom}");
}
#[test]
fn test_subscript() {
assert_eq!(render_latex_to_string("x_1"), "x\u{2081}");
+34 -3
View File
@@ -7,7 +7,9 @@
//! rather than as a question ("that's even more confusing tbh"). This is the
//! question, as a popup, with the session it is about named in it.
use crossterm::event::{KeyCode, KeyEvent, MouseButton, MouseEvent, MouseEventKind};
use crossterm::event::{
KeyCode, KeyEvent, KeyEventKind, KeyModifiers, MouseButton, MouseEvent, MouseEventKind,
};
use ratatui::layout::Rect;
use ratatui::prelude::*;
use ratatui::widgets::{Block, Borders, Padding, Paragraph, Widget, Wrap};
@@ -58,8 +60,18 @@ impl ModalView for LaunchResumeConfirmView {
}
fn handle_key(&mut self, key: KeyEvent) -> ViewAction {
// Resuming replaces the whole session, so only a plain press answers:
// a release/repeat, or a Ctrl/Alt/Super chord that happens to end in
// Enter or `y`, is not a confirmation (U01-05). Shift stays allowed
// for `Y`.
if key.kind != KeyEventKind::Press {
return ViewAction::None;
}
let chord = key
.modifiers
.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER);
match key.code {
KeyCode::Enter => {
KeyCode::Enter if !chord => {
if self.selected == 0 {
self.confirm()
} else {
@@ -73,7 +85,7 @@ impl ModalView for LaunchResumeConfirmView {
// `y` is the habit every terminal confirmation teaches; Esc and
// `n` both back out. Nothing else acts, so a stray keystroke
// cannot resume a session by accident.
KeyCode::Char('y') | KeyCode::Char('Y') => self.confirm(),
KeyCode::Char('y') | KeyCode::Char('Y') if !chord => self.confirm(),
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => ViewAction::Close,
_ => ViewAction::None,
}
@@ -260,6 +272,25 @@ mod tests {
confirm.handle_key(KeyEvent::new(KeyCode::Char('k'), KeyModifiers::NONE)),
ViewAction::None
));
// U01-05: a chord that ends in Enter or `y`, and a key release, are
// not a confirmation.
for key in [
KeyEvent::new(KeyCode::Enter, KeyModifiers::CONTROL),
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::ALT),
KeyEvent::new(KeyCode::Char('y'), KeyModifiers::SUPER),
KeyEvent::new_with_kind(
KeyCode::Enter,
KeyModifiers::NONE,
crossterm::event::KeyEventKind::Release,
),
] {
let mut confirm = view();
assert!(
matches!(confirm.handle_key(key), ViewAction::None),
"{key:?} must not resume"
);
}
}
#[test]
+4
View File
@@ -208,6 +208,10 @@ impl LiveTranscriptOverlay {
let options = app.transcript_render_options();
if self.options != options {
self.options = options;
// Per-cell wraps are keyed by (cell, width, revision) only, so a
// theme, locale or thinking toggle would otherwise reuse lines
// rendered under the old options (U04-m1).
self.cache.get_mut().clear();
changed = true;
}
if changed {
+4 -2
View File
@@ -3999,8 +3999,10 @@ impl ModalView for ModelPickerView {
// Shift+D makes the visible provider/model pair the startup
// default. Plain Enter deliberately stays session-local, so a
// one-off route comparison cannot silently change the next launch.
// Exactly Shift: this chord persists the next launch's route, so
// Ctrl/Alt/Super+Shift+D must not save it by accident (U07-12).
KeyCode::Char(ch)
if key.modifiers.contains(KeyModifiers::SHIFT)
if key.modifiers == KeyModifiers::SHIFT
&& self.query.is_empty()
&& ch.eq_ignore_ascii_case(&'d')
&& self.selected_model_is_selectable() =>
@@ -4011,7 +4013,7 @@ impl ModalView for ModelPickerView {
// ⇧D is query text: it used to open provider auth for a row that
// was not locked at all (#6500).
KeyCode::Char(ch)
if key.modifiers.contains(KeyModifiers::SHIFT)
if key.modifiers == KeyModifiers::SHIFT
&& self.query.is_empty()
&& ch.eq_ignore_ascii_case(&'d') =>
{
+25 -6
View File
@@ -369,6 +369,7 @@ pub fn notify_done(
threshold: Duration,
elapsed: Duration,
) -> DeliveryOutcome {
let reserved = std::cell::Cell::new(None);
notify_with_sinks(
method,
in_tmux,
@@ -379,13 +380,31 @@ pub fn notify_done(
attention_delivery_allowed(),
&mut io::stdout(),
&mut |kind, bell| {
crate::notify::sound_policy::decide(
kind,
crate::notify::sound_policy::epoch_millis_now(),
bell,
)
let now_ms = crate::notify::sound_policy::epoch_millis_now();
let decision = crate::notify::sound_policy::decide(kind, now_ms, bell);
if matches!(
decision,
crate::notify::sound_policy::SoundDecision::Play(_)
) {
reserved.set(Some((kind, now_ms)));
}
decision
},
&mut |cue, sink| {
let outcome = crate::notify::audio::dispatch(cue, sink);
// A cue that never played must not spend the category's repeat
// window: the next notification may try again (U06-m3).
if matches!(
outcome,
crate::notify::audio::AudioOutcome::Busy
| crate::notify::audio::AudioOutcome::Unsupported
| crate::notify::audio::AudioOutcome::Failed
) && let Some((kind, reserved_ms)) = reserved.take()
{
crate::notify::sound_policy::release(kind, reserved_ms);
}
outcome
},
&mut crate::notify::audio::dispatch,
&mut dispatch_native,
)
}
+25 -3
View File
@@ -191,6 +191,10 @@ pub struct SetupWizardView {
/// Display label of the model that authored `model_draft` (safe metadata,
/// e.g. "GLM-5.2"), for provenance copy only.
model_draft_label: Option<String>,
/// The answers (and free-form note) the in-flight model draft was asked
/// to write. A draft lands only while they are still the wizard's
/// answers; one requested for answers since changed is stale (U08-06).
model_draft_request: Option<(GuidedConstitutionDraft, Option<String>)>,
runtime_preset: SetupRuntimePreset,
runtime_preset_preview_seen: bool,
body_scroll: usize,
@@ -2316,6 +2320,7 @@ impl SetupWizardView {
existing_preview_seen: false,
model_draft: None,
model_draft_label: None,
model_draft_request: None,
runtime_preset: SetupRuntimePreset::default(),
runtime_preset_preview_seen: false,
body_scroll: 0,
@@ -2343,6 +2348,7 @@ impl SetupWizardView {
existing_preview_seen: false,
model_draft: None,
model_draft_label: None,
model_draft_request: None,
runtime_preset: SetupRuntimePreset::default(),
runtime_preset_preview_seen: false,
body_scroll: 0,
@@ -2844,10 +2850,11 @@ impl SetupWizardView {
/// `A` on the constitution step: ask the first configured model to draft.
/// Requires a ready provider route; otherwise the key is inert and the
/// deterministic guided flow stands untouched.
fn request_model_draft(&self) -> ViewAction {
fn request_model_draft(&mut self) -> ViewAction {
if !self.facts.provider_ready {
return ViewAction::None;
}
self.model_draft_request = Some(self.current_model_draft_request());
ViewAction::Emit(ViewEvent::SetupConstitutionModelDraftRequested {
draft: self.guided_draft,
freeform_note: self.freeform_note_for_draft().map(str::to_string),
@@ -2862,6 +2869,13 @@ impl SetupWizardView {
ViewAction::None
}
fn current_model_draft_request(&self) -> (GuidedConstitutionDraft, Option<String>) {
(
self.guided_draft,
self.freeform_note_for_draft().map(str::to_string),
)
}
fn freeform_note_for_draft(&self) -> Option<&str> {
let note = self.freeform_note.trim();
(!note.is_empty()).then_some(note)
@@ -2906,12 +2920,20 @@ impl SetupWizardView {
/// ratification preview the host must open in the same breath — that is
/// what satisfies the preview gate. Ratifying still takes the explicit
/// `G` keypress afterwards.
///
/// Returns `None`, installing nothing, when the answers or note changed
/// after the draft was requested: that draft is law for answers the
/// person no longer holds (U08-06).
#[must_use]
pub(crate) fn install_model_draft(
&mut self,
constitution: Box<UserConstitution>,
model_label: String,
) -> (String, String) {
) -> Option<(String, String)> {
if self.model_draft_request.as_ref() != Some(&self.current_model_draft_request()) {
return None;
}
self.model_draft_request = None;
let content = constitution_ratification_text(
self.locale,
&constitution,
@@ -2920,7 +2942,7 @@ impl SetupWizardView {
self.model_draft = Some(constitution);
self.model_draft_label = Some(model_label);
self.guided_preview_seen = true;
(ratification_preview_title(self.locale).to_string(), content)
Some((ratification_preview_title(self.locale).to_string(), content))
}
fn commit_constitution(&self, kind: SetupCommitKind) -> ViewAction {
+3 -1
View File
@@ -606,7 +606,9 @@ fn deliver_constitution_draft_result(
let preview = boxed
.as_any_mut()
.downcast_mut::<crate::tui::setup::SetupWizardView>()
.map(|wizard| wizard.install_model_draft(constitution, model_label.clone()));
.and_then(|wizard| {
wizard.install_model_draft(constitution, model_label.clone())
});
app.view_stack.push_boxed(boxed);
if let Some((title, content)) = preview {
open_text_pager(app, title, content);
+16 -1
View File
@@ -332,6 +332,18 @@ pub(crate) fn apply_message_submit_outcome(
match outcome {
crate::hooks::MessageSubmitOutcome::Unchanged { .. } => true,
crate::hooks::MessageSubmitOutcome::Replaced { text, .. } => {
// A queued message was already echoed with its pre-hook text.
// Retarget that one cell so dispatch reuses it instead of adding a
// second User cell beside the stale echo (U02-03).
if message.history_echoed
&& let Some(idx) =
crate::tui::ui::dispatch::echoed_user_turn_cell(app, &message.display)
{
app.history[idx] = HistoryCell::User {
content: text.clone(),
};
app.bump_history_cell(idx);
}
message.display = text;
true
}
@@ -1963,7 +1975,10 @@ async fn apply_command_result_inner(
let base_url = config.active_route_base_url();
match fetch_provider_balance(provider, &api_key, &base_url).await {
Some(info) => {
if let Ok(mut guard) = app.balance_cell.lock() {
if let Ok(mut guard) =
balance_cell_for_route(app, provider, &api_key, &base_url)
.lock()
{
*guard = Some(info.clone());
}
app.last_balance_fetch = Some(Instant::now());
+9 -2
View File
@@ -91,12 +91,19 @@ pub(super) async fn auto_deny_session_approval(
"session_id": app.current_session_id,
}),
);
let _ = engine_handle
// The notice claims the call was denied; say so only when the denial
// reached the engine (U03-06). A failed send means the engine's approval
// mailbox is closed, so nothing is left waiting on this decision.
if let Err(error) = engine_handle
.deny_tool_call_by(
id.to_string(),
crate::approval_log::ApprovalDecider::SessionRule,
)
.await;
.await
{
tracing::warn!(tool_name, %error, "session-rule denial did not reach the engine");
return;
}
surface_session_denied_notice(app, tool_name);
}
+51 -10
View File
@@ -12,25 +12,32 @@ use super::*;
/// when the turn is busy. A dropped refresh is safe: the next turn rebuilds
/// its compaction config from `App`, and the status message tells the user
/// whether the update was queued or deferred.
///
/// The model and its compaction budget are one route update: both mailbox
/// slots are reserved before either op is sent, so a full mailbox defers the
/// whole update instead of applying the model without its budget (U03-05).
pub(crate) fn try_apply_model_and_compaction_update(
engine_handle: &EngineHandle,
compaction: crate::compaction::CompactionConfig,
mode: AppMode,
route_limits: Option<codewhale_config::route::RouteLimits>,
) -> bool {
if engine_handle
.try_send(Op::SetModel {
let Ok(model_permit) = engine_handle.tx_op.clone().try_reserve_owned() else {
return false;
};
let Ok(compaction_permit) = engine_handle.tx_op.clone().try_reserve_owned() else {
return false;
};
engine_handle.send_reserved_op(
model_permit,
Op::SetModel {
model: compaction.model.clone(),
mode,
route_limits,
})
.is_err()
{
return false;
}
engine_handle
.try_send(Op::SetCompaction { config: compaction })
.is_ok()
},
);
engine_handle.send_reserved_op(compaction_permit, Op::SetCompaction { config: compaction });
true
}
pub(crate) fn set_explicit_compaction_status(
@@ -572,4 +579,38 @@ mod config_update_tests {
Some(Op::SetCompaction { config }) if config == compaction
));
}
/// U03-05: with room for only one op, neither half of the route update
/// is queued; a model without its compaction budget is a half-applied
/// route with nothing to roll it back.
#[tokio::test]
async fn live_compaction_update_is_all_or_nothing_when_one_slot_is_free() {
let mut mock = mock_engine_handle();
let mut fillers = 0;
while mock.handle.tx_op.capacity() > 1 {
mock.handle
.try_send(Op::ListSubAgents)
.expect("filler op fits");
fillers += 1;
}
let compaction = crate::compaction::CompactionConfig {
model: "deepseek-v4-flash".to_string(),
..crate::compaction::CompactionConfig::default()
};
assert!(!try_apply_model_and_compaction_update(
&mock.handle,
compaction,
AppMode::Agent,
None,
));
for _ in 0..fillers {
assert!(matches!(mock.rx_op.try_recv(), Ok(Op::ListSubAgents)));
}
assert!(
mock.rx_op.try_recv().is_err(),
"no half of the route update may be queued"
);
}
}
+14 -10
View File
@@ -89,24 +89,28 @@ pub(crate) fn paint_user_turn_cell(
content: String,
) -> usize {
if message.history_echoed
&& let Some(idx) = app
.history
.iter()
.enumerate()
.rev()
.find_map(|(idx, cell)| match cell {
HistoryCell::User { content } if content == &message.display => Some(idx),
_ => None,
})
&& let Some(idx) = echoed_user_turn_cell(app, &message.display)
{
app.history[idx] = HistoryCell::User { content };
app.needs_redraw = true;
app.bump_history_cell(idx);
return idx;
}
app.add_message(HistoryCell::User { content });
app.history.len().saturating_sub(1)
}
/// The newest transcript cell that queue-time echo painted for `display`.
pub(crate) fn echoed_user_turn_cell(app: &App, display: &str) -> Option<usize> {
app.history
.iter()
.enumerate()
.rev()
.find_map(|(idx, cell)| match cell {
HistoryCell::User { content } if content == display => Some(idx),
_ => None,
})
}
pub(crate) fn enqueue_offline_message(app: &mut App, message: QueuedMessage) {
app.queue_message(message);
persist_offline_queue_state(app);
+41 -11
View File
@@ -117,6 +117,14 @@ fn current_session_fleet_workers_status(
.replace("{count}", &count.to_string())
}
/// A turn is unsettled until its authoritative terminal event lands. A local
/// cancel clears `is_loading` at once, but the engine's `TurnComplete` is still
/// owed: until it arrives the recovery checkpoint is the only complete record
/// of that turn, so shutdown must not declare the session settled (U02-09).
fn turn_unsettled_for_shutdown(app: &App) -> bool {
app.is_loading || app.dispatch_in_flight || app.suppress_stream_events_until_turn_complete
}
/// Host state can change without a model turn, including learning the Runtime
/// binding of a resumed legacy session. Commit that state before clearing its
/// recovery checkpoint; an unfinished turn keeps its checkpoint untouched.
@@ -124,7 +132,7 @@ pub(super) fn persist_settled_session_on_shutdown(
app: &mut App,
handle: &persistence_actor::PersistActorHandle,
) -> Result<bool, String> {
if app.is_loading || app.dispatch_in_flight || app.current_session_id.is_none() {
if turn_unsettled_for_shutdown(app) || app.current_session_id.is_none() {
return Ok(false);
}
let manager = SessionManager::default_location().map_err(|error| error.to_string())?;
@@ -1260,7 +1268,7 @@ pub async fn run_tui(
// A quit key can leave the frame before its usual queue comparison.
// Capture the final edited draft before the shutdown durability barrier.
persist_offline_queue_state(&app);
let turn_in_flight = app.is_loading || app.dispatch_in_flight;
let turn_in_flight = turn_unsettled_for_shutdown(&app);
if turn_in_flight {
tracing::info!(
target: "persistence",
@@ -2180,6 +2188,12 @@ pub(crate) async fn run_event_loop(
break;
}
};
// Count every received event before any filter can `continue`
// past it (U02-02). Filtered deltas, suppressed post-cancel
// events and stale requests used to skip the counter, so a
// producer flooding them never tripped the drain budget and
// the loop never returned to render or read the cancel key.
events_drained = events_drained.saturating_add(1);
// #3033: remember whether an EARLIER event in this drain batch
// already requested a redraw. The AgentProgress throttle below
// may opt the current event out of repainting, but it must not
@@ -2798,7 +2812,20 @@ pub(crate) async fn run_event_loop(
// (#6190).
crate::tui::ui::dispatch::settle_unaccepted_steers_at_turn_end(app);
let completed_turn = app.active_turn.take();
app.unanswered_submission = None;
// TurnComplete carries no turn id. After a local
// cancel the UI is idle, so `is_loading` can only be
// true again because a newer dispatch set it: this
// terminal event belongs to the cancelled turn and
// must not close the newer one's loading, dispatch,
// status or unanswered-submission state, nor drain
// the queue ahead of it (U02-07). The engine runs one
// turn at a time, so the newer turn's own TurnStarted
// and TurnComplete follow this event.
let newer_dispatch_owns_turn_state =
app.suppress_stream_events_until_turn_complete && app.is_loading;
if !newer_dispatch_owns_turn_state {
app.unanswered_submission = None;
}
// The in-flight provisional estimate hands off to the
// authoritative cumulative price accrued below; the
// high-water mark keeps the displayed total monotonic
@@ -2842,8 +2869,10 @@ pub(crate) async fn run_event_loop(
} else {
app.flush_active_cell();
}
app.is_loading = false;
app.dispatch_started_at = None;
if !newer_dispatch_owns_turn_state {
app.is_loading = false;
app.dispatch_started_at = None;
}
app.pending_provider_switch = None;
app.offline_mode = false;
app.streaming_state.reset();
@@ -2872,7 +2901,7 @@ pub(crate) async fn run_event_loop(
// turn's chunks pull the view down again until the
// user opts out by scrolling up.
app.user_scrolled_during_stream = false;
app.runtime_turn_status = Some(match status {
let turn_status_label = match status {
crate::core::events::TurnOutcomeStatus::Completed => {
app.ocean_completion_started_at = Some(Instant::now());
app.ocean_receipt_settle_start =
@@ -2889,7 +2918,10 @@ pub(crate) async fn run_event_loop(
app.ocean_receipt_settle_start = None;
"failed".to_string()
}
});
};
if !newer_dispatch_owns_turn_state {
app.runtime_turn_status = Some(turn_status_label.clone());
}
if matches!(
status,
crate::core::events::TurnOutcomeStatus::Interrupted
@@ -3305,8 +3337,7 @@ pub(crate) async fn run_event_loop(
// `turn.interrupted` for locally cancelled ones.
// No-op when the feature is disabled.
{
let outbox_status =
app.runtime_turn_status.as_deref().unwrap_or("unknown");
let outbox_status = turn_status_label.as_str();
let kind = match outbox_status {
"completed" => "turn.completed",
"failed" => "turn.failed",
@@ -3333,7 +3364,7 @@ pub(crate) async fn run_event_loop(
});
}
if queued_to_send.is_none() {
if queued_to_send.is_none() && !newer_dispatch_owns_turn_state {
queued_to_send = app.pop_queued_message();
}
}
@@ -4265,7 +4296,6 @@ pub(crate) async fn run_event_loop(
}
}
}
events_drained = events_drained.saturating_add(1);
}
}
if let Some(rollback) = fallback_after_engine_error {
+13 -5
View File
@@ -31,9 +31,12 @@
/// The restore byte string, written in a single `write(2)`.
///
/// Mirrors `emergency_restore_terminal`'s mode teardown, minus raw mode
/// (termios state lives behind a lock that may be held by the dying thread)
/// and minus every query (a dead process cannot read replies). Modes left
/// Mirrors `emergency_restore_terminal`'s mode teardown, minus every query
/// (a dead process cannot read replies). Raw mode is not in these bytes:
/// crossterm's termios state sits behind a lock the dying thread may hold, so
/// the handler restores the job-control guard's lock-free cooked snapshot with
/// `tcsetattr` instead (U03-02). When that guard is disabled or its snapshot
/// failed there is no snapshot, and raw mode is left for `stty sane`. Modes left
/// over that a shell does not self-heal are the ones that poison input:
/// mouse capture and the kitty keyboard stack get the full reset.
#[cfg(unix)]
@@ -108,8 +111,8 @@ pub(crate) fn install_fatal_signal_guard() {
///
/// # Safety
///
/// Only async-signal-safe operations: `write(2)`, `open(2)`, `close(2)`,
/// `signal(2)`, `raise(2)`, and fixed-buffer arithmetic. No allocation, no
/// Only async-signal-safe operations: `write(2)`, `tcsetattr(3)`, `open(2)`,
/// `close(2)`, `signal(2)`, `raise(2)`, and fixed-buffer arithmetic. No allocation, no
/// locks (the `OnceLock`/`AtomicUsize` reads complete before any thread
/// exists and are never written again).
#[cfg(unix)]
@@ -136,6 +139,11 @@ unsafe extern "C" fn fatal_signal_handler(signal: libc::c_int) {
FATAL_RESTORE_BYTES.len(),
);
}
// Leave raw mode from the cooked snapshot taken before it was enabled.
// `tcsetattr` is async-signal-safe; TCSANOW never waits on output.
if let Some(original) = super::job_control_guard::original_termios() {
let _ = libc::tcsetattr(libc::STDIN_FILENO, libc::TCSANOW, original);
}
// 2. Append the one-line marker (mtime timestamps it).
let len = MARKER_LEN.load(std::sync::atomic::Ordering::Acquire);
+10 -9
View File
@@ -1664,16 +1664,17 @@ pub(crate) async fn handle_view_events(
apply_user_input_submission_result(app, &tool_id, result);
}
ViewEvent::UserInputCancelled { tool_id } => {
if engine_handle
.cancel_user_input(tool_id.clone())
.await
.is_ok()
{
settle_user_input_request(app, &tool_id);
// A cancel is an answer too: when it cannot reach the engine
// the question is still pending there, so reopen it the way a
// failed submit does instead of recording a cancel (U02-04).
let result = engine_handle.cancel_user_input(tool_id.clone()).await;
let delivered = result.is_ok();
apply_user_input_submission_result(app, &tool_id, result);
if delivered {
app.add_message(HistoryCell::System {
content: "User input cancelled".to_string(),
});
}
app.add_message(HistoryCell::System {
content: "User input cancelled".to_string(),
});
}
ViewEvent::SessionSelected { session_id } => {
let manager = match SessionManager::default_location() {
@@ -80,6 +80,13 @@ static RESTORED: AtomicBool = AtomicBool::new(false);
#[cfg(unix)]
static ORIGINAL_TERMIOS: OnceLock<libc::termios> = OnceLock::new();
/// The cooked snapshot above, for the fatal-signal guard: one snapshot, taken
/// once before raw mode, serves every signal-time restore.
#[cfg(unix)]
pub(crate) fn original_termios() -> Option<&'static libc::termios> {
ORIGINAL_TERMIOS.get()
}
/// Install the job-control guard. POSIX only; no-op elsewhere.
///
/// Call once on the main thread, after the foreground-ownership check (the
+33 -5
View File
@@ -307,17 +307,47 @@ pub(crate) fn should_fetch_provider_balance(app: &App) -> bool {
&& crate::config::provider_has_balance_api(app.api_provider)
}
/// The balance cell for this route. A reading belongs to the route that
/// fetched it: when the provider, endpoint or key changes, a fresh cell
/// replaces the old one, so a response still in flight for the previous route
/// lands in a cell nothing reads (U03-08), and the new route is not held behind
/// the previous route's fetch cooldown.
pub(crate) fn balance_cell_for_route(
app: &mut App,
provider: ApiProvider,
api_key: &str,
base_url: &str,
) -> std::sync::Arc<std::sync::Mutex<Option<crate::pricing::BalanceInfo>>> {
use std::hash::{Hash, Hasher};
let mut key_fingerprint = std::collections::hash_map::DefaultHasher::new();
api_key.hash(&mut key_fingerprint);
let route = format!(
"{}\u{1f}{base_url}\u{1f}{:016x}",
provider.as_str(),
key_fingerprint.finish()
);
if app.balance_route.as_deref() != Some(route.as_str()) {
app.balance_cell = std::sync::Arc::new(std::sync::Mutex::new(None));
app.balance_route = Some(route);
app.last_balance_fetch = None;
}
app.balance_cell.clone()
}
/// Kick a background remaining-credit fetch for the live route.
///
/// `force` skips the status-item gate (used by `/balance`). Providers without
/// a known endpoint clear the parked chip so a previous route cannot linger.
pub(crate) fn schedule_balance_fetch(app: &mut App, api_key: &str, base_url: &str, force: bool) {
if !crate::config::provider_has_balance_api(app.api_provider) {
if let Ok(mut guard) = app.balance_cell.lock() {
*guard = None;
}
// A fresh cell, not a cleared one: a fetch still in flight for the
// previous route holds the old cell and cannot repaint the chip.
app.balance_cell = std::sync::Arc::new(std::sync::Mutex::new(None));
app.balance_route = None;
return;
}
let provider = app.api_provider;
let cell = balance_cell_for_route(app, provider, api_key, base_url);
if !force && !should_fetch_provider_balance(app) {
return;
}
@@ -332,8 +362,6 @@ pub(crate) fn schedule_balance_fetch(app: &mut App, api_key: &str, base_url: &st
return;
}
app.last_balance_fetch = Some(Instant::now());
let cell = app.balance_cell.clone();
let provider = app.api_provider;
let api_key = api_key.to_string();
let base_url = base_url.to_string();
tokio::spawn(async move {
@@ -251,7 +251,10 @@ pub(crate) async fn drain_remote_control_events(
}
}
crate::remote_control::RemoteCommand::Approval { gate, approved } => {
let Some(tool_id) = app.remote_control.take_pending_approval(&gate) else {
// Keep the gate pending until the engine takes the
// decision: a failed send leaves it retryable (U03-07).
let Some(tool_id) = app.remote_control.pending_approval_tool_id(&gate)
else {
app.remote_control.acknowledge(
&run_id,
seq,
@@ -268,6 +271,7 @@ pub(crate) async fn drain_remote_control_events(
};
match result {
Ok(()) => {
let _ = app.remote_control.take_pending_approval(&gate);
app.retire_action_notices(Some(&tool_id));
// First decision wins: the web answered this
// gate, so dismiss exactly the matching card —
+18 -5
View File
@@ -448,20 +448,33 @@ pub(crate) fn pause_terminal(
// silently ignore the pop. Matches the shutdown and panic paths.
pop_keyboard_enhancement_flags(terminal.backend_mut());
disable_alternate_scroll_mode(terminal.backend_mut());
execute!(terminal.backend_mut(), DisableFocusChange)?;
disable_raw_mode()?;
// Every teardown step is attempted even when an earlier one fails: one
// failed write must not leave mouse capture or raw mode on for the child
// (U03-09). The first failure is still returned so the caller refuses the
// handoff.
let mut first_error: Option<io::Error> = None;
let mut attempt = |result: io::Result<()>| {
if let Err(error) = result {
first_error.get_or_insert(error);
}
};
attempt(execute!(terminal.backend_mut(), DisableFocusChange));
attempt(disable_raw_mode());
if use_alt_screen {
leave_alt_screen(terminal.backend_mut())?;
attempt(leave_alt_screen(terminal.backend_mut()));
#[cfg(windows)]
crate::logging::restore_verbose_state();
}
if use_mouse_capture {
execute!(terminal.backend_mut(), DisableMouseCapture)?;
attempt(execute!(terminal.backend_mut(), DisableMouseCapture));
}
if use_bracketed_paste {
disable_bracketed_paste_mode(terminal.backend_mut());
}
Ok(())
match first_error {
Some(error) => Err(error.into()),
None => Ok(()),
}
}
pub(crate) fn resume_terminal(
+4
View File
@@ -337,6 +337,10 @@ impl TerminalInputPump {
/// detached: `stop` is flagged and the `JoinHandle` dropped, so if the
/// thread ever wakes it exits on its own (its send fails once `rx` is
/// replaced, and the stop flag covers the poll loop).
///
/// Known limitation (U03-10): stop is flagged before the replacement is
/// spawned, but a thread already inside `event::read` cannot be stopped.
/// If it ever wakes, the one event it read has no receiver and is lost.
pub(super) fn restart_detached(&mut self) -> io::Result<()> {
self.detach_current_thread();
let parts = Self::spawn_parts()?;
+41
View File
@@ -19840,6 +19840,47 @@ fn steer_reuses_queued_echo_cell_instead_of_doubling() {
assert_eq!(idx, 0, "the rewritten cell keeps the queue-time index");
}
/// U02-03: a message_submit hook that rewrites a queued message retargets
/// the queue-time echo, so dispatch reuses it instead of adding a second
/// User cell beside the stale pre-hook text.
#[test]
fn hook_replaced_queued_message_keeps_one_user_cell() {
let mut app = create_test_app();
let mut message = QueuedMessage::new("draft as typed".to_string(), None);
echo_queued_user_turn(&mut app, &mut message);
assert!(apply_message_submit_outcome(
&mut app,
&mut message,
crate::hooks::MessageSubmitOutcome::replaced("draft as rewritten".to_string()),
));
paint_user_turn_cell(&mut app, &message, message.display.clone());
let user_cells: Vec<String> = app
.history
.iter()
.filter_map(|cell| match cell {
HistoryCell::User { content } => Some(content.clone()),
_ => None,
})
.collect();
assert_eq!(user_cells, vec!["draft as rewritten".to_string()]);
}
/// U02-10: a dispatch still in flight, or a locally cancelled turn whose
/// terminal event has not landed, holds the session: switching now would
/// carry the stale cancellation into the next session's first turn.
#[test]
fn session_transition_waits_for_pending_dispatch_and_cancelled_turn() {
let mut app = create_test_app();
assert!(!app.session_transition_blocked());
app.suppress_stream_events_until_turn_complete = true;
assert!(app.session_transition_blocked());
app.suppress_stream_events_until_turn_complete = false;
app.dispatch_in_flight = true;
assert!(app.session_transition_blocked());
}
#[test]
fn engine_drain_budget_respects_event_and_time_limits() {
let start = Instant::now();
@@ -102,7 +102,12 @@ async fn runtime_store_binding_exit_preserves_inflight_recovery() -> anyhow::Res
let root = tempfile::tempdir()?;
let _home = crate::test_support::EnvVarGuard::set("CODEWHALE_HOME", root.path());
let sessions = SessionManager::default_location()?;
for (loading, dispatch) in [(true, false), (false, true)] {
// U02-09: a locally cancelled turn still owes its terminal event.
for (loading, dispatch, cancelled) in [
(true, false, false),
(false, true, false),
(false, false, true),
] {
let original = crate::session_manager::create_saved_session_with_mode(
&[],
"deepseek-v4-pro",
@@ -119,6 +124,7 @@ async fn runtime_store_binding_exit_preserves_inflight_recovery() -> anyhow::Res
app.current_session_id = Some(original.metadata.id.clone());
app.is_loading = loading;
app.dispatch_in_flight = dispatch;
app.suppress_stream_events_until_turn_complete = cancelled;
let (handle, actor) =
persistence_actor::spawn_persistence_actor(SessionManager::default_location()?);
assert!(
+21
View File
@@ -404,6 +404,27 @@ impl ModalView for UserInputView {
}
}
/// A pasted custom answer lands in the free-text field (U08-m1); while
/// choosing among options the paste is not consumed. Line breaks become
/// spaces because Enter submits this single-line answer.
fn handle_paste(&mut self, text: &str) -> bool {
if self.mode != InputMode::OtherInput {
return false;
}
self.other_input.extend(
text.chars()
.map(|ch| {
if matches!(ch, '\n' | '\r' | '\t') {
' '
} else {
ch
}
})
.filter(|ch| !ch.is_control()),
);
true
}
fn handle_mouse(&mut self, mouse: MouseEvent) -> ViewAction {
let scroll = match mouse.kind {
MouseEventKind::ScrollUp => self.scroll_offset.get().saturating_sub(3),