detached ed638e0

feat(gui): add workspace-aware focus management and pane controls

commit
ed638e0087f738a29823ff98d13bf9b3ddedb386
author
bamen · · 2025-09-19 03:42 UTC
parent
6260495
signature
unsigned
4 files · +501 -68
M gui/src/injected/gui.ts +112 -1
@@ -8,7 +8,8 @@
88 // Import all GUI modules
99 import { initializeToast, type ToastConfig } from './modules/toast';
1010 import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox';
11-import { keyboardService, type KeyboardService } from '../lib/keyboard';
11+import { initializeWorkspace, type WorkspaceConfigPayload, type WorkspaceRuntime } from './modules/workspace';
12+import { keyboardService, type KeyboardService } from '$lib/keyboard';
1213 // Note: color-scheme module is loaded separately at document-start by WebKit
1314
1415 // Global interface for the unified GUI system
@@ -45,6 +46,8 @@ declare global {
4546 __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;
4647 __dumber_showZoomToast?: (level: number) => void;
4748 __dumber_keyboard?: KeyboardService;
49+ __dumber_workspace_config?: WorkspaceConfigPayload;
50+ __dumber_workspace?: WorkspaceRuntime;
4851 // Legacy compatibility functions for Go bridge
4952 __dumber_toggle?: () => void;
5053 __dumber_find_open?: (query?: string) => void;
@@ -61,6 +64,43 @@ if (!window.__dumber_gui_ready) {
6164 } else {
6265 window.__dumber_gui_ready = true;
6366
67+ let workspaceHasFocus = document.hasFocus();
68+ let currentPaneId = window.__dumber_pane?.id || 'unknown';
69+
70+ document.addEventListener('dumber:workspace-focus', (event: Event) => {
71+ const detail = (event as CustomEvent).detail || {};
72+ console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus);
73+
74+ // Update focus state
75+ if (typeof detail?.active === 'boolean') {
76+ workspaceHasFocus = detail.active;
77+ } else {
78+ workspaceHasFocus = detail !== false;
79+ }
80+
81+ // Update pane tracking
82+ if (detail?.paneId) {
83+ currentPaneId = detail.paneId;
84+ }
85+
86+ // Update global pane state
87+ if (window.__dumber_pane) {
88+ window.__dumber_pane.active = workspaceHasFocus;
89+ }
90+
91+ console.log('[workspace focus updated] focus=', workspaceHasFocus, 'paneId=', currentPaneId);
92+
93+ // Handle GUI visibility based on focus
94+ if (window.__dumber_omnibox) {
95+ if (workspaceHasFocus) {
96+ window.__dumber_omnibox.setActive?.(true);
97+ } else {
98+ window.__dumber_omnibox.setActive?.(false);
99+ window.__dumber_omnibox.close?.();
100+ }
101+ }
102+ });
103+
64104 // Initialize Svelte GUI systems immediately
65105 whenDOMReady(async () => {
66106 try {
@@ -77,6 +117,9 @@ if (!window.__dumber_gui_ready) {
77117 // Initialize omnibox system
78118 await initializeOmnibox();
79119
120+ // Initialize workspace controls (pane/tab scaffolding)
121+ initializeWorkspace(window.__dumber_workspace_config);
122+
80123 console.log('✅ All GUI systems initialized successfully');
81124 } catch (e) {
82125 console.error('❌ Failed to initialize Svelte toast system:', e);
@@ -167,6 +210,55 @@ if (!window.__dumber_gui_ready) {
167210 }
168211 });
169212
213+ document.addEventListener('dumber:ui:shortcut', (e: Event) => {
214+ const detail = (e as CustomEvent).detail || {};
215+ const action = detail?.action;
216+ const eventPaneId = detail?.paneId;
217+ const source = detail?.source || 'unknown';
218+
219+ if (typeof action !== 'string') {
220+ return;
221+ }
222+
223+ // Enhanced focus checking with pane ID validation
224+ const isForThisPane = !eventPaneId || eventPaneId === currentPaneId;
225+ const shouldHandle = workspaceHasFocus && isForThisPane;
226+
227+ if (!shouldHandle) {
228+ console.log('[dumber shortcuts] ignored action', {
229+ action,
230+ workspaceHasFocus,
231+ isForThisPane,
232+ eventPaneId,
233+ currentPaneId,
234+ source,
235+ detail
236+ });
237+ return;
238+ }
239+
240+ console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId });
241+
242+ const omnibox = window.__dumber_omnibox;
243+
244+ switch (action) {
245+ case 'omnibox-toggle':
246+ omnibox?.toggle?.();
247+ break;
248+ case 'omnibox-open':
249+ omnibox?.open?.('omnibox', detail?.query);
250+ break;
251+ case 'omnibox-find':
252+ omnibox?.open?.('find', detail?.query);
253+ break;
254+ case 'omnibox-close':
255+ omnibox?.close?.();
256+ break;
257+ default:
258+ console.warn('[dumber] Unknown UI shortcut action:', action);
259+ }
260+ });
261+
170262 // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed
171263 });
172264
@@ -232,5 +324,24 @@ if (!window.__dumber_gui_ready) {
232324 isReady: true
233325 };
234326
327+ // Bootstrap function for lazy GUI initialization by workspace manager
328+ window.__dumber_gui_bootstrap = function(paneId: string) {
329+ console.log(`[gui-bootstrap] Initializing GUI for pane ${paneId}`);
330+
331+ // Update current pane ID
332+ currentPaneId = paneId;
333+
334+ // Ensure omnibox is available for this pane
335+ if (!window.__dumber_omnibox) {
336+ console.log(`[gui-bootstrap] Omnibox not yet available for pane ${paneId}, will be loaded when needed`);
337+ } else {
338+ console.log(`[gui-bootstrap] Omnibox already available for pane ${paneId}`);
339+ // Ensure omnibox is aware of the current pane
340+ window.__dumber_omnibox.setActive?.(workspaceHasFocus);
341+ }
342+
343+ console.log(`[gui-bootstrap] GUI bootstrap complete for pane ${paneId}`);
344+ };
345+
235346 }
236347 }
@@ -8,7 +8,8 @@
8 // Import all GUI modules8 // Import all GUI modules
9 import { initializeToast, type ToastConfig } from './modules/toast';9 import { initializeToast, type ToastConfig } from './modules/toast';
10 import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox';10 import { initializeOmnibox, type OmniboxInitConfig } from './modules/omnibox';
11-import { keyboardService, type KeyboardService } from '../lib/keyboard';11+import { initializeWorkspace, type WorkspaceConfigPayload, type WorkspaceRuntime } from './modules/workspace';
12+import { keyboardService, type KeyboardService } from '$lib/keyboard';
12 // Note: color-scheme module is loaded separately at document-start by WebKit13 // Note: color-scheme module is loaded separately at document-start by WebKit
13 14
14 // Global interface for the unified GUI system15 // Global interface for the unified GUI system
@@ -45,6 +46,8 @@ declare global {
45 __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;46 __dumber_showToast?: (message: string, duration?: number, type?: 'info' | 'success' | 'error') => number | void;
46 __dumber_showZoomToast?: (level: number) => void;47 __dumber_showZoomToast?: (level: number) => void;
47 __dumber_keyboard?: KeyboardService;48 __dumber_keyboard?: KeyboardService;
49+ __dumber_workspace_config?: WorkspaceConfigPayload;
50+ __dumber_workspace?: WorkspaceRuntime;
48 // Legacy compatibility functions for Go bridge51 // Legacy compatibility functions for Go bridge
49 __dumber_toggle?: () => void;52 __dumber_toggle?: () => void;
50 __dumber_find_open?: (query?: string) => void;53 __dumber_find_open?: (query?: string) => void;
@@ -61,6 +64,43 @@ if (!window.__dumber_gui_ready) {
61 } else {64 } else {
62 window.__dumber_gui_ready = true;65 window.__dumber_gui_ready = true;
63 66
67+ let workspaceHasFocus = document.hasFocus();
68+ let currentPaneId = window.__dumber_pane?.id || 'unknown';
69+
70+ document.addEventListener('dumber:workspace-focus', (event: Event) => {
71+ const detail = (event as CustomEvent).detail || {};
72+ console.log('[workspace focus event]', detail, 'prev focus=', workspaceHasFocus);
73+
74+ // Update focus state
75+ if (typeof detail?.active === 'boolean') {
76+ workspaceHasFocus = detail.active;
77+ } else {
78+ workspaceHasFocus = detail !== false;
79+ }
80+
81+ // Update pane tracking
82+ if (detail?.paneId) {
83+ currentPaneId = detail.paneId;
84+ }
85+
86+ // Update global pane state
87+ if (window.__dumber_pane) {
88+ window.__dumber_pane.active = workspaceHasFocus;
89+ }
90+
91+ console.log('[workspace focus updated] focus=', workspaceHasFocus, 'paneId=', currentPaneId);
92+
93+ // Handle GUI visibility based on focus
94+ if (window.__dumber_omnibox) {
95+ if (workspaceHasFocus) {
96+ window.__dumber_omnibox.setActive?.(true);
97+ } else {
98+ window.__dumber_omnibox.setActive?.(false);
99+ window.__dumber_omnibox.close?.();
100+ }
101+ }
102+ });
103+
64 // Initialize Svelte GUI systems immediately104 // Initialize Svelte GUI systems immediately
65 whenDOMReady(async () => {105 whenDOMReady(async () => {
66 try {106 try {
@@ -77,6 +117,9 @@ if (!window.__dumber_gui_ready) {
77 // Initialize omnibox system117 // Initialize omnibox system
78 await initializeOmnibox();118 await initializeOmnibox();
79 119
120+ // Initialize workspace controls (pane/tab scaffolding)
121+ initializeWorkspace(window.__dumber_workspace_config);
122+
80 console.log('✅ All GUI systems initialized successfully');123 console.log('✅ All GUI systems initialized successfully');
81 } catch (e) {124 } catch (e) {
82 console.error('❌ Failed to initialize Svelte toast system:', e);125 console.error('❌ Failed to initialize Svelte toast system:', e);
@@ -167,6 +210,55 @@ if (!window.__dumber_gui_ready) {
167 }210 }
168 });211 });
169 212
213+ document.addEventListener('dumber:ui:shortcut', (e: Event) => {
214+ const detail = (e as CustomEvent).detail || {};
215+ const action = detail?.action;
216+ const eventPaneId = detail?.paneId;
217+ const source = detail?.source || 'unknown';
218+
219+ if (typeof action !== 'string') {
220+ return;
221+ }
222+
223+ // Enhanced focus checking with pane ID validation
224+ const isForThisPane = !eventPaneId || eventPaneId === currentPaneId;
225+ const shouldHandle = workspaceHasFocus && isForThisPane;
226+
227+ if (!shouldHandle) {
228+ console.log('[dumber shortcuts] ignored action', {
229+ action,
230+ workspaceHasFocus,
231+ isForThisPane,
232+ eventPaneId,
233+ currentPaneId,
234+ source,
235+ detail
236+ });
237+ return;
238+ }
239+
240+ console.log('[dumber shortcuts] handling action', { action, source, paneId: currentPaneId });
241+
242+ const omnibox = window.__dumber_omnibox;
243+
244+ switch (action) {
245+ case 'omnibox-toggle':
246+ omnibox?.toggle?.();
247+ break;
248+ case 'omnibox-open':
249+ omnibox?.open?.('omnibox', detail?.query);
250+ break;
251+ case 'omnibox-find':
252+ omnibox?.open?.('find', detail?.query);
253+ break;
254+ case 'omnibox-close':
255+ omnibox?.close?.();
256+ break;
257+ default:
258+ console.warn('[dumber] Unknown UI shortcut action:', action);
259+ }
260+ });
261+
170 // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed262 // Suggestions are now fetched via API directly by the omnibox component; no page-bridge handlers needed
171 });263 });
172 264
@@ -232,5 +324,24 @@ if (!window.__dumber_gui_ready) {
232 isReady: true324 isReady: true
233 };325 };
234 326
327+ // Bootstrap function for lazy GUI initialization by workspace manager
328+ window.__dumber_gui_bootstrap = function(paneId: string) {
329+ console.log(`[gui-bootstrap] Initializing GUI for pane ${paneId}`);
330+
331+ // Update current pane ID
332+ currentPaneId = paneId;
333+
334+ // Ensure omnibox is available for this pane
335+ if (!window.__dumber_omnibox) {
336+ console.log(`[gui-bootstrap] Omnibox not yet available for pane ${paneId}, will be loaded when needed`);
337+ } else {
338+ console.log(`[gui-bootstrap] Omnibox already available for pane ${paneId}`);
339+ // Ensure omnibox is aware of the current pane
340+ window.__dumber_omnibox.setActive?.(workspaceHasFocus);
341+ }
342+
343+ console.log(`[gui-bootstrap] GUI bootstrap complete for pane ${paneId}`);
344+ };
345+
235 }346 }
236 }347 }
M gui/src/injected/modules/color-scheme.ts +3 -3
@@ -9,13 +9,13 @@
99 declare global {
1010 interface Window {
1111 __dumber_gtk_prefers_dark?: boolean;
12- __dumber_setTheme?: (theme: string) => void;
12+ __dumber_setTheme?: (theme: 'light' | 'dark') => void;
1313 }
1414 }
1515
1616 interface ColorSchemeConfig {
1717 isDark: boolean;
18- colorScheme: string;
18+ colorScheme: 'light' | 'dark';
1919 metaContent: string;
2020 rootStyle: string;
2121 }
@@ -70,7 +70,7 @@ class ColorSchemeManager {
7070 value: this.config.colorScheme
7171 })
7272 );
73- } catch (error) {
73+ } catch {
7474 // Ignore errors - native layer may not be available
7575 }
7676 }
@@ -9,13 +9,13 @@
9 declare global {9 declare global {
10 interface Window {10 interface Window {
11 __dumber_gtk_prefers_dark?: boolean;11 __dumber_gtk_prefers_dark?: boolean;
12- __dumber_setTheme?: (theme: string) => void;12+ __dumber_setTheme?: (theme: 'light' | 'dark') => void;
13 }13 }
14 }14 }
15 15
16 interface ColorSchemeConfig {16 interface ColorSchemeConfig {
17 isDark: boolean;17 isDark: boolean;
18- colorScheme: string;18+ colorScheme: 'light' | 'dark';
19 metaContent: string;19 metaContent: string;
20 rootStyle: string;20 rootStyle: string;
21 }21 }
@@ -70,7 +70,7 @@ class ColorSchemeManager {
70 value: this.config.colorScheme70 value: this.config.colorScheme
71 })71 })
72 );72 );
73- } catch (error) {73+ } catch {
74 // Ignore errors - native layer may not be available74 // Ignore errors - native layer may not be available
75 }75 }
76 }76 }
A gui/src/injected/modules/workspace.ts +377 -0
@@ -0,0 +1,377 @@
1+/**
2+ * Workspace Controller Module
3+ *
4+ * Provides the initial scaffolding for Zellij-inspired pane management.
5+ * Currently tracks pane mode state, exposes configuration to the page,
6+ * and wires keyboard service shortcuts based on configuration defaults.
7+ */
8+
9+import { keyboardService } from '$lib/keyboard';
10+
11+const ACTIVATION_COMPONENT_ID = 'workspace-pane-activation';
12+const ACTION_COMPONENT_ID = 'workspace-pane-actions';
13+
14+export interface WorkspaceConfigPayload {
15+ enable_zellij_controls?: boolean;
16+ pane_mode?: {
17+ activation_shortcut?: string;
18+ timeout_ms?: number;
19+ action_bindings?: Record<string, string>;
20+ };
21+ tabs?: {
22+ new_tab?: string;
23+ close_tab?: string;
24+ next_tab?: string;
25+ previous_tab?: string;
26+ };
27+ popups?: {
28+ placement?: string;
29+ open_in_new_pane?: boolean;
30+ follow_pane_context?: boolean;
31+ };
32+}
33+
34+export interface WorkspacePaneModeConfig {
35+ activationShortcut: string;
36+ timeoutMs: number;
37+ actionBindings: Record<string, string>;
38+}
39+
40+export interface WorkspaceTabsConfig {
41+ newTab: string;
42+ closeTab: string;
43+ nextTab: string;
44+ previousTab: string;
45+}
46+
47+export interface WorkspacePopupConfig {
48+ placement: string;
49+ openInNewPane: boolean;
50+ followPaneContext: boolean;
51+}
52+
53+export interface WorkspaceConfigNormalized {
54+ enableZellijControls: boolean;
55+ paneMode: WorkspacePaneModeConfig;
56+ tabs: WorkspaceTabsConfig;
57+ popups: WorkspacePopupConfig;
58+}
59+
60+export interface WorkspaceRuntime {
61+ enterPaneMode: () => void;
62+ exitPaneMode: (reason?: string) => void;
63+ isPaneModeActive: () => boolean;
64+ getConfig: () => WorkspaceConfigNormalized;
65+ getInstanceId: () => string;
66+ isActiveInstance: () => boolean;
67+}
68+
69+const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
70+ enableZellijControls: true,
71+ paneMode: {
72+ activationShortcut: 'cmdorctrl+p',
73+ timeoutMs: 3000,
74+ actionBindings: {
75+ arrowright: 'split-right',
76+ arrowleft: 'split-left',
77+ arrowup: 'split-up',
78+ arrowdown: 'split-down',
79+ r: 'split-right',
80+ l: 'split-left',
81+ u: 'split-up',
82+ d: 'split-down',
83+ x: 'close-pane',
84+ enter: 'confirm',
85+ escape: 'cancel'
86+ }
87+ },
88+ tabs: {
89+ newTab: 'cmdorctrl+t',
90+ closeTab: 'cmdorctrl+w',
91+ nextTab: 'cmdorctrl+tab',
92+ previousTab: 'cmdorctrl+shift+tab'
93+ },
94+ popups: {
95+ placement: 'right',
96+ openInNewPane: true,
97+ followPaneContext: true
98+ }
99+};
100+
101+function normalizeWorkspaceConfig(payload?: WorkspaceConfigPayload | null): WorkspaceConfigNormalized {
102+ const paneModePayload = payload?.pane_mode ?? {};
103+ const tabsPayload = payload?.tabs ?? {};
104+ const popupsPayload = payload?.popups ?? {};
105+
106+ return {
107+ enableZellijControls: payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
108+ paneMode: {
109+ activationShortcut: paneModePayload.activation_shortcut ?? DEFAULT_CONFIG.paneMode.activationShortcut,
110+ timeoutMs: paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
111+ actionBindings: {
112+ ...DEFAULT_CONFIG.paneMode.actionBindings,
113+ ...(paneModePayload.action_bindings ?? {})
114+ }
115+ },
116+ tabs: {
117+ newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
118+ closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
119+ nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
120+ previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab
121+ },
122+ popups: {
123+ placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
124+ openInNewPane: popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
125+ followPaneContext: popupsPayload.follow_pane_context ?? DEFAULT_CONFIG.popups.followPaneContext
126+ }
127+ };
128+}
129+
130+class WorkspaceController implements WorkspaceRuntime {
131+ private config: WorkspaceConfigNormalized = DEFAULT_CONFIG;
132+ private paneModeActive = false;
133+ private paneModeTimer: ReturnType<typeof setTimeout> | null = null;
134+ private instanceId: string;
135+ private lastActiveCheckTime = 0;
136+
137+ constructor() {
138+ // Generate unique instance ID for this webview
139+ this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;
140+ console.log(`[workspace] Created WorkspaceController instance: ${this.instanceId}`);
141+ }
142+
143+ getInstanceId(): string {
144+ return this.instanceId;
145+ }
146+
147+ isActiveInstance(): boolean {
148+ // Cache active check for 50ms to avoid excessive checks
149+ const now = Date.now();
150+ if (now - this.lastActiveCheckTime < 50) {
151+ return true; // Assume active within debounce window
152+ }
153+ this.lastActiveCheckTime = now;
154+
155+ // Check if this webview has focus by attempting to query document.hasFocus()
156+ // In a multi-pane environment, only the focused webview should handle pane mode
157+ try {
158+ const hasFocus = document.hasFocus();
159+ const isVisible = document.visibilityState === 'visible';
160+ const isActive = hasFocus && isVisible;
161+
162+ if (!isActive) {
163+ console.log(`[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`);
164+ }
165+
166+ return isActive;
167+ } catch (error) {
168+ console.warn('[workspace] Failed to check active state, assuming active:', error);
169+ return true; // Fallback to active if check fails
170+ }
171+ }
172+
173+ configure(payload?: WorkspaceConfigPayload | null): void {
174+ const normalized = normalizeWorkspaceConfig(payload);
175+ this.unregisterShortcuts();
176+ this.config = normalized;
177+
178+ if (!normalized.enableZellijControls) {
179+ console.log('[workspace] Zellij controls disabled via config');
180+ window.__dumber_workspace = this.exportRuntime();
181+ return;
182+ }
183+
184+ this.registerShortcuts();
185+ window.__dumber_workspace = this.exportRuntime();
186+ console.log('[workspace] Zellij controls enabled with config:', this.config);
187+ }
188+
189+ private bridge(message: Record<string, unknown>): void {
190+ const bridge = window.webkit?.messageHandlers?.dumber;
191+ if (!bridge || typeof bridge.postMessage !== 'function') {
192+ return;
193+ }
194+
195+ try {
196+ bridge.postMessage(
197+ JSON.stringify({
198+ type: 'workspace',
199+ ...message
200+ })
201+ );
202+ } catch (error) {
203+ console.error('[workspace] Failed to post workspace message', error);
204+ }
205+ }
206+
207+ enterPaneMode(): void {
208+ if (!this.config.enableZellijControls) return;
209+
210+ // Only allow the active instance to enter pane mode
211+ if (!this.isActiveInstance()) {
212+ console.log(`[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`);
213+ return;
214+ }
215+
216+ console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
217+ this.paneModeActive = true;
218+ this.restartPaneModeTimer();
219+ this.emitWorkspaceEvent('pane-mode-entered', { action: 'enter', config: this.config, instanceId: this.instanceId });
220+ this.bridge({ event: 'pane-mode-entered', instanceId: this.instanceId });
221+ this.showToast('Pane mode: awaiting direction');
222+ }
223+
224+ exitPaneMode(reason: string = 'exit'): void {
225+ if (!this.paneModeActive) return;
226+
227+ this.paneModeActive = false;
228+ if (this.paneModeTimer) {
229+ clearTimeout(this.paneModeTimer);
230+ this.paneModeTimer = null;
231+ }
232+ this.emitWorkspaceEvent('pane-mode-exited', { action: reason });
233+ this.bridge({ event: 'pane-mode-exited', reason });
234+ }
235+
236+ isPaneModeActive(): boolean {
237+ return this.paneModeActive;
238+ }
239+
240+ getConfig(): WorkspaceConfigNormalized {
241+ return this.config;
242+ }
243+
244+ private registerShortcuts(): void {
245+ keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [
246+ {
247+ key: this.config.paneMode.activationShortcut,
248+ handler: () => this.enterPaneMode(),
249+ description: 'Enter pane mode',
250+ preventDefault: true,
251+ stopPropagation: true,
252+ whenFocused: false
253+ }
254+ ]);
255+
256+ const paneShortcuts = Object.entries(this.config.paneMode.actionBindings).map(([key, action]) => ({
257+ key,
258+ handler: () => this.handlePaneAction(action, key),
259+ description: `Pane action: ${action}`,
260+ preventDefault: true,
261+ stopPropagation: true,
262+ whenFocused: false
263+ }));
264+
265+ keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);
266+ }
267+
268+ private unregisterShortcuts(): void {
269+ keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID);
270+ keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID);
271+ }
272+
273+ private handlePaneAction(action: string, key?: string): void {
274+ if (!this.paneModeActive) {
275+ return;
276+ }
277+
278+ switch (action) {
279+ case 'split-right':
280+ this.emitWorkspaceEvent('pane-split', { direction: 'right', config: this.config });
281+ this.bridge({ event: 'pane-split', direction: 'right' });
282+ this.showToast('Split pane to the right');
283+ this.exitPaneMode('split-right');
284+ break;
285+ case 'split-left':
286+ this.emitWorkspaceEvent('pane-split', { direction: 'left', config: this.config });
287+ this.bridge({ event: 'pane-split', direction: 'left' });
288+ this.showToast('Split pane to the left');
289+ this.exitPaneMode('split-left');
290+ break;
291+ case 'split-up':
292+ this.emitWorkspaceEvent('pane-split', { direction: 'up', config: this.config });
293+ this.bridge({ event: 'pane-split', direction: 'up' });
294+ this.showToast('Split pane upwards');
295+ this.exitPaneMode('split-up');
296+ break;
297+ case 'split-down':
298+ this.emitWorkspaceEvent('pane-split', { direction: 'down', config: this.config });
299+ this.bridge({ event: 'pane-split', direction: 'down' });
300+ this.showToast('Split pane downwards');
301+ this.exitPaneMode('split-down');
302+ break;
303+ case 'close-pane':
304+ this.emitWorkspaceEvent('pane-closed', { key, config: this.config });
305+ this.bridge({ event: 'pane-close', action });
306+ this.showToast('Closed pane');
307+ this.exitPaneMode('close-pane');
308+ break;
309+ case 'confirm':
310+ this.bridge({ event: 'pane-confirmed' });
311+ this.showToast('Pane mode confirmed');
312+ this.exitPaneMode('confirm');
313+ break;
314+ case 'cancel':
315+ this.bridge({ event: 'pane-cancelled' });
316+ this.showToast('Pane mode cancelled');
317+ this.exitPaneMode('cancel');
318+ break;
319+ default:
320+ console.warn('[workspace] Unhandled pane action:', action);
321+ }
322+ }
323+
324+ private restartPaneModeTimer(): void {
325+ if (this.paneModeTimer) {
326+ clearTimeout(this.paneModeTimer);
327+ }
328+
329+ if (this.config.paneMode.timeoutMs > 0) {
330+ this.paneModeTimer = setTimeout(() => {
331+ this.showToast('Pane mode timed out');
332+ this.exitPaneMode('timeout');
333+ }, this.config.paneMode.timeoutMs);
334+ }
335+ }
336+
337+ private showToast(message: string): void {
338+ if (typeof window.__dumber_showToast === 'function') {
339+ window.__dumber_showToast(message, 2000, 'info');
340+ } else {
341+ console.log('[workspace toast]', message);
342+ }
343+ }
344+
345+ private emitWorkspaceEvent(event: string, detail: Record<string, unknown>): void {
346+ document.dispatchEvent(new CustomEvent(`dumber:workspace-${event}`, { detail }));
347+ }
348+
349+ private exportRuntime(): WorkspaceRuntime {
350+ return {
351+ enterPaneMode: () => this.enterPaneMode(),
352+ exitPaneMode: (reason?: string) => this.exitPaneMode(reason ?? 'external'),
353+ isPaneModeActive: () => this.isPaneModeActive(),
354+ getConfig: () => this.getConfig(),
355+ getInstanceId: () => this.getInstanceId(),
356+ isActiveInstance: () => this.isActiveInstance()
357+ };
358+ }
359+}
360+
361+const workspaceController = new WorkspaceController();
362+let listenersAttached = false;
363+
364+export function initializeWorkspace(payload?: WorkspaceConfigPayload | null): void {
365+ if (!listenersAttached) {
366+ document.addEventListener('dumber:workspace-config', (event: Event) => {
367+ const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
368+ workspaceController.configure(detail);
369+ });
370+ listenersAttached = true;
371+ }
372+
373+ const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null;
374+ workspaceController.configure(bootstrapPayload);
375+}
376+
377+export { workspaceController };
@@ -0,0 +1,377 @@
1+/**
2+ * Workspace Controller Module
3+ *
4+ * Provides the initial scaffolding for Zellij-inspired pane management.
5+ * Currently tracks pane mode state, exposes configuration to the page,
6+ * and wires keyboard service shortcuts based on configuration defaults.
7+ */
8+
9+import { keyboardService } from '$lib/keyboard';
10+
11+const ACTIVATION_COMPONENT_ID = 'workspace-pane-activation';
12+const ACTION_COMPONENT_ID = 'workspace-pane-actions';
13+
14+export interface WorkspaceConfigPayload {
15+ enable_zellij_controls?: boolean;
16+ pane_mode?: {
17+ activation_shortcut?: string;
18+ timeout_ms?: number;
19+ action_bindings?: Record<string, string>;
20+ };
21+ tabs?: {
22+ new_tab?: string;
23+ close_tab?: string;
24+ next_tab?: string;
25+ previous_tab?: string;
26+ };
27+ popups?: {
28+ placement?: string;
29+ open_in_new_pane?: boolean;
30+ follow_pane_context?: boolean;
31+ };
32+}
33+
34+export interface WorkspacePaneModeConfig {
35+ activationShortcut: string;
36+ timeoutMs: number;
37+ actionBindings: Record<string, string>;
38+}
39+
40+export interface WorkspaceTabsConfig {
41+ newTab: string;
42+ closeTab: string;
43+ nextTab: string;
44+ previousTab: string;
45+}
46+
47+export interface WorkspacePopupConfig {
48+ placement: string;
49+ openInNewPane: boolean;
50+ followPaneContext: boolean;
51+}
52+
53+export interface WorkspaceConfigNormalized {
54+ enableZellijControls: boolean;
55+ paneMode: WorkspacePaneModeConfig;
56+ tabs: WorkspaceTabsConfig;
57+ popups: WorkspacePopupConfig;
58+}
59+
60+export interface WorkspaceRuntime {
61+ enterPaneMode: () => void;
62+ exitPaneMode: (reason?: string) => void;
63+ isPaneModeActive: () => boolean;
64+ getConfig: () => WorkspaceConfigNormalized;
65+ getInstanceId: () => string;
66+ isActiveInstance: () => boolean;
67+}
68+
69+const DEFAULT_CONFIG: WorkspaceConfigNormalized = {
70+ enableZellijControls: true,
71+ paneMode: {
72+ activationShortcut: 'cmdorctrl+p',
73+ timeoutMs: 3000,
74+ actionBindings: {
75+ arrowright: 'split-right',
76+ arrowleft: 'split-left',
77+ arrowup: 'split-up',
78+ arrowdown: 'split-down',
79+ r: 'split-right',
80+ l: 'split-left',
81+ u: 'split-up',
82+ d: 'split-down',
83+ x: 'close-pane',
84+ enter: 'confirm',
85+ escape: 'cancel'
86+ }
87+ },
88+ tabs: {
89+ newTab: 'cmdorctrl+t',
90+ closeTab: 'cmdorctrl+w',
91+ nextTab: 'cmdorctrl+tab',
92+ previousTab: 'cmdorctrl+shift+tab'
93+ },
94+ popups: {
95+ placement: 'right',
96+ openInNewPane: true,
97+ followPaneContext: true
98+ }
99+};
100+
101+function normalizeWorkspaceConfig(payload?: WorkspaceConfigPayload | null): WorkspaceConfigNormalized {
102+ const paneModePayload = payload?.pane_mode ?? {};
103+ const tabsPayload = payload?.tabs ?? {};
104+ const popupsPayload = payload?.popups ?? {};
105+
106+ return {
107+ enableZellijControls: payload?.enable_zellij_controls ?? DEFAULT_CONFIG.enableZellijControls,
108+ paneMode: {
109+ activationShortcut: paneModePayload.activation_shortcut ?? DEFAULT_CONFIG.paneMode.activationShortcut,
110+ timeoutMs: paneModePayload.timeout_ms ?? DEFAULT_CONFIG.paneMode.timeoutMs,
111+ actionBindings: {
112+ ...DEFAULT_CONFIG.paneMode.actionBindings,
113+ ...(paneModePayload.action_bindings ?? {})
114+ }
115+ },
116+ tabs: {
117+ newTab: tabsPayload.new_tab ?? DEFAULT_CONFIG.tabs.newTab,
118+ closeTab: tabsPayload.close_tab ?? DEFAULT_CONFIG.tabs.closeTab,
119+ nextTab: tabsPayload.next_tab ?? DEFAULT_CONFIG.tabs.nextTab,
120+ previousTab: tabsPayload.previous_tab ?? DEFAULT_CONFIG.tabs.previousTab
121+ },
122+ popups: {
123+ placement: popupsPayload.placement ?? DEFAULT_CONFIG.popups.placement,
124+ openInNewPane: popupsPayload.open_in_new_pane ?? DEFAULT_CONFIG.popups.openInNewPane,
125+ followPaneContext: popupsPayload.follow_pane_context ?? DEFAULT_CONFIG.popups.followPaneContext
126+ }
127+ };
128+}
129+
130+class WorkspaceController implements WorkspaceRuntime {
131+ private config: WorkspaceConfigNormalized = DEFAULT_CONFIG;
132+ private paneModeActive = false;
133+ private paneModeTimer: ReturnType<typeof setTimeout> | null = null;
134+ private instanceId: string;
135+ private lastActiveCheckTime = 0;
136+
137+ constructor() {
138+ // Generate unique instance ID for this webview
139+ this.instanceId = `workspace-${Date.now()}-${Math.random().toString(36).substring(2)}`;
140+ console.log(`[workspace] Created WorkspaceController instance: ${this.instanceId}`);
141+ }
142+
143+ getInstanceId(): string {
144+ return this.instanceId;
145+ }
146+
147+ isActiveInstance(): boolean {
148+ // Cache active check for 50ms to avoid excessive checks
149+ const now = Date.now();
150+ if (now - this.lastActiveCheckTime < 50) {
151+ return true; // Assume active within debounce window
152+ }
153+ this.lastActiveCheckTime = now;
154+
155+ // Check if this webview has focus by attempting to query document.hasFocus()
156+ // In a multi-pane environment, only the focused webview should handle pane mode
157+ try {
158+ const hasFocus = document.hasFocus();
159+ const isVisible = document.visibilityState === 'visible';
160+ const isActive = hasFocus && isVisible;
161+
162+ if (!isActive) {
163+ console.log(`[workspace] Instance ${this.instanceId} not active: focus=${hasFocus} visible=${isVisible}`);
164+ }
165+
166+ return isActive;
167+ } catch (error) {
168+ console.warn('[workspace] Failed to check active state, assuming active:', error);
169+ return true; // Fallback to active if check fails
170+ }
171+ }
172+
173+ configure(payload?: WorkspaceConfigPayload | null): void {
174+ const normalized = normalizeWorkspaceConfig(payload);
175+ this.unregisterShortcuts();
176+ this.config = normalized;
177+
178+ if (!normalized.enableZellijControls) {
179+ console.log('[workspace] Zellij controls disabled via config');
180+ window.__dumber_workspace = this.exportRuntime();
181+ return;
182+ }
183+
184+ this.registerShortcuts();
185+ window.__dumber_workspace = this.exportRuntime();
186+ console.log('[workspace] Zellij controls enabled with config:', this.config);
187+ }
188+
189+ private bridge(message: Record<string, unknown>): void {
190+ const bridge = window.webkit?.messageHandlers?.dumber;
191+ if (!bridge || typeof bridge.postMessage !== 'function') {
192+ return;
193+ }
194+
195+ try {
196+ bridge.postMessage(
197+ JSON.stringify({
198+ type: 'workspace',
199+ ...message
200+ })
201+ );
202+ } catch (error) {
203+ console.error('[workspace] Failed to post workspace message', error);
204+ }
205+ }
206+
207+ enterPaneMode(): void {
208+ if (!this.config.enableZellijControls) return;
209+
210+ // Only allow the active instance to enter pane mode
211+ if (!this.isActiveInstance()) {
212+ console.log(`[workspace] Instance ${this.instanceId} ignoring pane mode - not active instance`);
213+ return;
214+ }
215+
216+ console.log(`[workspace] Instance ${this.instanceId} entering pane mode`);
217+ this.paneModeActive = true;
218+ this.restartPaneModeTimer();
219+ this.emitWorkspaceEvent('pane-mode-entered', { action: 'enter', config: this.config, instanceId: this.instanceId });
220+ this.bridge({ event: 'pane-mode-entered', instanceId: this.instanceId });
221+ this.showToast('Pane mode: awaiting direction');
222+ }
223+
224+ exitPaneMode(reason: string = 'exit'): void {
225+ if (!this.paneModeActive) return;
226+
227+ this.paneModeActive = false;
228+ if (this.paneModeTimer) {
229+ clearTimeout(this.paneModeTimer);
230+ this.paneModeTimer = null;
231+ }
232+ this.emitWorkspaceEvent('pane-mode-exited', { action: reason });
233+ this.bridge({ event: 'pane-mode-exited', reason });
234+ }
235+
236+ isPaneModeActive(): boolean {
237+ return this.paneModeActive;
238+ }
239+
240+ getConfig(): WorkspaceConfigNormalized {
241+ return this.config;
242+ }
243+
244+ private registerShortcuts(): void {
245+ keyboardService.registerShortcuts(ACTIVATION_COMPONENT_ID, [
246+ {
247+ key: this.config.paneMode.activationShortcut,
248+ handler: () => this.enterPaneMode(),
249+ description: 'Enter pane mode',
250+ preventDefault: true,
251+ stopPropagation: true,
252+ whenFocused: false
253+ }
254+ ]);
255+
256+ const paneShortcuts = Object.entries(this.config.paneMode.actionBindings).map(([key, action]) => ({
257+ key,
258+ handler: () => this.handlePaneAction(action, key),
259+ description: `Pane action: ${action}`,
260+ preventDefault: true,
261+ stopPropagation: true,
262+ whenFocused: false
263+ }));
264+
265+ keyboardService.registerShortcuts(ACTION_COMPONENT_ID, paneShortcuts);
266+ }
267+
268+ private unregisterShortcuts(): void {
269+ keyboardService.unregisterShortcuts(ACTIVATION_COMPONENT_ID);
270+ keyboardService.unregisterShortcuts(ACTION_COMPONENT_ID);
271+ }
272+
273+ private handlePaneAction(action: string, key?: string): void {
274+ if (!this.paneModeActive) {
275+ return;
276+ }
277+
278+ switch (action) {
279+ case 'split-right':
280+ this.emitWorkspaceEvent('pane-split', { direction: 'right', config: this.config });
281+ this.bridge({ event: 'pane-split', direction: 'right' });
282+ this.showToast('Split pane to the right');
283+ this.exitPaneMode('split-right');
284+ break;
285+ case 'split-left':
286+ this.emitWorkspaceEvent('pane-split', { direction: 'left', config: this.config });
287+ this.bridge({ event: 'pane-split', direction: 'left' });
288+ this.showToast('Split pane to the left');
289+ this.exitPaneMode('split-left');
290+ break;
291+ case 'split-up':
292+ this.emitWorkspaceEvent('pane-split', { direction: 'up', config: this.config });
293+ this.bridge({ event: 'pane-split', direction: 'up' });
294+ this.showToast('Split pane upwards');
295+ this.exitPaneMode('split-up');
296+ break;
297+ case 'split-down':
298+ this.emitWorkspaceEvent('pane-split', { direction: 'down', config: this.config });
299+ this.bridge({ event: 'pane-split', direction: 'down' });
300+ this.showToast('Split pane downwards');
301+ this.exitPaneMode('split-down');
302+ break;
303+ case 'close-pane':
304+ this.emitWorkspaceEvent('pane-closed', { key, config: this.config });
305+ this.bridge({ event: 'pane-close', action });
306+ this.showToast('Closed pane');
307+ this.exitPaneMode('close-pane');
308+ break;
309+ case 'confirm':
310+ this.bridge({ event: 'pane-confirmed' });
311+ this.showToast('Pane mode confirmed');
312+ this.exitPaneMode('confirm');
313+ break;
314+ case 'cancel':
315+ this.bridge({ event: 'pane-cancelled' });
316+ this.showToast('Pane mode cancelled');
317+ this.exitPaneMode('cancel');
318+ break;
319+ default:
320+ console.warn('[workspace] Unhandled pane action:', action);
321+ }
322+ }
323+
324+ private restartPaneModeTimer(): void {
325+ if (this.paneModeTimer) {
326+ clearTimeout(this.paneModeTimer);
327+ }
328+
329+ if (this.config.paneMode.timeoutMs > 0) {
330+ this.paneModeTimer = setTimeout(() => {
331+ this.showToast('Pane mode timed out');
332+ this.exitPaneMode('timeout');
333+ }, this.config.paneMode.timeoutMs);
334+ }
335+ }
336+
337+ private showToast(message: string): void {
338+ if (typeof window.__dumber_showToast === 'function') {
339+ window.__dumber_showToast(message, 2000, 'info');
340+ } else {
341+ console.log('[workspace toast]', message);
342+ }
343+ }
344+
345+ private emitWorkspaceEvent(event: string, detail: Record<string, unknown>): void {
346+ document.dispatchEvent(new CustomEvent(`dumber:workspace-${event}`, { detail }));
347+ }
348+
349+ private exportRuntime(): WorkspaceRuntime {
350+ return {
351+ enterPaneMode: () => this.enterPaneMode(),
352+ exitPaneMode: (reason?: string) => this.exitPaneMode(reason ?? 'external'),
353+ isPaneModeActive: () => this.isPaneModeActive(),
354+ getConfig: () => this.getConfig(),
355+ getInstanceId: () => this.getInstanceId(),
356+ isActiveInstance: () => this.isActiveInstance()
357+ };
358+ }
359+}
360+
361+const workspaceController = new WorkspaceController();
362+let listenersAttached = false;
363+
364+export function initializeWorkspace(payload?: WorkspaceConfigPayload | null): void {
365+ if (!listenersAttached) {
366+ document.addEventListener('dumber:workspace-config', (event: Event) => {
367+ const detail = (event as CustomEvent<WorkspaceConfigPayload>).detail;
368+ workspaceController.configure(detail);
369+ });
370+ listenersAttached = true;
371+ }
372+
373+ const bootstrapPayload = payload ?? window.__dumber_workspace_config ?? null;
374+ workspaceController.configure(bootstrapPayload);
375+}
376+
377+export { workspaceController };
M gui/src/lib/keyboard/KeyboardService.ts +9 -64
@@ -13,7 +13,7 @@ import type {
1313 KeyboardEvent,
1414 KeyboardEventType
1515 } from './types';
16-import { COMMON_SHORTCUTS } from './types';
16+// COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
1717
1818 export class KeyboardService {
1919 private components = new Map<string, ComponentShortcuts>();
@@ -362,82 +362,27 @@ export const keyboardService = new KeyboardService({
362362 globalShortcuts: [
363363 // Navigation shortcuts (migrated from controls module)
364364 {
365- key: 'alt+arrowleft',
365+ key: 'cmdorctrl+arrowleft',
366366 handler: () => {
367367 if (window.history.length > 1) {
368368 window.history.back();
369369 }
370370 },
371- description: 'Navigate back (Alt+Left Arrow)'
371+ description: 'Navigate back (Ctrl/Cmd+Left Arrow)'
372372 },
373373 {
374- key: 'alt+arrowright',
374+ key: 'cmdorctrl+arrowright',
375375 handler: () => {
376376 window.history.forward();
377377 },
378- description: 'Navigate forward (Alt+Right Arrow)'
378+ description: 'Navigate forward (Ctrl/Cmd+Right Arrow)'
379379 },
380- // Global UI shortcuts (migrated from UCM JavaScript)
381- {
382- key: COMMON_SHORTCUTS.OMNIBOX_OPEN,
383- handler: () => {
384- try {
385- if (window.__dumber_omnibox?.toggle) {
386- console.log('🎯 Using omnibox API directly');
387- window.__dumber_omnibox.toggle();
388- return;
389- }
390-
391- console.error('❌ Omnibox toggle requested but API is unavailable');
392- } catch (error) {
393- console.error('❌ Error in omnibox open handler:', error);
394- }
395- },
396- description: 'Open omnibox (Ctrl/Cmd+L)'
397- },
398- {
399- key: COMMON_SHORTCUTS.FIND_OPEN,
400- handler: () => {
401- try {
402- if (window.__dumber_omnibox?.open) {
403- console.log('🔍 Using omnibox find API directly');
404- window.__dumber_omnibox.open('find');
405- return;
406- }
407-
408- console.error('❌ Omnibox find requested but API is unavailable');
409- } catch (error) {
410- console.error('❌ Error in find open handler:', error);
411- }
412- },
413- description: 'Open find bar (Ctrl/Cmd+F)'
414- },
415- // Copy URL shortcut (Ctrl/Cmd+Shift+C)
416- {
417- key: 'cmdorctrl+shift+c',
418- handler: async () => {
419- try {
420- const url = window.location.href;
421- await navigator.clipboard.writeText(url);
422-
423- // Show toast notification
424- if (typeof window.__dumber_showToast === 'function') {
425- window.__dumber_showToast('URL copied to clipboard!', 2000, 'success');
426- }
427- } catch (error) {
428- console.error('Failed to copy URL:', error);
429-
430- // Show error toast
431- if (typeof window.__dumber_showToast === 'function') {
432- window.__dumber_showToast('Failed to copy URL', 2000, 'error');
433- }
434- }
435- },
436- description: 'Copy current URL to clipboard (Ctrl/Cmd+Shift+C)'
437- }
380+ // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
381+ // directly by the native Go shortcut handlers to ensure proper active pane detection.
382+ // This avoids conflicts between multiple webview instances.
438383 ]
439384 });
440385
441386 // Export types for use in components
442387 export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types';
443-export { COMMON_SHORTCUTS } from './types';
388+// COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers
@@ -13,7 +13,7 @@ import type {
13 KeyboardEvent,13 KeyboardEvent,
14 KeyboardEventType14 KeyboardEventType
15 } from './types';15 } from './types';
16-import { COMMON_SHORTCUTS } from './types';16+// COMMON_SHORTCUTS not needed since global shortcuts moved to native handlers
17 17
18 export class KeyboardService {18 export class KeyboardService {
19 private components = new Map<string, ComponentShortcuts>();19 private components = new Map<string, ComponentShortcuts>();
@@ -362,82 +362,27 @@ export const keyboardService = new KeyboardService({
362 globalShortcuts: [362 globalShortcuts: [
363 // Navigation shortcuts (migrated from controls module)363 // Navigation shortcuts (migrated from controls module)
364 {364 {
365- key: 'alt+arrowleft',365+ key: 'cmdorctrl+arrowleft',
366 handler: () => {366 handler: () => {
367 if (window.history.length > 1) {367 if (window.history.length > 1) {
368 window.history.back();368 window.history.back();
369 }369 }
370 },370 },
371- description: 'Navigate back (Alt+Left Arrow)'371+ description: 'Navigate back (Ctrl/Cmd+Left Arrow)'
372 },372 },
373 {373 {
374- key: 'alt+arrowright',374+ key: 'cmdorctrl+arrowright',
375 handler: () => {375 handler: () => {
376 window.history.forward();376 window.history.forward();
377 },377 },
378- description: 'Navigate forward (Alt+Right Arrow)'378+ description: 'Navigate forward (Ctrl/Cmd+Right Arrow)'
379 },379 },
380- // Global UI shortcuts (migrated from UCM JavaScript)380+ // Note: Global UI shortcuts (Ctrl+L, Ctrl+F, Ctrl+Shift+C) are now handled
381- {381+ // directly by the native Go shortcut handlers to ensure proper active pane detection.
382- key: COMMON_SHORTCUTS.OMNIBOX_OPEN,382+ // This avoids conflicts between multiple webview instances.
383- handler: () => {
384- try {
385- if (window.__dumber_omnibox?.toggle) {
386- console.log('🎯 Using omnibox API directly');
387- window.__dumber_omnibox.toggle();
388- return;
389- }
390-
391- console.error('❌ Omnibox toggle requested but API is unavailable');
392- } catch (error) {
393- console.error('❌ Error in omnibox open handler:', error);
394- }
395- },
396- description: 'Open omnibox (Ctrl/Cmd+L)'
397- },
398- {
399- key: COMMON_SHORTCUTS.FIND_OPEN,
400- handler: () => {
401- try {
402- if (window.__dumber_omnibox?.open) {
403- console.log('🔍 Using omnibox find API directly');
404- window.__dumber_omnibox.open('find');
405- return;
406- }
407-
408- console.error('❌ Omnibox find requested but API is unavailable');
409- } catch (error) {
410- console.error('❌ Error in find open handler:', error);
411- }
412- },
413- description: 'Open find bar (Ctrl/Cmd+F)'
414- },
415- // Copy URL shortcut (Ctrl/Cmd+Shift+C)
416- {
417- key: 'cmdorctrl+shift+c',
418- handler: async () => {
419- try {
420- const url = window.location.href;
421- await navigator.clipboard.writeText(url);
422-
423- // Show toast notification
424- if (typeof window.__dumber_showToast === 'function') {
425- window.__dumber_showToast('URL copied to clipboard!', 2000, 'success');
426- }
427- } catch (error) {
428- console.error('Failed to copy URL:', error);
429-
430- // Show error toast
431- if (typeof window.__dumber_showToast === 'function') {
432- window.__dumber_showToast('Failed to copy URL', 2000, 'error');
433- }
434- }
435- },
436- description: 'Copy current URL to clipboard (Ctrl/Cmd+Shift+C)'
437- }
438 ]383 ]
439 });384 });
440 385
441 // Export types for use in components386 // Export types for use in components
442 export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types';387 export type { ShortcutConfig, ComponentShortcuts, KeyboardServiceConfig } from './types';
443-export { COMMON_SHORTCUTS } from './types';388+// COMMON_SHORTCUTS export not needed since global shortcuts moved to native handlers

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close