Synced from monorepo

Changes:
- Gate session-lifecycle heap steady state with a dhat soak
- Unbreak merge lifecycle e2e after default model → grok-4.5
- Scan home-scope rules dirs at <root>/rules
- Complete text-input paste and terminal parity
- Gate project roles and personas
- Use canonical editing in dialogs
- Use canonical editing in search bars
- Reject ambiguous MCP tool IDs
- Harden Git operands for plugins
- Simplify queue drain API
- Pass RFC 9207 iss through MCP OAuth token exchange
- Show leader roster when local agents map is empty
- Use canonical editing in Persona views
- Remove marketplace default-skills auto-install and purge old installs
- Use canonical editing in extension forms
- Add canonical dashboard text editing
- Use canonical editing in settings
- Add /summarize as a /recap alias
- Restore previous agent when exiting dashboard
- Use tool_choice auto for compaction
- Settings toggle for snap-prompt-to-top on send
- Update default models to grok-4.5
- Source login shell once for local bash (env + alias/function snapshot)
- Template hardcoded param names in server-native tool descriptions
- Fix System-Reminder XML tag injection in CLAUDE.md via agents_md
- Fix remote workspace-server hardcoding LSP trust (repo code execution risk)
- Clear orphaned tool-call updates at turn end
- Suppress task wake after cancel
- Send x-grok-client-identifier on direct API tool calls
- Harden dashboard peek lease transitions
- Host /btw side panel in live region (minimal mode)
- Bound scroll presentation latency
- Highlight multi-line constructs correctly in diffs and the file viewer
- Block web_fetch non-public IPs; local opt-in is explicit-host only
- Seed coding_data_retention_opt_out=false for OAuth e2es in pty-harness
- Follow up clipboard delivery feedback
- Use canonical editing in pickers
- Route TextArea through canonical editor
- Persistent "watching" status row; quieter turn markers
- Gate sensitive edit targets
- Expose agent registry counts and gate session churn on them
- Default coding data sharing to opt-out until server preference applies
- Wire chat attachment ids through gateway prompts
- On auth refresh failure, issue retry
- Forward preview provenance and computer lifecycle state
- Document independent privacy controls and scope /privacy output
- Strip SamplingError Display prefix on rate-limit UI copy
- Stop dumping Cloudflare HTML into Retry failed
- Disable in-place prompt edit (scroll jank on enter)
- Strip forced ANSI color from gh pr view JSON
- Plumb bash tool description onto ToolUsageCard wire
This commit is contained in:
grokkybara[bot] 2026-07-18 19:48:28 +01:00
commit 7cfcb20d2b
292 changed files with 23315 additions and 9209 deletions

View file

@ -9,6 +9,7 @@ use crate::actions::{ActionId, ActionRegistry, When};
use crate::appearance::AppearanceConfig;
use crate::input::KeyboardNormalizer;
use crate::input::key::KeyShortcut;
use crate::input::line_editor::{LineEditOutcome, LineEditor};
use crate::input::mouse::{MouseScrollState, ScrollConfig, ScrollDirection};
use crate::key;
use crate::notifications::NotificationService;
@ -27,28 +28,56 @@ use xai_acp_lib::AcpAgentTx;
#[derive(Debug, Default)]
pub struct NewWorktreeDialogState {
/// Text input for the worktree label (empty = auto-generated name).
pub label_input: String,
label: LineEditor,
}
const MAX_WORKTREE_LABEL_BYTES: usize = 100;
impl NewWorktreeDialogState {
pub fn new() -> Self {
Self {
label_input: String::new(),
label: LineEditor::default(),
}
}
pub fn label(&self) -> &str {
self.label.text()
}
pub(crate) fn viewport(&self, width: usize) -> xai_ratatui_textarea::SingleLineViewport {
self.label.viewport(width)
}
#[cfg(test)]
pub(crate) fn set_label(&mut self, label: impl Into<String>) {
self.label.set_text(label);
}
#[cfg(test)]
pub(crate) fn set_cursor_byte(&mut self, cursor_byte: usize) -> LineEditOutcome {
self.label.set_cursor_byte(cursor_byte)
}
pub fn insert_paste(&mut self, text: &str) -> NewWorktreeDialogOutcome {
Self::from_line_edit(
self.label
.insert_paste_with_byte_limit(text, MAX_WORKTREE_LABEL_BYTES),
)
}
/// Handle a key event. Returns the dialog outcome.
pub fn handle_key(&mut self, key: &crossterm::event::KeyEvent) -> NewWorktreeDialogOutcome {
use crossterm::event::{KeyCode, KeyModifiers};
if crate::input::key::is_paste_key(key) {
return crate::clipboard::system_clipboard_get()
.map_or(NewWorktreeDialogOutcome::Unchanged, |text| {
self.insert_paste(&text)
});
}
if key.modifiers.contains(KeyModifiers::CONTROL)
&& !crate::input::key::is_altgr(key.modifiers)
&& matches!(key.code, KeyCode::Char('c' | 'd' | 'q'))
{
return match key.code {
KeyCode::Char('c' | 'd' | 'q') => NewWorktreeDialogOutcome::Cancelled,
_ => NewWorktreeDialogOutcome::Unchanged,
};
return NewWorktreeDialogOutcome::Cancelled;
}
if key.code == KeyCode::Enter && !key.modifiers.is_empty() {
return NewWorktreeDialogOutcome::Unchanged;
}
match key.code {
KeyCode::Enter => {
let label = self.label_input.trim().to_string();
KeyCode::Enter if key.modifiers.is_empty() => {
let label = self.label().trim().to_string();
NewWorktreeDialogOutcome::Submitted(if label.is_empty() {
None
} else {
@ -56,20 +85,21 @@ impl NewWorktreeDialogState {
})
}
KeyCode::Esc => NewWorktreeDialogOutcome::Cancelled,
KeyCode::Backspace => {
if self.label_input.pop().is_some() {
NewWorktreeDialogOutcome::Changed
} else {
NewWorktreeDialogOutcome::Unchanged
}
_ => {
let remaining = MAX_WORKTREE_LABEL_BYTES.saturating_sub(self.label().len());
let outcome = self.label.handle_key_with_insert_policy(key, |character| {
character.len_utf8() <= remaining
});
Self::from_line_edit(outcome)
}
KeyCode::Char(c) => {
if self.label_input.len() < 100 {
self.label_input.push(c);
}
NewWorktreeDialogOutcome::Changed
}
_ => NewWorktreeDialogOutcome::Unchanged,
}
}
fn from_line_edit(outcome: LineEditOutcome) -> NewWorktreeDialogOutcome {
match outcome {
LineEditOutcome::TextChanged
| LineEditOutcome::CursorChanged
| LineEditOutcome::HandledNoChange => NewWorktreeDialogOutcome::Changed,
LineEditOutcome::Unhandled => NewWorktreeDialogOutcome::Unchanged,
}
}
}
@ -197,6 +227,26 @@ pub enum ActiveView {
/// The top-level Agent Dashboard. State lives in `AppView::dashboard`.
AgentDashboard,
}
/// Target restored when leaving the dashboard (Ctrl+\ / Esc).
/// Consumed by `dispatch_exit_dashboard`; dead agents fall back to
/// insertion-order first / Welcome.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DashboardReturn {
/// Plain agent view (no session-overlay chrome).
Agent(AgentId),
/// Session overlay: re-set `attached_agent` on the way back.
Overlay(AgentId),
}
impl DashboardReturn {
pub fn agent_id(self) -> AgentId {
match self {
Self::Agent(id) | Self::Overlay(id) => id,
}
}
pub fn is_overlay(self) -> bool {
matches!(self, Self::Overlay(_))
}
}
/// Tick cadence demanded by the current view state — see
/// [`AppView::tick_demand`]. Ordered: `None < Slow < Fast`.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
@ -956,7 +1006,7 @@ pub struct AppView {
/// Initial auth mode hint from method metadata.
pub auth_start_mode: AuthMode,
/// Text buffer for manual auth token paste (loopback mode).
pub auth_code_input: String,
pub(crate) auth_code_input: LineEditor,
/// Monotonically increasing sequence number for auth requests.
pub next_auth_request_seq: u64,
/// Abort handle for the in-flight `PollAuthUrl` task (with its request_seq).
@ -966,8 +1016,10 @@ pub struct AppView {
pub deferred_startup: crate::app::session_startup::DeferredStartupActions,
/// Whether deferred welcome-screen login should force OAuth.
pub auth_use_oauth: bool,
/// Whether the last clipboard copy during auth succeeded.
pub auth_clipboard_copied: bool,
/// Delivery state from the last clipboard copy during auth.
pub auth_clipboard_delivery: Option<crate::clipboard::ClipboardDelivery>,
/// Generation of the current auth copy feedback and its clear timer.
pub auth_clipboard_feedback_generation: u64,
/// Team principal UUID from auth (`None` for personal sessions).
pub team_id: Option<String>,
/// Team name from auth (displayed in the shortcuts bar).
@ -1052,6 +1104,8 @@ pub struct AppView {
/// Held outside the `ActiveView` discriminant because `DashboardState`
/// is not `Copy` (owns its prompt widget, peek panel, etc.).
pub dashboard: Option<crate::views::dashboard::DashboardState>,
/// Where to return when leaving the dashboard. See [`DashboardReturn`].
pub dashboard_return: Option<DashboardReturn>,
/// Persisted dashboard configuration (pinned rows, reorderings,
/// grouping). Loaded once on startup from
/// `~/.grok/config.toml`. `None` when the file/section is absent
@ -1295,17 +1349,18 @@ impl AppView {
login_label: None,
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
auth_code_input: LineEditor::default(),
next_auth_request_seq: 1,
auth_url_poll_handle: None,
deferred_startup: Default::default(),
auth_use_oauth: false,
auth_clipboard_copied: false,
auth_clipboard_delivery: None,
auth_clipboard_feedback_generation: 0,
team_id: None,
team_name: None,
is_zdr: false,
team_role: None,
coding_data_retention_opt_out: false,
coding_data_retention_opt_out: true,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
@ -1351,6 +1406,7 @@ impl AppView {
cancel_rewind_enabled: true,
session_recap_available: false,
dashboard: None,
dashboard_return: None,
dashboard_persisted: None,
keyboard_normalizer: KeyboardNormalizer::from_terminal_context(),
voice_mode_enabled: false,
@ -2054,11 +2110,12 @@ impl AppView {
/// Quit always goes through double-press confirmation, even when
/// escalated from agent-level (e.g., Ctrl-C while cancelling).
pub fn handle_input(&mut self, ev: &Event) -> InputOutcome {
self.handle_input_with_paste_provenance(ev, PasteProvenance::Terminal)
self.handle_input_at_with_paste_provenance(ev, Instant::now(), PasteProvenance::Terminal)
}
pub(crate) fn handle_input_with_paste_provenance(
pub(crate) fn handle_input_at_with_paste_provenance(
&mut self,
ev: &Event,
arrived_at: Instant,
paste_provenance: PasteProvenance,
) -> InputOutcome {
debug_assert!(
@ -2106,7 +2163,9 @@ impl AppView {
let config = self
.scroll_config
.with_viewport_height(self.scroll_viewport_height());
let update = self.scroll_state.on_scroll_event(direction, config);
let update = self
.scroll_state
.on_scroll_event_at(arrived_at, direction, config);
let pos = (mouse.column, mouse.row);
self.last_scroll_pos = Some(pos);
if update.lines != 0 {
@ -2370,20 +2429,34 @@ impl AppView {
return outcome;
}
let prompt_paging = !overlay_active && !self.screen_mode.is_minimal();
match self.agents.get_mut(&id) {
Some(agent) => {
let outcome = if prompt_paging {
agent.handle_input_with_prompt_paging(ev, &self.registry)
} else {
agent.handle_input(ev, &self.registry)
};
if let Event::Key(key) = ev {
agent.record_input(key, &outcome);
if self.screen_mode.is_minimal() {
match self.agents.get_mut(&id) {
Some(agent) => {
let outcome = agent.handle_minimal_input(ev, &self.registry);
if let Event::Key(key) = ev {
agent.record_input(key, &outcome);
}
self.pending_effects.append(&mut agent.pending_effects);
outcome
}
self.pending_effects.append(&mut agent.pending_effects);
outcome
None => InputOutcome::Unchanged,
}
} else {
match self.agents.get_mut(&id) {
Some(agent) => {
let outcome = if prompt_paging {
agent.handle_input_with_prompt_paging(ev, &self.registry)
} else {
agent.handle_input(ev, &self.registry)
};
if let Event::Key(key) = ev {
agent.record_input(key, &outcome);
}
self.pending_effects.append(&mut agent.pending_effects);
outcome
}
None => InputOutcome::Unchanged,
}
None => InputOutcome::Unchanged,
}
}
ActiveView::AgentDashboard => {
@ -2681,7 +2754,10 @@ impl AppView {
}
}
pub(crate) use crate::views::session_picker::filter_session_entries;
use crate::views::session_picker::{CONTENT_EXPAND_OFFSET, PickerItem, build_entry_map};
use crate::views::session_picker::{
CONTENT_EXPAND_OFFSET, PickerItem, SessionPickerWorktreeSelection, build_entry_map,
session_picker_worktree_selection, sync_session_picker_query_expansion,
};
/// Context for welcome-view input handling.
struct WelcomeInputCtx<'a> {
auth_state: &'a AuthState,
@ -2695,7 +2771,7 @@ struct WelcomeInputCtx<'a> {
/// that was started from inside a session. Esc / `q` then cancel the
/// login and return to the session rather than quitting the app.
mid_session_login: bool,
auth_code_input: &'a mut String,
auth_code_input: &'a mut LineEditor,
prompt: &'a mut PromptWidget,
prompt_focused: &'a mut bool,
new_worktree_dialog: &'a mut Option<NewWorktreeDialogState>,
@ -2829,31 +2905,30 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::Unchanged;
}
if let Some(dialog) = ctx.new_worktree_dialog.as_mut() {
if let Event::Key(key) = ev {
if key.kind == crossterm::event::KeyEventKind::Release {
return InputOutcome::Unchanged;
let outcome = match ev {
Event::Key(key) if key.kind != crossterm::event::KeyEventKind::Release => {
dialog.handle_key(key)
}
match dialog.handle_key(key) {
NewWorktreeDialogOutcome::Submitted(label) => {
*ctx.new_worktree_dialog = None;
return InputOutcome::Action(Action::NewWorktreeSession {
load_session_id: None,
label,
git_ref: None,
});
}
NewWorktreeDialogOutcome::Cancelled => {
*ctx.new_worktree_dialog = None;
return InputOutcome::Changed;
}
NewWorktreeDialogOutcome::Changed => return InputOutcome::Changed,
NewWorktreeDialogOutcome::Unchanged => return InputOutcome::Unchanged,
Event::Paste(text) => dialog.insert_paste(text),
Event::Resize(_, _) => return InputOutcome::Changed,
_ => NewWorktreeDialogOutcome::Unchanged,
};
match outcome {
NewWorktreeDialogOutcome::Submitted(label) => {
*ctx.new_worktree_dialog = None;
return InputOutcome::Action(Action::NewWorktreeSession {
load_session_id: None,
label,
git_ref: None,
});
}
NewWorktreeDialogOutcome::Cancelled => {
*ctx.new_worktree_dialog = None;
return InputOutcome::Changed;
}
NewWorktreeDialogOutcome::Changed => return InputOutcome::Changed,
NewWorktreeDialogOutcome::Unchanged => return InputOutcome::Unchanged,
}
if matches!(ev, Event::Resize(_, _)) {
return InputOutcome::Changed;
}
return InputOutcome::Unchanged;
}
if matches!(ctx.auth_state, AuthState::Done)
&& ctx.has_access
@ -2899,7 +2974,6 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
if ctx.sp_entries.is_some() && matches!(ctx.auth_state, AuthState::Done) {
use crate::views::picker::{PickerConfig, PickerOutcome, handle_picker_input};
let query_before = ctx.sp_state.query.clone();
let source_filter = *ctx.sp_source_filter;
let current_repo =
crate::views::session_picker::repo_name_from_cwd(&ctx.cwd.to_string_lossy());
@ -2907,7 +2981,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
ctx.sp_entries.as_deref(),
ctx.sp_content_results.as_deref(),
crate::views::session_picker::effective_filter_query(
&ctx.sp_state.query,
ctx.sp_state.query(),
ctx.sp_entries_query.as_deref(),
),
ctx.session_picker_grouped,
@ -2938,6 +3012,33 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
search_only_on_slash: false,
vim_normal_first: crate::appearance::cache::load_vim_mode(),
};
if let Event::Key(key) = ev {
if key.kind == KeyEventKind::Press
&& (key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key))
{
return InputOutcome::Action(Action::Quit);
}
if let Some(selection) = session_picker_worktree_selection(
key,
ctx.sp_state,
&entry_map,
&non_selectable_flags,
ctx.sp_entries.as_deref(),
ctx.sp_content_results.as_deref(),
) {
return InputOutcome::Action(match selection {
SessionPickerWorktreeSelection::Fuzzy(original_index) => {
Action::PickSessionInWorktree(original_index)
}
SessionPickerWorktreeSelection::Content { session_id, cwd } => {
Action::PickContentSessionInWorktree { session_id, cwd }
}
SessionPickerWorktreeSelection::Unavailable => {
return InputOutcome::Changed;
}
});
}
}
let outcome = handle_picker_input(ev, ctx.sp_state, entry_count, &config);
match outcome {
PickerOutcome::Selected(i) => match entry_map.get(i).and_then(|e| e.as_ref()) {
@ -2958,7 +3059,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
None => return InputOutcome::Changed,
},
PickerOutcome::SubmitQuery => {
let query = ctx.sp_state.query.trim().to_string();
let query = ctx.sp_state.query().trim().to_string();
if !query.is_empty() {
return InputOutcome::Action(Action::LoadSession(query, None, false));
}
@ -3034,54 +3135,27 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
}
return InputOutcome::Changed;
}
PickerOutcome::Changed => {
if ctx.sp_state.query != query_before {
return InputOutcome::Action(Action::TriggerDeepSearch);
}
return InputOutcome::Changed;
PickerOutcome::QueryChanged => {
sync_session_picker_query_expansion(
ctx.sp_entries.as_deref(),
ctx.sp_content_results.as_deref(),
ctx.sp_entries_query.as_deref(),
ctx.sp_state,
ctx.session_picker_grouped,
ctx.sp_content_loading,
source_filter,
Some(current_repo.as_str()),
);
return InputOutcome::Action(Action::TriggerDeepSearch);
}
PickerOutcome::Changed => return InputOutcome::Changed,
PickerOutcome::Unchanged => {
if let Event::Key(key) = ev
&& key.kind == KeyEventKind::Press
{
if key!('w', CONTROL).matches(key) && entry_count > 0 {
match entry_map
.get(ctx.sp_state.selected)
.and_then(|e| e.as_ref())
{
Some(PickerItem::Fuzzy { original_index }) => {
if let Some(entries) = ctx.sp_entries.as_ref()
&& let Some(entry) = entries.get(*original_index)
&& !crate::app::foreign_sessions::is_foreign_picker_source(
&entry.source,
)
{
return InputOutcome::Action(Action::PickSessionInWorktree(
*original_index,
));
}
}
Some(PickerItem::Content { hit_index }) => {
if let Some(hits) = ctx.sp_content_results.as_ref()
&& let Some(hit) = hits.get(*hit_index)
{
return InputOutcome::Action(
Action::PickContentSessionInWorktree {
session_id: hit.session_id.clone(),
cwd: hit.cwd.clone(),
},
);
}
}
None => {}
}
}
if key!('/', CONTROL).matches(key) && !ctx.sp_state.query.trim().is_empty() {
if key!('/', CONTROL).matches(key) && !ctx.sp_state.query().trim().is_empty() {
return InputOutcome::Action(Action::ForceDeepSearch);
}
if key!('c', CONTROL).matches(key) || key!('d', CONTROL).matches(key) {
return InputOutcome::Action(Action::Quit);
}
}
return InputOutcome::Unchanged;
}
@ -3230,20 +3304,34 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
return InputOutcome::Action(Action::QuitConfirmed);
}
if key!(Enter).matches(key) {
let trimmed = ctx.auth_code_input.trim().to_string();
let trimmed = ctx.auth_code_input.text().trim().to_string();
if !trimmed.is_empty() {
return InputOutcome::Action(Action::SubmitAuthCode(trimmed));
}
return InputOutcome::Unchanged;
}
if key!(Backspace).matches(key) {
ctx.auth_code_input.pop();
let outcome = if crate::input::key::is_paste_key(key) {
let Some(text) = crate::clipboard::system_clipboard_get() else {
return InputOutcome::Unchanged;
};
ctx.auth_code_input.insert_paste(&text)
} else if key.modifiers.intersects(
crossterm::event::KeyModifiers::CONTROL
| crossterm::event::KeyModifiers::ALT
| crossterm::event::KeyModifiers::SUPER,
) && !crate::input::key::is_altgr(key.modifiers)
{
return InputOutcome::Changed;
}
if let crossterm::event::KeyCode::Char(c) = key.code {
ctx.auth_code_input.push(c);
return InputOutcome::Changed;
}
} else {
ctx.auth_code_input
.handle_key_with_insert_policy(key, |character| !character.is_control())
};
return match outcome {
LineEditOutcome::TextChanged
| LineEditOutcome::CursorChanged
| LineEditOutcome::HandledNoChange => InputOutcome::Changed,
LineEditOutcome::Unhandled => InputOutcome::Unchanged,
};
}
AuthState::Authenticating { .. } => {
if key!(Esc).matches(key)
@ -3270,8 +3358,7 @@ fn handle_welcome_input(ev: &Event, ctx: &mut WelcomeInputCtx<'_>) -> InputOutco
mode: AuthMode::Loopback,
..
} => {
let cleaned: String = text.chars().filter(|c| *c != '\n' && *c != '\r').collect();
ctx.auth_code_input.push_str(&cleaned);
let _ = ctx.auth_code_input.insert_paste(text);
return InputOutcome::Changed;
}
_ => {}
@ -3877,8 +3964,9 @@ impl AppView {
auth_state: &self.auth_state,
trust_state: &self.trust_state,
login_label: self.login_label.as_deref(),
auth_code_input: &self.auth_code_input,
clipboard_copied: self.auth_clipboard_copied,
auth_code_input: self.auth_code_input.text(),
auth_code_cursor_byte: self.auth_code_input.cursor_byte(),
clipboard_delivery: self.auth_clipboard_delivery,
show_raw_url: self.auth_show_raw_url,
announcement: hero_announcement,
tip,
@ -5105,10 +5193,12 @@ pub(crate) mod tests {
use ratatui::{TerminalOptions, Viewport};
test_support::install_counting_hook();
crate::memory_release::run_deferred_release();
let (frame_tx, _frame_rx) = std::sync::mpsc::channel::<Vec<u8>>();
let backend = ratatui::backend::CrosstermBackend::new(
crate::render::draw::TermWriter::new(frame_tx, crate::render::draw::WriterSync::new()),
);
let (frame_tx, _frame_rx) =
std::sync::mpsc::channel::<crate::render::draw::WriterPayload>();
let writer =
crate::render::draw::TermWriter::new(frame_tx, crate::render::draw::WriterSync::new())
.expect("single test writer");
let backend = ratatui::backend::CrosstermBackend::new(writer);
let mut terminal = xai_ratatui_inline::Terminal::with_options(
backend,
TerminalOptions {
@ -5201,17 +5291,18 @@ pub(crate) mod tests {
login_label: None,
login_method_id: None,
auth_start_mode: AuthMode::Pending,
auth_code_input: String::new(),
auth_code_input: LineEditor::default(),
next_auth_request_seq: 1,
auth_url_poll_handle: None,
deferred_startup: Default::default(),
auth_use_oauth: false,
auth_clipboard_copied: false,
auth_clipboard_delivery: None,
auth_clipboard_feedback_generation: 0,
team_id: None,
team_name: None,
is_zdr: false,
team_role: None,
coding_data_retention_opt_out: false,
coding_data_retention_opt_out: true,
show_tips: None,
auto_update: None,
ask_user_question_timeout_enabled: None,
@ -5310,6 +5401,7 @@ pub(crate) mod tests {
cancel_rewind_enabled: true,
session_recap_available: false,
dashboard: None,
dashboard_return: None,
dashboard_persisted: None,
keyboard_normalizer: KeyboardNormalizer::from_terminal_context(),
voice_mode_enabled: false,
@ -5399,8 +5491,9 @@ pub(crate) mod tests {
primary.active_view = ActiveView::AgentDashboard;
primary.dashboard = Some(crate::views::dashboard::DashboardState::new());
crate::clipboard::set_clipboard_probe_hook(clipboard_hook());
let outcome = primary.handle_input_with_paste_provenance(
let outcome = primary.handle_input_at_with_paste_provenance(
&Event::Paste(PRIMARY.to_owned()),
Instant::now(),
PasteProvenance::X11Primary,
);
let probe_calls = crate::clipboard::clipboard_probe_call_count();
@ -6856,6 +6949,77 @@ pub(crate) mod tests {
"full TUI must keep the queue-pane toggle, got {out:?}"
);
}
fn welcome_session_entry(id: &str) -> SessionPickerEntry {
SessionPickerEntry {
id: id.into(),
summary: id.into(),
updated_at: chrono::Utc::now(),
created_at: chrono::Utc::now(),
cwd: "/tmp/repo".into(),
hostname: None,
source: "local".into(),
model_id: None,
num_messages: 0,
last_active_at: None,
branch: None,
repo_name: "tmp-repo".into(),
worktree_label: None,
card_detail: None,
}
}
fn open_welcome_session_picker(app: &mut AppView) {
crate::appearance::cache::set_vim_mode(false);
app.session_picker_entries = Some(vec![welcome_session_entry("session-0")]);
app.session_picker_state.search_active = true;
}
#[test]
fn welcome_session_picker_ctrl_w_resumes_in_worktree_while_search_is_focused() {
let mut app = test_app();
open_welcome_session_picker(&mut app);
app.session_picker_state.set_query("session");
let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::CONTROL));
assert!(matches!(
outcome,
InputOutcome::Action(Action::PickSessionInWorktree(0))
));
assert_eq!(app.session_picker_state.query(), "session");
}
#[test]
fn welcome_session_picker_ctrl_d_keeps_global_quit_precedence() {
let mut app = test_app();
open_welcome_session_picker(&mut app);
app.session_picker_state.set_query("session");
let outcome = app.handle_input(&ctrl_d());
assert!(matches!(outcome, InputOutcome::Changed));
assert!(matches!(
app.pending_action.as_ref().map(|pending| &pending.action),
Some(Action::Quit)
));
assert_eq!(app.session_picker_state.query(), "session");
}
#[test]
fn welcome_session_picker_cursor_motion_does_not_trigger_deep_search() {
let mut app = test_app();
open_welcome_session_picker(&mut app);
app.session_picker_state.set_query("session");
let outcome = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.session_picker_state.query(), "session");
}
#[test]
fn welcome_session_picker_ctrl_u_kills_to_cursor_and_triggers_deep_search() {
let mut app = test_app();
open_welcome_session_picker(&mut app);
app.session_picker_state.set_query("session");
let _ = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE));
let outcome = app.handle_input(&key_event(KeyCode::Char('u'), KeyModifiers::CONTROL));
assert!(matches!(
outcome,
InputOutcome::Action(Action::TriggerDeepSearch)
));
assert_eq!(app.session_picker_state.query(), "n");
assert_eq!(app.session_picker_state.query_cursor(), 0);
}
#[test]
fn welcome_ctrl_w_opens_new_worktree_dialog() {
let mut app = test_app();
@ -8238,6 +8402,18 @@ pub(crate) mod tests {
assert!(app.new_worktree_dialog.is_none());
}
#[test]
fn worktree_dialog_modified_enter_is_ignored() {
let mut app = test_app();
app.auth_state = AuthState::Done;
app.new_worktree_dialog = Some(NewWorktreeDialogState::new());
let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::CONTROL));
assert!(matches!(outcome, InputOutcome::Unchanged));
assert!(app.new_worktree_dialog.is_some());
let outcome = app.handle_input(&key_event(KeyCode::Char('w'), KeyModifiers::SHIFT));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "W");
}
#[test]
fn worktree_dialog_enter_threads_label() {
let mut app = test_app();
app.auth_state = AuthState::Done;
@ -8269,21 +8445,56 @@ pub(crate) mod tests {
app.new_worktree_dialog = Some(NewWorktreeDialogState::new());
let outcome = app.handle_input(&key_event(KeyCode::Char('h'), KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label_input, "h");
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "h");
let outcome = app.handle_input(&key_event(KeyCode::Char('i'), KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label_input, "hi");
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "hi");
}
#[test]
fn worktree_dialog_backspace_removes_char() {
let mut app = test_app();
app.auth_state = AuthState::Done;
app.new_worktree_dialog = Some(NewWorktreeDialogState {
label_input: "test".into(),
});
let mut dialog = NewWorktreeDialogState::new();
dialog.set_label("test");
app.new_worktree_dialog = Some(dialog);
let outcome = app.handle_input(&key_event(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label_input, "tes");
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "tes");
}
#[test]
fn worktree_dialog_enforces_byte_cap_for_typing_and_middle_paste() {
let mut app = test_app();
app.auth_state = AuthState::Done;
let mut dialog = NewWorktreeDialogState::new();
dialog.set_label("a".repeat(98));
let _ = dialog.set_cursor_byte(1);
app.new_worktree_dialog = Some(dialog);
let outcome = app.handle_input(&Event::Paste("éx".to_owned()));
assert!(matches!(outcome, InputOutcome::Changed));
let dialog = app.new_worktree_dialog.as_ref().unwrap();
assert_eq!(dialog.label().len(), 100);
assert_eq!(&dialog.label()[1.."".len()], "é");
let outcome = app.handle_input(&key_event(KeyCode::Char('中'), KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label().len(), 100);
let mut dialog = NewWorktreeDialogState::new();
dialog.set_label("a".repeat(99));
app.new_worktree_dialog = Some(dialog);
let _ = app.handle_input(&key_event(KeyCode::Char('é'), KeyModifiers::NONE));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label().len(), 99);
}
#[test]
fn worktree_dialog_paste_is_scoped_away_from_welcome_prompt() {
let mut app = test_app();
app.auth_state = AuthState::Done;
let mut dialog = NewWorktreeDialogState::new();
dialog.set_label("ab");
let _ = dialog.set_cursor_byte(1);
app.new_worktree_dialog = Some(dialog);
let outcome = app.handle_input(&Event::Paste("".to_owned()));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.new_worktree_dialog.as_ref().unwrap().label(), "a中b");
assert!(app.welcome_prompt.text().is_empty());
}
#[test]
fn authenticating_loopback_esc_quits() {
@ -8325,7 +8536,7 @@ pub(crate) mod tests {
matches!(outcome, InputOutcome::Changed),
"typing 'q' must edit the auth code input, got {outcome:?}"
);
assert_eq!(app.auth_code_input, "q");
assert_eq!(app.auth_code_input.text(), "q");
}
/// Users reflex-type the displayed device code; bare 'q' must not abort.
#[test]
@ -8380,7 +8591,40 @@ pub(crate) mod tests {
};
let outcome = app.handle_input(&key_event(KeyCode::Char('a'), KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input, "a");
assert_eq!(app.auth_code_input.text(), "a");
}
#[test]
fn authenticating_loopback_readline_control_chords_are_ignored() {
for code in [KeyCode::Char('u'), KeyCode::Char('d')] {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Loopback,
};
app.auth_code_input.set_text("token");
let outcome = app.handle_input(&key_event(code, KeyModifiers::CONTROL));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input.text(), "token");
}
}
#[cfg(target_os = "windows")]
#[test]
fn authenticating_loopback_altgr_char_mutates_input() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Loopback,
};
let outcome = app.handle_input(&key_event(
KeyCode::Char('@'),
KeyModifiers::CONTROL | KeyModifiers::ALT,
));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input.text(), "@");
}
#[test]
fn authenticating_loopback_backspace_removes_char() {
@ -8391,10 +8635,10 @@ pub(crate) mod tests {
auth_url: None,
mode: AuthMode::Loopback,
};
app.auth_code_input = "ab".to_string();
app.auth_code_input.set_text("ab");
let outcome = app.handle_input(&key_event(KeyCode::Backspace, KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input, "a");
assert_eq!(app.auth_code_input.text(), "a");
}
#[test]
fn authenticating_loopback_paste_appends_text() {
@ -8405,10 +8649,45 @@ pub(crate) mod tests {
auth_url: None,
mode: AuthMode::Loopback,
};
app.auth_code_input = "tok".to_string();
app.auth_code_input.set_text("tok");
let outcome = app.handle_input(&Event::Paste("en_value".to_string()));
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input, "token_value");
assert_eq!(app.auth_code_input.text(), "token_value");
}
#[test]
fn authenticating_loopback_cursor_edit_and_paste_stay_scoped() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Loopback,
};
app.auth_code_input.set_text("ab");
let _ = app.handle_input(&key_event(KeyCode::Left, KeyModifiers::NONE));
let _ = app.handle_input(&Event::Paste("\r\n".to_owned()));
assert_eq!(app.auth_code_input.text(), "a中b");
assert!(app.welcome_prompt.text().is_empty());
let _ = app.handle_input(&key_event(KeyCode::Delete, KeyModifiers::NONE));
assert_eq!(app.auth_code_input.text(), "a中");
}
#[test]
fn authenticating_loopback_uses_canonical_super_v_paste() {
let mut app = test_app();
app.auth_state = AuthState::Authenticating {
request_seq: 1,
handle: None,
auth_url: None,
mode: AuthMode::Loopback,
};
crate::clipboard::set_clipboard_probe_hook(
crate::clipboard::ClipboardProbeHook::no_raster(Some("secret\r\n")),
);
let outcome = app.handle_input(&key_event(KeyCode::Char('v'), KeyModifiers::SUPER));
crate::clipboard::clear_clipboard_probe_hook();
assert!(matches!(outcome, InputOutcome::Changed));
assert_eq!(app.auth_code_input.text(), "secret");
assert!(app.welcome_prompt.text().is_empty());
}
#[test]
fn authenticating_loopback_enter_empty_is_noop() {
@ -8419,7 +8698,7 @@ pub(crate) mod tests {
auth_url: None,
mode: AuthMode::Loopback,
};
app.auth_code_input = " ".to_string();
app.auth_code_input.set_text(" ");
let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE));
assert!(matches!(outcome, InputOutcome::Unchanged));
}
@ -8432,7 +8711,7 @@ pub(crate) mod tests {
auth_url: None,
mode: AuthMode::Loopback,
};
app.auth_code_input = " token123 ".to_string();
app.auth_code_input.set_text(" token123 ");
let outcome = app.handle_input(&key_event(KeyCode::Enter, KeyModifiers::NONE));
match outcome {
InputOutcome::Action(Action::SubmitAuthCode(code)) => {
@ -10433,7 +10712,8 @@ pub(crate) mod tests {
"f must not cycle the hidden source filter under chat mode"
);
assert_eq!(
app.session_picker_state.query, "f",
app.session_picker_state.query(),
"f",
"under chat mode `f` keeps its normal typing/search meaning"
);
app.session_picker_state.reset();