Skip to content

Commit 4fcfb6e

Browse files
jonasnobileclaude
authored andcommitted
feat(keybindings): start the first terminal from the keyboard
cmd-t is bound under the TerminalPane context, so a project with no terminals — which has no pane to dispatch through — could only be started by clicking the empty state's "Start Terminal" button. Reaching such a project by keyboard therefore led nowhere. Bind AddTab globally as well and handle it at the window level, but only when the focused project has no layout: a focused pane always takes the action first, and the guard means nothing changes for a project that already has terminals. Modal context bails out too, so an overlay that owns the keyboard while leaving a project focused cannot spawn terminals behind itself. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Ud5F7z9cXJtzSjaJVRhzeN
1 parent 8f6d36d commit 4fcfb6e

2 files changed

Lines changed: 47 additions & 6 deletions

File tree

crates/okena-app/src/keybindings/config.rs

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -171,11 +171,17 @@ impl KeybindingConfig {
171171
KeybindingEntry::new("ctrl-d", Some("TerminalPane")),
172172
],
173173
);
174+
// Also bound globally: a project with no terminals has no TerminalPane
175+
// to dispatch through, so the pane-scoped binding alone leaves "Start
176+
// Terminal" reachable only by mouse. The window-level handler only acts
177+
// when there is genuinely no pane to take it (see `render.rs`).
174178
bindings.insert(
175179
"AddTab".to_string(),
176180
vec![
177181
KeybindingEntry::new("cmd-t", Some("TerminalPane")),
178182
KeybindingEntry::new("ctrl-shift-t", Some("TerminalPane")),
183+
KeybindingEntry::new("cmd-t", None),
184+
KeybindingEntry::new("ctrl-shift-t", None),
179185
],
180186
);
181187
bindings.insert(

crates/okena-app/src/views/window/render.rs

Lines changed: 41 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,10 @@
11
use crate::keybindings::{
2-
CheckForUpdates, ClearFocus, CloseWindow, CreateWorktree, EqualizeLayout, FocusActiveProject,
3-
FocusSidebar, InstallUpdate, NewProject, NewWindow, OpenSettingsFile, RestartDaemon,
4-
ReviewChanges, ShowBranchSwitcher, ShowCommandPalette, ShowContentSearch, ShowDiffViewer,
5-
ShowFileSearch, ShowHookLog, ShowKeybindings, ShowLogConsole, ShowPairingDialog,
6-
ShowProfileManager, ShowProjectSwitcher, ShowSessionManager, ShowSettings, ShowThemeSelector,
7-
StartAllServices, StopAllServices, TogglePaneSwitcher, ToggleProjectLayout,
2+
AddTab, CheckForUpdates, ClearFocus, CloseWindow, CreateWorktree, EqualizeLayout,
3+
FocusActiveProject, FocusSidebar, InstallUpdate, NewProject, NewWindow, OpenSettingsFile,
4+
RestartDaemon, ReviewChanges, ShowBranchSwitcher, ShowCommandPalette, ShowContentSearch,
5+
ShowDiffViewer, ShowFileSearch, ShowHookLog, ShowKeybindings, ShowLogConsole,
6+
ShowPairingDialog, ShowProfileManager, ShowProjectSwitcher, ShowSessionManager, ShowSettings,
7+
ShowThemeSelector, StartAllServices, StopAllServices, TogglePaneSwitcher, ToggleProjectLayout,
88
ToggleProjectVisibility, ToggleSidebar, ToggleSidebarAutoHide,
99
};
1010
use crate::settings::{open_settings_file, settings_entity};
@@ -888,6 +888,41 @@ impl Render for WindowView {
888888
ws.toggle_project_layout_mode(window_id, cx);
889889
});
890890
}))
891+
// Start the first terminal in a project that has none — the
892+
// keyboard equivalent of the empty state's "Start Terminal" button.
893+
//
894+
// `AddTab` is normally handled by the focused TerminalPane; this
895+
// only runs when the focused project has no pane to handle it,
896+
// which is exactly the case the pane-scoped binding cannot reach. A
897+
// project WITH a layout falls through untouched, so an open modal
898+
// (which owns the keyboard while leaving a project focused) can't
899+
// spawn terminals behind itself either.
900+
.on_action(cx.listener(|this, _: &AddTab, _window, cx| {
901+
if this.focus_manager.read(cx).is_modal() {
902+
return;
903+
}
904+
let project_id = {
905+
let fm = this.focus_manager.read(cx);
906+
fm.focused_terminal_state()
907+
.map(|state| state.project_id)
908+
.or_else(|| fm.focused_project_id().map(String::from))
909+
};
910+
let Some(project_id) = project_id else { return };
911+
if this
912+
.workspace
913+
.read(cx)
914+
.project(&project_id)
915+
.is_none_or(|p| p.layout.is_some())
916+
{
917+
return;
918+
}
919+
if let Some(dispatcher) = this.dispatcher_for_project(&project_id, cx) {
920+
dispatcher.dispatch(
921+
okena_core::api::ActionRequest::CreateTerminal { project_id },
922+
cx,
923+
);
924+
}
925+
}))
891926
// Hide the active project from this window's overview — the
892927
// keyboard route to the eye toggle in the project header. Scoped to
893928
// this window, like every other visibility toggle. Resolving the

0 commit comments

Comments
 (0)