Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions crates/ui/src/composer/components/pickers.rs
Original file line number Diff line number Diff line change
Expand Up @@ -231,6 +231,7 @@ impl Composer {
);

crate::material::overlay_popover(("model-picker-popover", self.model_picker_token))
.track_focus(&self.model_search.read(cx).focus_handle(cx))
.anchor(Anchor::BottomLeft)
.when(self.compact, |popover| {
popover.bottom_sheet(crate::tr!("mobile.model"))
Expand Down Expand Up @@ -729,6 +730,7 @@ fn render_model_pane(
let popover_key = popover.clone();

let pane = h_flex()
.key_context("ModelPicker")
.when(!composer.read(cx).compact, |pane| pane.w(px(360.)))
.when(composer.read(cx).compact, |pane| pane.w_full())
.h(px(360.))
Expand Down
1 change: 1 addition & 0 deletions crates/ui/src/run.rs
Original file line number Diff line number Diff line change
Expand Up @@ -127,6 +127,7 @@ pub fn run_shell(
.expect("failed to register bundled application fonts");
theme::init_with_json(&options.theme_json, cx);
crate::markdown::init(cx);
crate::shortcut::init(cx);
// Global ⌘K / Ctrl-K opens/closes the command palette (handled by
// AppShell). `secondary` is gpui's platform modifier: command on macOS,
// control on Windows/Linux — where a literal `cmd-` binding would mean the
Expand Down
140 changes: 140 additions & 0 deletions crates/ui/src/shell.rs
Original file line number Diff line number Diff line change
Expand Up @@ -995,6 +995,25 @@ fn current_shell(cx: &App) -> Option<Entity<AppShell>> {
cx.try_global::<ShellBackTarget>()?.shell.upgrade()
}

/// App-level dispatch also reaches the shell when no child has keyboard focus.
pub(crate) fn navigate_thread(action: &crate::shortcut::NavigateThread, cx: &mut App) {
let Some(shell) = current_shell(cx) else {
return;
};
shell.update(cx, |shell, cx| {
if let Some(attachment) = &shell.attachment
&& attachment
.sidebar
.update(cx, |sidebar, cx| sidebar.navigate_thread(action, cx))
{
shell.window_state.update(cx, |state, cx| {
state.close_palette(cx);
state.go(Destination::Thread, cx);
});
}
});
}

/// Point this window at another host.
pub(crate) fn switch_current(target: AttachmentTarget, window: &mut Window, cx: &mut App) {
let Some(shell) = current_shell(cx) else {
Expand Down Expand Up @@ -3626,6 +3645,127 @@ mod tests {
shell.read_with(cx, |shell, _| shell.store().expect("attached"))
}

#[gpui::test]
fn thread_shortcuts_follow_list_order_and_leave_model_picker_numbers_alone(
cx: &mut TestAppContext,
) {
cx.update(crate::theme::init);
cx.update(crate::shortcut::init);
let root = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR"))
.join("../../tmp")
.join(format!(
"thread-shortcuts-{}",
tcode_services::store::now_millis()
));
let disk = tcode_services::store::SessionStore::open_at(root.clone()).unwrap();
let project = tcode_core::project::Project::from_root(root.join("project"));
let sessions = [("third", 10), ("first", 30), ("second", 20)]
.into_iter()
.map(|(id, updated_at)| {
let mut meta = tcode_core::project::SessionMeta::new(
agent::ProviderKind::Codex,
project.root.clone(),
None,
);
meta.id = id.into();
meta.project_id = Some(project.id.clone());
meta.updated_at = updated_at;
meta
})
.collect();
let host =
tcode_runtime::pipe::spawn_host(disk, tcode_runtime::pipe::HostServices::default())
.unwrap();
smol::block_on(host.update_state_for_test(move |state, _| {
state.projects = vec![project];
state.sessions = sessions;
state.settings.auto_archive_disabled = true;
}))
.unwrap();
let (shell, _transport, cx) = mount(cx);
cx.update(|window, cx| set_back_target(window.window_handle(), &shell, cx));
let store = store_of(&shell, cx);
store.update(cx, |store, cx| {
*store = WorkspaceStore::new(host.link(), cx);
store.select_session("first".into());
});
await_restore_update(&shell, cx, |store| !store.chat_loading());
resize(cx, 1200.);
draw(cx);

let [first_key, second_key, out_of_range_key] = if cfg!(target_os = "macos") {
["cmd-1", "cmd-2", "cmd-9"]
} else {
["ctrl-1", "ctrl-2", "ctrl-9"]
};
let picker = cx.debug_bounds("model-picker").expect("model picker");
cx.simulate_click(picker.center(), gpui::Modifiers::default());
draw(cx);
cx.simulate_keystrokes(second_key);
draw(cx);
assert_eq!(
store
.read_with(cx, |store, _| store.active_session_id())
.as_deref(),
Some("first"),
"model picker must keep number keys"
);
cx.simulate_keystrokes("escape");
draw(cx);

for (keys, expected) in [
(second_key, "second"),
("ctrl-tab", "third"),
("ctrl-tab", "first"),
("ctrl-shift-tab", "third"),
(out_of_range_key, "third"),
(first_key, "first"),
] {
cx.simulate_keystrokes(keys);
await_restore_update(&shell, cx, |store| !store.chat_loading());
assert_eq!(
store
.read_with(cx, |store, _| store.active_session_id())
.as_deref(),
Some(expected),
"{keys}"
);
}
assert_eq!(
store.read_with(cx, |store, _| store.composer_state().interaction_mode),
agent::InteractionMode::Build,
"Ctrl+Shift+Tab must not toggle the composer's mode"
);
cx.update(|window, cx| window.blur(cx));
draw(cx);
cx.simulate_keystrokes(second_key);
assert_eq!(
store
.read_with(cx, |store, _| store.active_session_id())
.as_deref(),
Some("second"),
"shortcuts work without a focused input"
);
shell.update(cx, |shell, cx| shell.go(Destination::Settings, cx));
draw(cx);
cx.simulate_keystrokes(first_key);
assert_eq!(
shell.read_with(cx, |shell, cx| shell.destination(cx)),
Destination::Thread
);
resize(cx, 393.);
cx.simulate_keystrokes("ctrl-tab");
assert_eq!(
store
.read_with(cx, |store, _| store.active_session_id())
.as_deref(),
Some("second"),
"compact navigation uses the same shortcuts"
);
host.shutdown_blocking().unwrap();
std::fs::remove_dir_all(root).unwrap();
}

/// Resizing a window is a layout decision and nothing else: it must not
/// touch the attachment, the selected thread, the draft or the split.
#[gpui::test]
Expand Down
26 changes: 25 additions & 1 deletion crates/ui/src/shortcut.rs
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
use crate::widgets::kbd::Kbd;
use gpui::{Keystroke, Modifiers};
use gpui::{Action, App, KeyBinding, Keystroke, Modifiers, NoAction};
use serde::Deserialize;

#[derive(Action, Clone, PartialEq, Eq, Deserialize)]
#[action(namespace = tcode, no_json)]
pub(crate) enum NavigateThread {
Index(usize),
Next,
Previous,
}

pub(crate) fn init(cx: &mut App) {
cx.on_action(crate::shell::navigate_thread);
for number in 1..=9 {
let key = format!("secondary-{number}");
cx.bind_keys([
KeyBinding::new(&key, NavigateThread::Index(number - 1), None),
KeyBinding::new(&key, NoAction, Some("ModelPicker || ModelPicker > Input")),
]);
}
cx.bind_keys([
KeyBinding::new("ctrl-tab", NavigateThread::Next, None),
KeyBinding::new("ctrl-shift-tab", NavigateThread::Previous, None),
]);
}

/// Format a shortcut using GPUI's semantic secondary modifier.
///
Expand Down
Loading