mirror of
https://github.com/codewhale-hq/Codewhale.git
synced 2026-11-08 08:04:25 -10:00
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:
commit
fbb4572fbc
35 files changed
+757
-176
No files matched your search
@@ -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();
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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)),
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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}");
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}");
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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') =>
|
||||
{
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 —
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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()?;
|
||||
|
||||
@@ -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!(
|
||||
|
||||
@@ -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),
|
||||
|
||||
Reference in new issue
Block a user